mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894caf77ce | |||
| b06c210adf | |||
| b8d52ffe5b | |||
| 943cebec3f | |||
| e0d164588a | |||
| f0f483a310 | |||
| 173bde4872 | |||
| ba7b0c5a36 | |||
| 4a0958d130 | |||
| f61190bfa7 | |||
| 18cb9ec2f9 | |||
| 2112597c9f | |||
| 3383f1470d | |||
| 2a4250fc15 | |||
| 76a9ba70ae | |||
| 08c31f129a | |||
| 13ad481cd8 | |||
| cdd005d3c8 | |||
| c974500011 | |||
| 15d2c323c8 | |||
| 06aebb3d25 | |||
| 7af217cba7 | |||
| 28e84a49b6 | |||
| 3f4b3aadba | |||
| c5068a781f | |||
| 3b6e41561e | |||
| 76e482ea42 | |||
| e23c171a6d | |||
| 431e5467ad | |||
| 1cc58b2dad | |||
| 48f4e2e9b7 | |||
| ce43aa4907 | |||
| b841e25e01 | |||
| 9045fb9fa5 | |||
| c2cd755e63 | |||
| 434ce366ee | |||
| 434795976f | |||
| ec016ca003 | |||
| eb3085fa57 | |||
| 9bee6ff9f3 | |||
| 0b568c1e24 | |||
| aea3475e9f | |||
| 0d8ef517e0 | |||
| 0acb40c97f | |||
| 2a4e644c83 | |||
| a0ade39dde | |||
| 4c31ccd5fb | |||
| 394845f9eb | |||
| 8540f11b41 | |||
| 3ed29e2f83 | |||
| 0aab229825 | |||
| 540f9f3f1d |
@@ -0,0 +1,7 @@
|
||||
# copy this file to .env when you want to develop the desktop app or you will fail
|
||||
APP_URL=http://localhost:3020
|
||||
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
|
||||
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
|
||||
NEXT_PUBLIC_SERVICE_MODE='server'
|
||||
NEXT_PUBLIC_IS_DESKTOP_APP=0
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
|
||||
@@ -109,3 +109,12 @@ CLAUDE.local.md
|
||||
*.ppt*
|
||||
*.doc*
|
||||
*.xls*
|
||||
|
||||
|
||||
# for local prd docs
|
||||
docs/prd
|
||||
|
||||
# eas
|
||||
service-account-key.json
|
||||
|
||||
repomix-output-lobehub-lobe-ui.xml
|
||||
|
||||
@@ -3,6 +3,7 @@ resolution-mode=highest
|
||||
|
||||
ignore-workspace-root-check=true
|
||||
enable-pre-post-scripts=true
|
||||
strict-store-pkg-content-check=false
|
||||
|
||||
public-hoist-pattern[]=*@umijs/lint*
|
||||
public-hoist-pattern[]=*changelog*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Create development builds
|
||||
|
||||
jobs:
|
||||
android_development_build:
|
||||
name: Build Android
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: development
|
||||
ios_device_development_build:
|
||||
name: Build iOS device
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development
|
||||
ios_simulator_development_build:
|
||||
name: Build iOS simulator
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development-simulator
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Create Production Builds
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
type: build # This job type creates a production build for Android
|
||||
params:
|
||||
platform: android
|
||||
build_ios:
|
||||
type: build # This job type creates a production build for iOS
|
||||
params:
|
||||
platform: ios
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Deploy to production
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
fingerprint:
|
||||
name: Fingerprint
|
||||
type: fingerprint
|
||||
get_android_build:
|
||||
name: Check for existing android build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
|
||||
profile: production
|
||||
get_ios_build:
|
||||
name: Check for existing ios build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
|
||||
profile: production
|
||||
build_android:
|
||||
name: Build Android
|
||||
needs: [get_android_build]
|
||||
if: ${{ !needs.get_android_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
build_ios:
|
||||
name: Build iOS
|
||||
needs: [get_ios_build]
|
||||
if: ${{ !needs.get_ios_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
submit_android_build:
|
||||
name: Submit Android Build
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
submit_ios_build:
|
||||
name: Submit iOS Build
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
publish_android_update:
|
||||
name: Publish Android update
|
||||
needs: [get_android_build]
|
||||
if: ${{ needs.get_android_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: android
|
||||
publish_ios_update:
|
||||
name: Publish iOS update
|
||||
needs: [get_ios_build]
|
||||
if: ${{ needs.get_ios_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: ios
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Publish preview update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
publish_preview_update:
|
||||
name: Publish preview update
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
name: Build Android app
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit_android:
|
||||
name: Submit to Google Play Store
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
platform: android
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_ios:
|
||||
name: Build iOS app
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit_ios:
|
||||
name: Submit to Apple App Store
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
platform: ios
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
@@ -0,0 +1,12 @@
|
||||
# OAuth 2.0 OIDC 认证配置
|
||||
# 客户端 ID - 从认证服务器获取
|
||||
EXPO_PUBLIC_OAUTH_CLIENT_ID=lobehub-mobile
|
||||
|
||||
# 认证服务器地址
|
||||
EXPO_PUBLIC_OAUTH_ISSUER=https://lobechat.com
|
||||
|
||||
# 官方云服务器地址
|
||||
EXPO_PUBLIC_OFFICIAL_CLOUD_SERVER=https://lobechat.com
|
||||
|
||||
# 回调地址 - 必须与认证服务器配置一致
|
||||
EXPO_PUBLIC_OAUTH_REDIRECT_URI=lobechat://auth/callback
|
||||
@@ -0,0 +1,35 @@
|
||||
# Eslintignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
|
||||
# test
|
||||
jest*
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
!.dumirc.ts
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
ios
|
||||
android
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
.expo
|
||||
|
||||
polyfills.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: '../../.eslintrc.js',
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
package-lock.json
|
||||
# pnpm-lock.yaml
|
||||
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
android
|
||||
ios
|
||||
yarn.lock
|
||||
repomix-output.xml
|
||||
lobe-ui-repomix-output.xml
|
||||
lobe-chat-repomix-output.xml
|
||||
repomix-output-ant-design-theme.xml
|
||||
credentials.json
|
||||
|
||||
build-*.aab
|
||||
build-*.apk
|
||||
build-*.ipa
|
||||
|
||||
coverage
|
||||
@@ -0,0 +1,49 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/zh-CN',
|
||||
entryLocale: 'zh-CN',
|
||||
output: 'locales',
|
||||
outputLocales: [
|
||||
'ar',
|
||||
'bg-BG',
|
||||
'zh-TW',
|
||||
'en-US',
|
||||
'ru-RU',
|
||||
'ja-JP',
|
||||
'ko-KR',
|
||||
'fr-FR',
|
||||
'tr-TR',
|
||||
'es-ES',
|
||||
'pt-BR',
|
||||
'de-DE',
|
||||
'it-IT',
|
||||
'nl-NL',
|
||||
'pl-PL',
|
||||
'vi-VN',
|
||||
'fa-IR',
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'gpt-4.1-mini',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
markdown: {
|
||||
reference: '你需要保持 mdx 的组件格式,输出文本不需要在最外层包裹任何代码块语法',
|
||||
entry: ['./README.zh-CN.md'],
|
||||
entryLocale: 'zh-CN',
|
||||
outputLocales: ['en-US'],
|
||||
includeMatter: true,
|
||||
exclude: ['./src/**/*'],
|
||||
outputExtensions: (locale, { filePath }) => {
|
||||
if (filePath.includes('.mdx')) {
|
||||
if (locale === 'en-US') return '.mdx';
|
||||
return `.${locale}.mdx`;
|
||||
} else {
|
||||
if (locale === 'en-US') return '.md';
|
||||
return `.${locale}.md`;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
lockfile=true
|
||||
shamefully-hoist=true
|
||||
ignore-workspace-root-check=true
|
||||
@@ -0,0 +1,125 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
.expo/
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Expo
|
||||
.expo-shared/
|
||||
|
||||
# React Native
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# Flipper
|
||||
ios/Pods/
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Generated files
|
||||
*.generated.*
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Package files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
proseWrap: 'never',
|
||||
quoteProps: 'consistent',
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
useTabs: false,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
Apache License Version 2.0
|
||||
|
||||
Copyright (c) 2025/06/17 - current LobeHub LLC. All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,202 @@
|
||||
# LobeChat React Native
|
||||
|
||||
A modern open-source AI chat application built with React Native\
|
||||
Get your own cross-platform ChatGPT/Claude/Gemini app for **free** with one click
|
||||
|
||||
**简体中文** · [English](./README.md)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/releases) [](https://expo.dev) [](https://reactnative.dev) [](https://www.typescriptlang.org)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/stargazers) [](https://github.com/lobehub/lobe-chat-react-native/network/members) [](https://github.com/lobehub/lobe-chat-react-native/issues) [](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
|
||||
|
||||
Explore the limitless possibilities of AI conversations on mobile, crafted for you in an era of individual empowerment.
|
||||
|
||||

|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [✨ Features Overview](#-features-overview)
|
||||
- [📱 Supported Platforms](#-supported-platforms)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [🛠️ Technology Stack](#️-technology-stack)
|
||||
- [📦 Project Structure](#-project-structure)
|
||||
- [⌨️ Local Development](#️-local-development)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [🔗 More Tools](#-more-tools)
|
||||
|
||||
## ✨ Features Overview
|
||||
|
||||
### `1` Cross-Platform Support
|
||||
|
||||
Built with React Native and Expo, fully supporting both iOS and Android platforms with a single codebase running on multiple devices.
|
||||
|
||||
### `2` Modern UI Design
|
||||
|
||||
- 💎 **Refined UI**: Carefully crafted interface with elegant visuals and smooth interactions
|
||||
- 🌗 **Dark/Light Themes**: Supports theme switching and adapts to system preferences
|
||||
- 📱 **Mobile Optimization**: Deeply optimized for mobile devices, delivering a native app-like experience
|
||||
|
||||
### `3` Multi-Model Provider Support
|
||||
|
||||
Supports a variety of mainstream AI service providers:
|
||||
|
||||
- **OpenAI**: GPT-4, GPT-3.5, and more
|
||||
- **Anthropic**: Claude series models
|
||||
- **Google**: Gemini series models
|
||||
- **Local Models**: Supports local LLMs like Ollama
|
||||
|
||||
### `4` Powerful Conversation Features
|
||||
|
||||
- 🗣️ **Smooth Chat Experience**: Supports streaming responses for real-time AI replies
|
||||
- 📝 **Markdown Rendering**: Full support for Markdown formatting, including code highlighting
|
||||
- 🎨 **Code Syntax Highlighting**: Professional code rendering powered by Shiki
|
||||
- 🔊 **Voice Interaction**: Supports text-to-speech and speech-to-text functionality
|
||||
|
||||
### `5` Security and Privacy
|
||||
|
||||
- 🔒 **Data Security**: Supports local data storage to protect user privacy
|
||||
- 💾 **Offline Support**: Important data cached locally so you can view chat history offline
|
||||
|
||||
### `6` Developer Friendly
|
||||
|
||||
- 🛠️ **TypeScript**: Full type support for a better development experience
|
||||
- 📦 **Modular Architecture**: Clear project structure for easy maintenance and extensibility
|
||||
- 🧪 **Testing Support**: Built-in Jest testing framework
|
||||
- 📱 **Hot Reloading**: Real-time preview during development
|
||||
|
||||
## 📱 Supported Platforms
|
||||
|
||||
| Platform | Status | Version Requirement |
|
||||
| -------- | ------------ | --------------------- |
|
||||
| iOS | ✅ Supported | iOS 13.4+ |
|
||||
| Android | ✅ Supported | Android 6.0+ (API 23) |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Environment Requirements
|
||||
|
||||
Before getting started, please ensure your development environment meets the following requirements:
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **pnpm**: >= 8.0.0 (recommended)
|
||||
- **Expo CLI**: Latest version
|
||||
- **iOS**: Xcode 14+ (macOS only)
|
||||
- **Android**: Android Studio
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/lobehub/lobe-chat-react-native.git
|
||||
cd lobe-chat-react-native
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Configure Environment Variables
|
||||
|
||||
```bash
|
||||
# Copy the environment variable template
|
||||
cp .env.example .env.local
|
||||
|
||||
# Edit the environment variables and add your API keys
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
```
|
||||
|
||||
### Start the Development Server
|
||||
|
||||
```bash
|
||||
# Start the Expo development server
|
||||
pnpm start
|
||||
|
||||
# Or run on a specific platform directly
|
||||
pnpm run ios # iOS simulator
|
||||
pnpm run android # Android emulator
|
||||
```
|
||||
|
||||
### Run on a Physical Device
|
||||
|
||||
1. Install the **Expo Go** app:
|
||||
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||
|
||||
2. Scan the QR code displayed in the terminal to preview on your device
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
| Technology | Version | Description |
|
||||
| --------------------------- | -------- | ----------------------------------------------- |
|
||||
| **React Native** | 0.76.6 | Cross-platform mobile app framework |
|
||||
| **Expo** | \~52.0.5 | React Native development platform and toolchain |
|
||||
| **TypeScript** | ^5.8.2 | Type-safe JavaScript superset |
|
||||
| **Expo Router** | \~4.0.17 | File system-based routing solution |
|
||||
| **Zustand** | ^5.0.3 | Lightweight state management library |
|
||||
| **React Native Reanimated** | \~3.16.7 | High-performance animation library |
|
||||
| **Shiki** | ^3.1.0 | Code syntax highlighting engine |
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
### Development Scripts
|
||||
|
||||
```bash
|
||||
# Start the development server
|
||||
pnpm start
|
||||
|
||||
# Run on iOS simulator
|
||||
pnpm run ios
|
||||
|
||||
# Run on Android emulator
|
||||
pnpm run android
|
||||
|
||||
# Run tests
|
||||
pnpm run test
|
||||
|
||||
# Code linting
|
||||
pnpm run lint
|
||||
|
||||
# Build the app
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Code Standards
|
||||
|
||||
This project follows strict code standards:
|
||||
|
||||
- **ESLint** + **Prettier** for code formatting
|
||||
- **TypeScript** strict type checking
|
||||
- **Git Hooks** for pre-commit checks
|
||||
- **Conventional Commits** for commit message conventions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We warmly welcome all forms of contributions!
|
||||
|
||||
### Contribution Guide
|
||||
|
||||
1. **Fork** this repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a **Pull Request**
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Please follow the [Conventional Commits](https://conventionalcommits.org/) specification for commit messages
|
||||
- Use Prettier for code formatting and ESLint for code linting
|
||||
- All new features should include corresponding test cases
|
||||
|
||||
## 🔗 More Tools
|
||||
|
||||
| Project | Description |
|
||||
| --- | --- |
|
||||
| **[🤯 Lobe Chat](https://github.com/lobehub/lobe-chat)** | Modern open-source ChatGPT/LLM chat app (Web version) |
|
||||
| **[🅰️ Lobe UI](https://github.com/lobehub/lobe-ui)** | Open-source UI component library for building AIGC web apps |
|
||||
| **[🌏 Lobe i18n](https://github.com/lobehub/lobe-commit/tree/master/packages/lobe-i18n)** | ChatGPT-powered i18n translation automation tool |
|
||||
| **[💌 Lobe Commit](https://github.com/lobehub/lobe-commit)** | AI-based Git commit message generator |
|
||||
|
||||
---
|
||||
|
||||
#### 📝 License
|
||||
|
||||
Copyright © 2025 [LobeHub](https://github.com/lobehub). This project is open source under the [Apache 2.0](./LICENSE) license.
|
||||
@@ -0,0 +1,203 @@
|
||||
# LobeChat React Native
|
||||
|
||||
现代化设计的开源 AI 聊天应用,基于 React Native 构建\
|
||||
一键**免费**拥有你自己的跨平台 ChatGPT/Claude/Gemini 应用
|
||||
|
||||
**简体中文** · [English](./README.md)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/releases) [](https://expo.dev) [](https://reactnative.dev) [](https://www.typescriptlang.org)
|
||||
|
||||
[](https://github.com/lobehub/lobe-chat-react-native/stargazers) [](https://github.com/lobehub/lobe-chat-react-native/network/members) [](https://github.com/lobehub/lobe-chat-react-native/issues) [](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
|
||||
|
||||
探索移动端 AI 对话的无限可能,在个体崛起的时代中为你打造
|
||||
|
||||

|
||||
|
||||
## 目录
|
||||
|
||||
- [✨ 特性一览](#-特性一览)
|
||||
- [📱 支持平台](#-支持平台)
|
||||
- [🚀 快速开始](#-快速开始)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [📦 项目结构](#-项目结构)
|
||||
- [⌨️ 本地开发](#️-本地开发)
|
||||
- [🤝 参与贡献](#-参与贡献)
|
||||
- [🔗 更多工具](#-更多工具)
|
||||
|
||||
## ✨ 特性一览
|
||||
|
||||
### `1` 跨平台支持
|
||||
|
||||
基于 React Native 和 Expo 构建,完美支持 iOS 和 Android 平台,一套代码多端运行。
|
||||
|
||||
### `2` 现代化 UI 设计
|
||||
|
||||
- 💎 **精致 UI 设计**:经过精心设计的界面,具有优雅的外观和流畅的交互效果
|
||||
- 🌗 **深色 / 浅色主题**:支持明暗主题切换,适配系统主题
|
||||
- 📱 **移动端优化**:针对移动设备进行了深度优化,提供原生应用般的体验
|
||||
|
||||
### `3` 多模型服务商支持
|
||||
|
||||
支持多种主流 AI 服务提供商:
|
||||
|
||||
- **OpenAI**:GPT-4、GPT-3.5 等模型
|
||||
- **Anthropic**:Claude 系列模型
|
||||
- **Google**:Gemini 系列模型
|
||||
- **本地模型**:支持 Ollama 等本地 LLM
|
||||
|
||||
### `4` 强大的会话功能
|
||||
|
||||
- 🗣️ **流畅的对话体验**:支持流式响应,实时显示 AI 回复
|
||||
- 📝 **Markdown 渲染**:完整支持 Markdown 格式,包括代码高亮
|
||||
- 🎨 **代码语法高亮**:基于 Shiki 的专业代码渲染
|
||||
- 🔊 **语音交互**:支持文字转语音和语音转文字功能
|
||||
|
||||
### `5` 安全与隐私
|
||||
|
||||
- 🔒 **数据安全**:支持本地数据存储,保护用户隐私
|
||||
- 💾 **离线支持**:重要数据本地缓存,离线也能查看历史对话
|
||||
|
||||
### `6` 开发者友好
|
||||
|
||||
- 🛠️ **TypeScript**:完整的类型支持,提供更好的开发体验
|
||||
- 📦 **模块化架构**:清晰的项目结构,易于维护和扩展
|
||||
- 🧪 **测试支持**:内置 Jest 测试框架
|
||||
- 📱 **热重载**:开发过程中支持实时预览
|
||||
|
||||
## 📱 支持平台
|
||||
|
||||
| 平台 | 状态 | 版本要求 |
|
||||
| ------- | ------- | --------------------- |
|
||||
| iOS | ✅ 支持 | iOS 13.4+ |
|
||||
| Android | ✅ 支持 | Android 6.0+ (API 23) |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
在开始之前,请确保你的开发环境满足以下要求:
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **pnpm**: >= 8.0.0(推荐)
|
||||
- **Expo CLI**: 最新版本
|
||||
- **iOS**: Xcode 14+ (仅限 macOS)
|
||||
- **Android**: Android Studio
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/lobehub/lobe-chat-react-native.git
|
||||
cd lobe-chat-react-native
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env.local
|
||||
|
||||
# 编辑环境变量,填入你的 API 密钥
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 启动 Expo 开发服务器
|
||||
pnpm start
|
||||
|
||||
# 或者直接运行指定平台
|
||||
pnpm run ios # iOS 模拟器
|
||||
pnpm run android # Android 模拟器
|
||||
```
|
||||
|
||||
### 在设备上运行
|
||||
|
||||
1. 安装 **Expo Go** 应用:
|
||||
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||
|
||||
2. 扫描终端中显示的二维码即可在真机上预览
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 技术 | 版本 | 描述 |
|
||||
| --------------------------- | -------- | ----------------------------- |
|
||||
| **React Native** | 0.76.6 | 跨平台移动应用开发框架 |
|
||||
| **Expo** | \~52.0.5 | React Native 开发平台和工具链 |
|
||||
| **TypeScript** | ^5.8.2 | 类型安全的 JavaScript 超集 |
|
||||
| **Expo Router** | \~4.0.17 | 基于文件系统的路由解决方案 |
|
||||
| **Zustand** | ^5.0.3 | 轻量级状态管理库 |
|
||||
| **React Native Reanimated** | \~3.16.7 | 高性能动画库 |
|
||||
| **Shiki** | ^3.1.0 | 代码语法高亮引擎 |
|
||||
|
||||
## ⌨️ 本地开发
|
||||
|
||||
### 开发脚本
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm start
|
||||
|
||||
# 在 iOS 模拟器中运行
|
||||
pnpm run ios
|
||||
|
||||
# 在 Android 模拟器中运行
|
||||
pnpm run android
|
||||
|
||||
# 运行测试
|
||||
pnpm run test
|
||||
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 构建应用
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
本项目遵循严格的代码规范:
|
||||
|
||||
- **ESLint** + **Prettier** 代码格式化
|
||||
- **TypeScript** 严格类型检查
|
||||
- **Git Hooks** 提交前自动检查
|
||||
- **Conventional Commits** 提交信息规范
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
我们非常欢迎各种形式的贡献!
|
||||
|
||||
### 贡献指南
|
||||
|
||||
1. **Fork** 本仓库
|
||||
2. 创建你的特性分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交你的改动 (`git commit -m 'feat: add amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建一个 **Pull Request**
|
||||
|
||||
### 开发规范
|
||||
|
||||
- 提交信息请遵循 [Conventional Commits](https://conventionalcommits.org/) 规范
|
||||
- 代码格式化使用 Prettier,代码检查使用 ESLint
|
||||
- 所有新功能都需要包含相应的测试用例
|
||||
|
||||
## 🔗 更多工具
|
||||
|
||||
| 项目 | 描述 |
|
||||
| --- | --- |
|
||||
| **[🤯 Lobe Chat](https://github.com/lobehub/lobe-chat)** | 现代化设计的开源 ChatGPT/LLM 聊天应用(Web 版) |
|
||||
| **[🅰️ Lobe UI](https://github.com/lobehub/lobe-ui)** | 构建 AIGC 网页应用的开源 UI 组件库 |
|
||||
| **[🌏 Lobe i18n](https://github.com/lobehub/lobe-commit/tree/master/packages/lobe-i18n)** | 由 ChatGPT 驱动的 i18n 翻译自动化工具 |
|
||||
| **[💌 Lobe Commit](https://github.com/lobehub/lobe-commit)** | 基于 AI 的 Git 提交信息生成工具 |
|
||||
|
||||
---
|
||||
|
||||
#### 📝 开源协议
|
||||
|
||||
Copyright © 2025 [LobeHub](https://github.com/lobehub). 本项目基于 [Apache 2.0](./LICENSE) 协议开源.
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "LobeHub",
|
||||
"slug": "lobe-chat-react-native",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "com.lobehub.app",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.lobehub.app",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
},
|
||||
"icon": {
|
||||
"dark": "./assets/images/ios-dark.png",
|
||||
"light": "./assets/images/ios-light.png",
|
||||
"tinted": "./assets/images/ios-tinted.png"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.lobehub.app"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.ico"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon-light.png",
|
||||
"backgroundColor": "#ffffff",
|
||||
"imageWidth": 200,
|
||||
"dark": {
|
||||
"image": "./assets/images/splash-icon-dark.png",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"resizeMode": "contain"
|
||||
}
|
||||
],
|
||||
"expo-font",
|
||||
"expo-secure-store",
|
||||
"expo-localization"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "f02d6f4f-e042-4c95-ba0d-ac06bb474ef0"
|
||||
}
|
||||
},
|
||||
"owner": "lobehub",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/f02d6f4f-e042-4c95-ba0d-ac06bb474ef0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Alert, Image } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth, useAuthActions } from '@/store/user';
|
||||
import Button from '@/components/Button';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const LoginPage = () => {
|
||||
const { t } = useTranslation(['auth', 'error', 'common']);
|
||||
const { isLoading } = useAuth();
|
||||
const { login } = useAuthActions();
|
||||
const router = useRouter();
|
||||
|
||||
const { styles } = useStyles();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await login();
|
||||
// Redirect to home page after successful login
|
||||
router.replace('/chat');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
Alert.alert(t('error.title', { ns: 'error' }), errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* Logo 和标题 */}
|
||||
<View style={styles.welcome}>
|
||||
<Image source={require('../../../assets/images/logo.png')} style={styles.logo} />
|
||||
|
||||
<Text style={styles.title}>LobeChat</Text>
|
||||
<Text style={styles.subtitle}>{t('login.subtitle', { ns: 'auth' })}</Text>
|
||||
</View>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
block
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
onPress={handleLogin}
|
||||
size="large"
|
||||
style={styles.loginButton}
|
||||
type="primary"
|
||||
>
|
||||
{t('login.button', { appName: 'LobeChat.com', ns: 'auth' })}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<View style={styles.securityNote}>
|
||||
<Text style={styles.securityText}>
|
||||
{t('login.securityNote', { ns: 'auth' })}
|
||||
<Link href="https://lobehub.com/terms" style={styles.securityLink}>
|
||||
《{t('login.usePolicy', { ns: 'auth' })}》
|
||||
</Link>
|
||||
{t('and', { ns: 'common' })}
|
||||
<Link href="https://lobehub.com/privacy" style={styles.securityLink}>
|
||||
《{t('login.privacyPolicy', { ns: 'auth' })}》
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createStyles } from '@/theme';
|
||||
import { LOGO_SIZE } from '@/const/common';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
padding: token.paddingXL,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
maxWidth: token.screenSM,
|
||||
width: '100%',
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderRadius: token.borderRadius,
|
||||
marginBottom: token.marginLG,
|
||||
padding: token.padding,
|
||||
width: '100%',
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: token.fontSize,
|
||||
textAlign: 'center',
|
||||
},
|
||||
header: {},
|
||||
loginButton: {
|
||||
marginBottom: token.marginXL,
|
||||
},
|
||||
logo: {
|
||||
height: LOGO_SIZE,
|
||||
marginBottom: token.marginLG,
|
||||
width: LOGO_SIZE,
|
||||
},
|
||||
securityLink: {
|
||||
color: token.colorLink,
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeightSM,
|
||||
textAlign: 'center',
|
||||
},
|
||||
securityNote: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginLG,
|
||||
maxWidth: token.screenSM,
|
||||
width: '100%',
|
||||
},
|
||||
securityText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeightSM,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeHeading1,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginSM,
|
||||
},
|
||||
welcome: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginXXL,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { NavigateBack } from '@/components';
|
||||
|
||||
import { useThemedScreenOptions } from '@/const/navigation';
|
||||
|
||||
export default function RoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
const { t } = useTranslation(['discover']);
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerLeft: () => <NavigateBack />,
|
||||
headerTitle: t('title', { ns: 'discover' }),
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="assistant/detail/index"
|
||||
options={{ headerShown: true, headerTitle: '' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { Avatar } from '@/components';
|
||||
import GitHubAvatar from '@/components/GithubAvatar';
|
||||
import { useStyles } from './styles';
|
||||
import { AVATAR_SIZE_LARGE } from '@/const/common';
|
||||
|
||||
interface DetailHeaderProps {
|
||||
author: string;
|
||||
avatar: string;
|
||||
createdAt: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const DetailHeader: React.FC<DetailHeaderProps> = ({ avatar, title, author, createdAt }) => {
|
||||
const { styles, token } = useStyles();
|
||||
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
<Avatar
|
||||
alt={title}
|
||||
avatar={avatar || '🤖'}
|
||||
backgroundColor={token.colorBgContainer}
|
||||
size={AVATAR_SIZE_LARGE}
|
||||
/>
|
||||
<View style={styles.headerContent}>
|
||||
<Text style={styles.name}>{title || ''}</Text>
|
||||
<View style={styles.authorContainer}>
|
||||
<GitHubAvatar size={20} username={author || 'LobeChat'} />
|
||||
<Text style={styles.authorName}>{author || 'LobeChat'}</Text>
|
||||
<Text style={styles.date}>{dayjs(createdAt).format('YYYY-MM-DD')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailHeader;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
authorContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
marginTop: token.marginXXS,
|
||||
},
|
||||
|
||||
authorName: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
|
||||
date: {
|
||||
color: token.colorTextQuaternary,
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
// Header related styles
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: token.margin,
|
||||
paddingHorizontal: token.marginXXS,
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginLeft: token.margin,
|
||||
},
|
||||
name: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeXL,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXXS,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Skeleton } from '@/components';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
|
||||
// Assistant Detail Page Skeleton Components
|
||||
const AssistantDetailSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View style={{ padding: 16 }}>
|
||||
{/* Title skeleton */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Skeleton.Title animated style={{ height: 28 }} width="80%" />
|
||||
</View>
|
||||
|
||||
{/* Author info skeleton */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar animated shape="circle" size={24} />
|
||||
<View style={{ flex: 1, marginLeft: 8 }}>
|
||||
<Skeleton.Title animated style={{ height: 14 }} width="50%" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Skeleton.Paragraph animated rows={3} width={['100%', '95%', '85%']} />
|
||||
</View>
|
||||
|
||||
{/* Tags skeleton */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{[60, 80, 70, 90].map((width, index) => (
|
||||
<Skeleton.Title
|
||||
animated
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 14,
|
||||
height: 28,
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Action button skeleton */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Skeleton.Title
|
||||
animated
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 8,
|
||||
height: 48,
|
||||
}}
|
||||
width="100%"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* System role section skeleton */}
|
||||
<View>
|
||||
{/* Section header */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar animated shape="square" size={ICON_SIZE} style={{ borderRadius: 4 }} />
|
||||
<View style={{ marginLeft: 8 }}>
|
||||
<Skeleton.Title animated style={{ height: 16 }} width={120} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<Skeleton.Paragraph
|
||||
animated
|
||||
rows={6}
|
||||
width={['100%', '95%', '90%', '100%', '85%', '75%']}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail Title Skeleton
|
||||
export const AssistantTitleSkeleton = () => {
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Skeleton.Title animated style={{ height: 28 }} width="80%" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail Author Skeleton
|
||||
export const AssistantAuthorSkeleton = () => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar animated shape="circle" size={24} />
|
||||
<View style={{ flex: 1, marginLeft: 8 }}>
|
||||
<Skeleton.Title animated style={{ height: 14 }} width="50%" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail Description Skeleton
|
||||
export const AssistantDescriptionSkeleton = () => {
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Skeleton.Paragraph animated rows={3} width={['100%', '95%', '85%']} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail Tags Skeleton
|
||||
export const AssistantTagsSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{[60, 80, 70, 90].map((width, index) => (
|
||||
<Skeleton.Title
|
||||
animated
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 14,
|
||||
height: 28,
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail Action Button Skeleton
|
||||
export const AssistantActionButtonSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Skeleton.Title
|
||||
animated
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 8,
|
||||
height: 48,
|
||||
}}
|
||||
width="100%"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Assistant Detail System Role Skeleton
|
||||
export const AssistantSystemRoleSkeleton = () => {
|
||||
return (
|
||||
<View>
|
||||
{/* Section header */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar animated shape="square" size={ICON_SIZE} style={{ borderRadius: 4 }} />
|
||||
<View style={{ marginLeft: 8 }}>
|
||||
<Skeleton.Title animated style={{ height: 16 }} width={120} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<Skeleton.Paragraph animated rows={6} width={['100%', '95%', '90%', '100%', '85%', '75%']} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantDetailSkeleton;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, ScrollView, Text, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BotMessageSquare } from 'lucide-react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { AssistantService } from '@/services/assistant';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { LobeAgentSession, LobeSessionType } from '@/types/session';
|
||||
import DetailHeader from './components/Header';
|
||||
import SkeletonDetail from './components/SkeletonDetail';
|
||||
import { Tag, Button, Markdown } from '@/components';
|
||||
import { useStyles } from './styles';
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
import { DEFAULT_MODEL } from '@/const/settings';
|
||||
|
||||
const ASSISTANT_DETAIL_KEY = 'discover-assistant-detail';
|
||||
|
||||
const AssistantDetail = () => {
|
||||
const { identifier } = useLocalSearchParams<{ identifier: string }>();
|
||||
const { styles, token } = useStyles();
|
||||
const { t, i18n } = useTranslation(['common', 'discover']);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const { addSession } = useSessionStore();
|
||||
|
||||
const {
|
||||
data: agent,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
[ASSISTANT_DETAIL_KEY, identifier as string, i18n.language],
|
||||
async ([, id, language]: [string, string, string]) => {
|
||||
const assistantService = new AssistantService();
|
||||
const data = await assistantService.getAssistantDetail(id, language);
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
||||
const handleAddAssistant = () => {
|
||||
if (!agent) return;
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
// 创建新的会话
|
||||
const newSession: LobeAgentSession = {
|
||||
createdAt: new Date(),
|
||||
id: `agent-${agent.identifier}-${Date.now()}`,
|
||||
meta: {
|
||||
author: agent.author || 'LobeChat',
|
||||
avatar: agent.meta.avatar || '🤖',
|
||||
description: agent.meta.description,
|
||||
tags: agent.meta.tags || [],
|
||||
title: agent.meta.title,
|
||||
},
|
||||
model: agent.config?.model || DEFAULT_MODEL,
|
||||
pinned: false,
|
||||
type: LobeSessionType.Agent,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// 添加到会话列表
|
||||
addSession(newSession);
|
||||
|
||||
// 导航到会话页面
|
||||
router.replace({
|
||||
params: { id: newSession.id },
|
||||
pathname: '/chat',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(t('assistant.detail.addFailed', { ns: 'discover' }), err);
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('assistant.detail.addFailedMessage', { ns: 'discover' }),
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const handleShare = async () => {
|
||||
// if (!agent) return;
|
||||
|
||||
// try {
|
||||
// await Share.share({
|
||||
// message: `${agent.meta.title} - ${agent.meta.description} #LobeChat`,
|
||||
// url: agent.homepage || `https://chat.lobehub.com`,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('分享失败:', error);
|
||||
// }
|
||||
// };
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView edges={['bottom']} style={styles.safeAreaContainer}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<SkeletonDetail />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<SafeAreaView style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>
|
||||
{!identifier
|
||||
? t('assistant.detail.notFoundIdentifier', { ns: 'discover' })
|
||||
: t('assistant.detail.loadFailed', { ns: 'discover' })}
|
||||
</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取系统角色内容(可能存在于不同的位置)
|
||||
const systemRoleContent = agent.config.systemRole;
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['bottom']} style={styles.safeAreaContainer}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.container}>
|
||||
{/* Header with avatar on left, title/author/date on right */}
|
||||
<DetailHeader
|
||||
author={agent.author || 'LobeChat'}
|
||||
avatar={agent.meta.avatar || '🤖'}
|
||||
createdAt={agent.createdAt}
|
||||
title={agent.meta.title}
|
||||
/>
|
||||
|
||||
{/* 描述信息 */}
|
||||
<Text style={styles.description}>{agent.meta.description}</Text>
|
||||
|
||||
{/* 标签列表 */}
|
||||
{agent.meta.tags && agent.meta.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{agent.meta.tags.map((tag: string) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 添加助手与对话按钮 */}
|
||||
<View style={styles.actionButtonsContainer}>
|
||||
<Button
|
||||
block
|
||||
disabled={isAdding}
|
||||
loading={isAdding}
|
||||
onPress={handleAddAssistant}
|
||||
size="large"
|
||||
type="primary"
|
||||
>
|
||||
{t('assistant.detail.addAndChat', { ns: 'discover' })}
|
||||
</Button>
|
||||
|
||||
{/* <Button
|
||||
type="default"
|
||||
size="large"
|
||||
onPress={handleShare}
|
||||
>
|
||||
<Share2 size={ICON_SIZE_SMALL} color={token.colorText} />
|
||||
</Button> */}
|
||||
</View>
|
||||
|
||||
{/* 系统提示区域 */}
|
||||
{systemRoleContent && (
|
||||
<View style={styles.systemRoleContainer}>
|
||||
<View style={styles.settingsTitleContainer}>
|
||||
<BotMessageSquare color={token.colorText} size={ICON_SIZE} />
|
||||
<Text style={styles.systemRoleTitle}>
|
||||
{t('assistant.detail.assistantSettings', { ns: 'discover' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.systemRoleContentContainer}>
|
||||
<Markdown fontSize={14}>{systemRoleContent}</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantDetail;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
// 操作按钮相关样式
|
||||
actionButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: token.marginLG,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
addButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginRight: token.marginXS,
|
||||
paddingHorizontal: token.margin,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
|
||||
addButtonText: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
|
||||
authorAvatar: {
|
||||
alignItems: 'center',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
height: token.controlHeightSM,
|
||||
justifyContent: 'center',
|
||||
marginRight: token.marginXS / 2,
|
||||
width: token.controlHeightSM,
|
||||
},
|
||||
|
||||
avatarTitleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
|
||||
// 头部相关样式
|
||||
backButton: {
|
||||
padding: token.marginXS,
|
||||
},
|
||||
|
||||
// 容器相关样式
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: token.padding,
|
||||
},
|
||||
|
||||
description: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeight,
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
},
|
||||
|
||||
iconText: {
|
||||
fontSize: token.fontSize,
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// 文字相关样式
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
|
||||
safeAreaContainer: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
settingsIcon: {
|
||||
fontSize: token.fontSizeLG,
|
||||
},
|
||||
|
||||
settingsTitleContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderTopLeftRadius: token.borderRadiusLG,
|
||||
borderTopRightRadius: token.borderRadiusLG,
|
||||
flexDirection: 'row',
|
||||
padding: token.margin,
|
||||
},
|
||||
|
||||
shareButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
height: token.controlHeight,
|
||||
justifyContent: 'center',
|
||||
width: token.controlHeight,
|
||||
},
|
||||
|
||||
// 系统角色相关样式
|
||||
systemRoleContainer: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
marginBottom: token.marginLG,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
systemRoleContentContainer: {
|
||||
// paddingVertical: 12,
|
||||
color: token.colorText,
|
||||
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeight,
|
||||
paddingHorizontal: token.margin,
|
||||
},
|
||||
|
||||
systemRoleTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginLeft: token.marginXS,
|
||||
},
|
||||
// 标签相关样式
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: token.marginLG,
|
||||
marginLeft: -token.marginXXS,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,67 @@
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { DiscoverAssistantItem } from '@/types/discover';
|
||||
import GitHubAvatar from '@/components/GithubAvatar';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import Space from '@/components/Space';
|
||||
import Tag from '@/components/Tag';
|
||||
|
||||
import { useStyles } from './style';
|
||||
import { AVATAR_SIZE_MEDIUM } from '@/const/common';
|
||||
|
||||
interface AgentCardProps {
|
||||
item: DiscoverAssistantItem;
|
||||
}
|
||||
|
||||
export const AgentCard = ({ item }: AgentCardProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
params: { identifier: item.identifier },
|
||||
pathname: '/(discover)/assistant/detail',
|
||||
})
|
||||
}
|
||||
style={styles.cardLink}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.name}>{item.meta.title}</Text>
|
||||
|
||||
<Space align="center">
|
||||
<GitHubAvatar size={24} username={item.author} />
|
||||
<Text style={styles.authorName}>
|
||||
{item.author || 'LobeChat'}{' '}
|
||||
<Text style={styles.date}>{dayjs(item.createdAt).format('YYYY-MM-DD')}</Text>
|
||||
</Text>
|
||||
</Space>
|
||||
</View>
|
||||
|
||||
<Avatar avatar={item.meta.avatar || '🤖'} size={AVATAR_SIZE_MEDIUM} />
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={2} style={styles.description}>
|
||||
{item.meta.description}
|
||||
</Text>
|
||||
|
||||
{item.meta.tags && item.meta.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{item.meta.tags.map((tag: string) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
authorContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
authorName: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
avatar: {
|
||||
fontSize: token.fontSizeIcon,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
borderWidth: token.lineWidth,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardContent: {
|
||||
padding: token.margin,
|
||||
},
|
||||
cardLink: {
|
||||
marginBottom: token.margin,
|
||||
width: '100%',
|
||||
},
|
||||
date: {
|
||||
fontSize: token.fontSizeSM,
|
||||
opacity: token.opacityImage,
|
||||
},
|
||||
description: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
lineHeight: token.lineHeightSM,
|
||||
marginBottom: token.paddingSM,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: token.paddingSM,
|
||||
},
|
||||
name: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeXL,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginLeft: -token.marginXXS,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
paddingRight: token.paddingSM,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Search } from 'lucide-react-native';
|
||||
|
||||
import { Skeleton, Space } from '@/components';
|
||||
import { useThemeToken } from '@/theme';
|
||||
|
||||
// Agent Card Skeleton Component
|
||||
const AgentCardSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: 16,
|
||||
borderWidth: token.lineWidth,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{/* Header with title, author and avatar */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
{/* Title */}
|
||||
<View style={{ marginBottom: 8 }}>
|
||||
<Skeleton.Title animated style={{ height: 22 }} width="65%" />
|
||||
</View>
|
||||
|
||||
{/* Author info */}
|
||||
<Space align="center" direction="horizontal" size={8}>
|
||||
<Skeleton.Avatar animated shape="circle" size={24} />
|
||||
<Skeleton.Paragraph animated rows={1} width="120" />
|
||||
</Space>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<Skeleton.Paragraph animated rows={2} width={['100%', '85%']} />
|
||||
</View>
|
||||
|
||||
{/* Tags */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{[60, 80, 70].map((width, index) => (
|
||||
<Skeleton.Title
|
||||
animated
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 12,
|
||||
height: 24,
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Search Bar Skeleton Component
|
||||
export const SearchBarSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
height: 40,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Search color={token.colorTextPlaceholder} size={20} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Category Tabs Skeleton Component
|
||||
export const CategoryTabsSkeleton = () => {
|
||||
const token = useThemeToken();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{[60, 80, 70, 90].map((width, index) => (
|
||||
<Skeleton.Title
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
borderRadius: 16,
|
||||
height: 32,
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// List Loading Skeleton (for multiple cards)
|
||||
export const AssistantListSkeleton = ({ count = 5 }: { count?: number }) => {
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<AgentCardSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Complete List Page Skeleton
|
||||
export const AssistantListPageSkeleton = () => {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
||||
<SearchBarSkeleton />
|
||||
<CategoryTabsSkeleton />
|
||||
</View>
|
||||
<AssistantListSkeleton count={5} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantListPageSkeleton;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AssistantCategory } from '@/types/discover';
|
||||
|
||||
const useCategory = () => {
|
||||
const { t } = useTranslation('discover');
|
||||
|
||||
return [
|
||||
{
|
||||
key: AssistantCategory.All,
|
||||
label: t('category.assistant.all'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Academic,
|
||||
label: t('category.assistant.academic'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Career,
|
||||
label: t('category.assistant.career'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.CopyWriting,
|
||||
label: t('category.assistant.copywriting'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Design,
|
||||
label: t('category.assistant.design'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Education,
|
||||
label: t('category.assistant.education'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Emotions,
|
||||
label: t('category.assistant.emotions'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Entertainment,
|
||||
label: t('category.assistant.entertainment'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Games,
|
||||
label: t('category.assistant.games'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.General,
|
||||
label: t('category.assistant.general'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Life,
|
||||
label: t('category.assistant.life'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Marketing,
|
||||
label: t('category.assistant.marketing'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Office,
|
||||
label: t('category.assistant.office'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Programming,
|
||||
label: t('category.assistant.programming'),
|
||||
},
|
||||
{
|
||||
key: AssistantCategory.Translation,
|
||||
label: t('category.assistant.translation'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default useCategory;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { FlatList, Text, View, TextInput } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AssistantService } from '@/services/assistant';
|
||||
import { AssistantCategory } from '@/types/discover';
|
||||
import { Search } from 'lucide-react-native';
|
||||
import { CapsuleTabs } from '@/components';
|
||||
import AgentCard from './components/AgentCard';
|
||||
import SkeletonList from './components/SkeletonList';
|
||||
import useCategory from './hooks/useCategory';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const DISCOVER_ASSISTANTS_KEY = 'discover-assistants';
|
||||
|
||||
const AssistantList = () => {
|
||||
const { styles, token } = useStyles();
|
||||
const { t, i18n } = useTranslation(['common']);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>(AssistantCategory.All);
|
||||
const categories = useCategory();
|
||||
|
||||
const {
|
||||
data: agents,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR([DISCOVER_ASSISTANTS_KEY, i18n.language], async ([, language]: [string, string]) => {
|
||||
const assistantService = new AssistantService();
|
||||
const data = await assistantService.getAssistantList(language);
|
||||
return data || [];
|
||||
});
|
||||
|
||||
const filteredAgents = agents?.filter((agent) => {
|
||||
const matchesCategory =
|
||||
selectedCategory === AssistantCategory.All || agent.meta.category === selectedCategory;
|
||||
const matchesSearch =
|
||||
!searchText ||
|
||||
agent.meta.title?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
agent.meta.description?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
agent.meta.tags?.some((tag) => tag.toLowerCase().includes(searchText.toLowerCase()));
|
||||
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView edges={['bottom']} style={styles.safeAreaContainer}>
|
||||
<SkeletonList />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{t('assistant.fetchError', { ns: 'common' })}</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['bottom']} style={styles.safeAreaContainer}>
|
||||
<View style={styles.filterContainer}>
|
||||
<View style={styles.searchContainer}>
|
||||
<Search color={token.colorTextPlaceholder} size={20} style={styles.searchIcon} />
|
||||
<TextInput
|
||||
onChangeText={setSearchText}
|
||||
placeholder={t('assistant.search', { ns: 'common' })}
|
||||
placeholderTextColor={token.colorTextPlaceholder}
|
||||
style={[styles.searchInput, { marginLeft: token.marginXS }]}
|
||||
textAlignVertical="center"
|
||||
value={searchText}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<CapsuleTabs
|
||||
items={categories}
|
||||
onSelect={setSelectedCategory}
|
||||
selectedKey={selectedCategory}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchText
|
||||
? t('assistant.noMatch', { ns: 'common' })
|
||||
: t('assistant.noData', { ns: 'common' })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
data={filteredAgents}
|
||||
keyExtractor={(item) => item.identifier}
|
||||
renderItem={({ item }) => <AgentCard item={item} />}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantList;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: token.padding,
|
||||
},
|
||||
emptyText: {
|
||||
color: token.colorTextSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: token.paddingXS,
|
||||
paddingTop: token.padding,
|
||||
},
|
||||
listContainer: {
|
||||
padding: token.padding,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
safeAreaContainer: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
searchContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
flexDirection: 'row',
|
||||
marginBottom: token.paddingSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
searchIcon: {
|
||||
alignItems: 'center',
|
||||
borderRadius: token.borderRadius,
|
||||
height: token.controlHeight,
|
||||
justifyContent: 'center',
|
||||
width: token.controlHeight,
|
||||
},
|
||||
searchInput: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
paddingVertical: token.marginXS,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: token.fontSizeXL,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
title: {
|
||||
fontSize: token.fontSizeHeading1,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { Markdown } from '@/components';
|
||||
import { ChatMessage } from '@/types/message';
|
||||
|
||||
import LoadingDots from '../LoadingDots';
|
||||
import MessageActions from '../MessageActions';
|
||||
import ToolTipActions from '../ToolTipActions';
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface ChatBubbleProps {
|
||||
isLoading?: boolean;
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
const ChatBubble = React.memo(({ message, isLoading }: ChatBubbleProps) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const { styles } = useStyles();
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return <LoadingDots />;
|
||||
}
|
||||
return <Markdown>{message.content}</Markdown>;
|
||||
}, [isLoading, message.content]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.bubbleContainer,
|
||||
isUser ? styles.userBubbleContainer : styles.aiBubbleContainer,
|
||||
]}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<View style={styles.aiMessageContainer}>
|
||||
<ToolTipActions message={message}>
|
||||
<View style={[styles.bubble, styles.aiBubble]}>{content}</View>
|
||||
</ToolTipActions>
|
||||
{!isLoading && message.content && <MessageActions message={message} />}
|
||||
</View>
|
||||
) : (
|
||||
<ToolTipActions message={message}>
|
||||
<View style={[styles.bubble, styles.userBubble]}>{content}</View>
|
||||
</ToolTipActions>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
ChatBubble.displayName = 'ChatBubble';
|
||||
|
||||
export default ChatBubble;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
aiBubble: {
|
||||
width: '100%',
|
||||
},
|
||||
aiBubbleContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
aiMessageContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
aiMessageText: {
|
||||
color: token.colorText,
|
||||
},
|
||||
bubble: {
|
||||
borderRadius: token.borderRadius,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
bubbleContainer: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
marginVertical: token.marginXS,
|
||||
},
|
||||
codeBlockContainer: {
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
marginVertical: token.marginXS,
|
||||
padding: token.paddingSM,
|
||||
},
|
||||
codeText: {
|
||||
color: token.colorText,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: token.lineHeightSM,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: token.lineHeight,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
},
|
||||
userBubbleContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AlignJustify, MoreHorizontal, MessagesSquare } from 'lucide-react-native';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
import { DEFAULT_AUTHOR, DEFAULT_INBOX_TITLE } from '@/const/meta';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
|
||||
import { useThemeToken } from '@/theme';
|
||||
|
||||
import { useStyles } from './style';
|
||||
import { Avatar } from '@/components';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onDrawerToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function ChatHeader({ onDrawerToggle }: ChatHeaderProps) {
|
||||
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
|
||||
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
|
||||
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
|
||||
const toggleTopicDrawer = useGlobalStore((s) => s.toggleTopicDrawer);
|
||||
const token = useThemeToken();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const displayTitle = isInbox ? DEFAULT_INBOX_TITLE : title;
|
||||
|
||||
return (
|
||||
<View style={[styles.header, { height: 44 }]}>
|
||||
<TouchableOpacity onPress={onDrawerToggle} style={styles.actionButton}>
|
||||
<AlignJustify color={token.colorText} size={ICON_SIZE} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerInfo}>
|
||||
<Avatar avatar={avatar} size={42} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.headerTitle}>{displayTitle}</Text>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={styles.headerSubtitle}>
|
||||
{DEFAULT_AUTHOR}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity onPress={toggleTopicDrawer} style={styles.actionButton}>
|
||||
<MessagesSquare color={token.colorText} size={ICON_SIZE} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/chat/setting')} style={styles.actionButton}>
|
||||
<MoreHorizontal color={token.colorText} size={ICON_SIZE} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HEADER_HEIGHT } from '@/const/common';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
actionButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: token.borderRadiusXS,
|
||||
padding: token.paddingXXS,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flexDirection: 'row',
|
||||
height: HEADER_HEIGHT,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: token.paddingSM,
|
||||
},
|
||||
headerActions: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
headerContent: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: token.paddingXS,
|
||||
},
|
||||
headerInfo: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXS,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
headerSubtitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
marginTop: 2,
|
||||
maxWidth: '100%',
|
||||
textAlign: 'left',
|
||||
},
|
||||
headerText: {
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
headerTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
textAlign: 'left',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { memo } from 'react';
|
||||
import { TouchableOpacity, TouchableOpacityProps, ViewStyle } from 'react-native';
|
||||
|
||||
import { getButtonSize, useStyles } from './styles';
|
||||
|
||||
interface IconBtnProps extends TouchableOpacityProps {
|
||||
containerStyle?: ViewStyle;
|
||||
icon: React.ReactNode;
|
||||
size?: number;
|
||||
variant?: 'default' | 'primary';
|
||||
}
|
||||
|
||||
const IconBtn = memo<IconBtnProps>(
|
||||
({ icon, variant = 'default', size = 24, containerStyle, style, ...props }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const getButtonStyle = (): ViewStyle => {
|
||||
const baseStyle = {
|
||||
...styles.buttonBase,
|
||||
...getButtonSize(size),
|
||||
};
|
||||
|
||||
if (variant === 'primary') {
|
||||
return {
|
||||
...baseStyle,
|
||||
...styles.buttonPrimary,
|
||||
};
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={[getButtonStyle(), containerStyle, style]}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
IconBtn.displayName = 'IconBtn';
|
||||
|
||||
export default IconBtn;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ViewStyle } from 'react-native';
|
||||
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
const PADDING_SIZE = 16;
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
buttonBase: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadiusLG + PADDING_SIZE,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: token.paddingXS,
|
||||
} as ViewStyle,
|
||||
|
||||
buttonPrimary: {
|
||||
backgroundColor: token.colorBgSolidActive,
|
||||
borderColor: token.colorPrimaryText,
|
||||
} as ViewStyle,
|
||||
}));
|
||||
|
||||
export const getButtonSize = (size: number) => ({
|
||||
height: size + PADDING_SIZE,
|
||||
width: size + PADDING_SIZE,
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { ModelIcon } from '@lobehub/icons-rn';
|
||||
|
||||
import { AIChatModelCard } from '@/types/aiModel';
|
||||
import { ModelInfoTags } from '@/components';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
interface ModelItemRenderProps extends AIChatModelCard {
|
||||
showInfoTag?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型项渲染组件
|
||||
* 显示模型图标、名称和能力标签,对齐Web端实现
|
||||
*/
|
||||
const ModelItemRender = memo<ModelItemRenderProps>(({ showInfoTag = true, ...model }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 左侧:模型图标和名称 */}
|
||||
<View style={styles.leftSection}>
|
||||
<ModelIcon model={model.id} size={20} />
|
||||
<Text numberOfLines={1} style={styles.modelName}>
|
||||
{model.displayName || model.id}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 右侧:能力标签(可选) */}
|
||||
{showInfoTag && (
|
||||
<View style={styles.rightSection}>
|
||||
<ModelInfoTags {...model} {...model.abilities} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
ModelItemRender.displayName = 'ModelItemRender';
|
||||
|
||||
export default ModelItemRender;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 40,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
|
||||
leftSection: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
minWidth: 0, // 允许文字收缩
|
||||
},
|
||||
|
||||
modelName: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
rightSection: {
|
||||
flexShrink: 0,
|
||||
marginLeft: 8,
|
||||
},
|
||||
}));
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { Modal, View, Text, TouchableOpacity, ScrollView, SafeAreaView } from 'react-native';
|
||||
import { X, ArrowRight } from 'lucide-react-native';
|
||||
|
||||
import { useCurrentAgent } from '@/hooks/useCurrentAgent';
|
||||
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
|
||||
import { useAiInfraInit } from '@/hooks/useAiInfraInit';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import ModelItemRender from '../ModelItemRender';
|
||||
import ProviderItemRender from '../ProviderItemRender';
|
||||
import { useStyles } from './styles';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { EnabledProviderWithModels } from '@/types/aiProvider';
|
||||
|
||||
// 生成菜单键,与Web端保持一致
|
||||
const menuKey = (provider: string, model: string) => `${provider}-${model}`;
|
||||
|
||||
interface ModelSelectModalProps {
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型选择模态框
|
||||
* 完全对齐Web端ModelSwitchPanel的逻辑和显示
|
||||
*/
|
||||
const ModelSelectModal = memo<ModelSelectModalProps>(({ visible, onClose }) => {
|
||||
const { currentModel, currentProvider, updateAgentConfig } = useCurrentAgent();
|
||||
const enabledModels = useEnabledChatModels();
|
||||
const { isLoading: infraLoading, hasError: infraError } = useAiInfraInit();
|
||||
const token = useThemeToken();
|
||||
const router = useRouter();
|
||||
const { styles } = useStyles();
|
||||
|
||||
// 当前选中的menuKey
|
||||
const activeKey = useMemo(() => {
|
||||
return menuKey(currentProvider, currentModel);
|
||||
}, [currentProvider, currentModel]);
|
||||
|
||||
// 处理模型选择
|
||||
const handleModelSelect = useCallback(
|
||||
async (model: string, provider: string) => {
|
||||
try {
|
||||
await updateAgentConfig({
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update model:', error);
|
||||
}
|
||||
},
|
||||
[updateAgentConfig, onClose],
|
||||
);
|
||||
|
||||
// 获取模型项列表(对齐Web端getModelItems逻辑)
|
||||
const getModelItems = useCallback(
|
||||
(provider: EnabledProviderWithModels) => {
|
||||
// 如果没有模型,返回空状态提示
|
||||
if (provider.children.length === 0) {
|
||||
return [
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
key={`${provider.id}-empty`}
|
||||
onPress={() => {
|
||||
// TODO: 导航到设置页面(移动端暂不实现)
|
||||
console.log('Navigate to provider settings:', provider.id);
|
||||
}}
|
||||
style={styles.emptyModelItem}
|
||||
>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyText}>暂无模型</Text>
|
||||
<ArrowRight color={token.colorTextTertiary} size={16} />
|
||||
</View>
|
||||
</TouchableOpacity>,
|
||||
];
|
||||
}
|
||||
|
||||
// 返回模型列表
|
||||
return provider.children.map((model) => {
|
||||
const isSelected = activeKey === menuKey(provider.id, model.id);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
key={menuKey(provider.id, model.id)}
|
||||
onPress={() => handleModelSelect(model.id, provider.id)}
|
||||
style={[
|
||||
styles.modelItem,
|
||||
isSelected ? styles.modelItemSelected : styles.modelItemNormal,
|
||||
]}
|
||||
>
|
||||
<ModelItemRender {...model} showInfoTag={true} type="chat" />
|
||||
|
||||
{/* 选中状态指示器 */}
|
||||
{isSelected && <View style={styles.selectedIndicator} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
},
|
||||
[activeKey, handleModelSelect, styles, token.colorTextTertiary],
|
||||
);
|
||||
|
||||
// 渲染提供商分组(对齐Web端逻辑)
|
||||
const renderProviderGroup = useCallback(
|
||||
(provider: EnabledProviderWithModels) => {
|
||||
return (
|
||||
<View key={provider.id} style={styles.providerGroup}>
|
||||
{/* 提供商标题 */}
|
||||
<View style={styles.providerHeader}>
|
||||
<ProviderItemRender
|
||||
logo={provider.logo}
|
||||
name={provider.name}
|
||||
provider={provider.id}
|
||||
source={provider.source}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<View style={styles.modelList}>{getModelItems(provider)}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[getModelItems, styles],
|
||||
);
|
||||
|
||||
// 计算要显示的内容(对齐Web端逻辑)
|
||||
const renderContent = useMemo(() => {
|
||||
// 加载中状态
|
||||
if (infraLoading) {
|
||||
return (
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (infraError) {
|
||||
return (
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.errorText}>加载失败</Text>
|
||||
<Text style={styles.subText}>请检查网络连接或重试</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 空提供商状态(对齐Web端emptyProvider逻辑)
|
||||
if (enabledModels.length === 0) {
|
||||
return (
|
||||
<View style={styles.statusContainer}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: 导航到设置页面
|
||||
router.push('/setting/providers');
|
||||
onClose();
|
||||
console.log('Navigate to provider settings');
|
||||
}}
|
||||
style={styles.emptyProviderItem}
|
||||
>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyText}>暂无提供商</Text>
|
||||
<ArrowRight color={token.colorTextTertiary} size={16} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.subText}>请添加AI提供商配置</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 正常显示提供商分组
|
||||
return enabledModels.map(renderProviderGroup);
|
||||
}, [
|
||||
infraLoading,
|
||||
infraError,
|
||||
enabledModels,
|
||||
renderProviderGroup,
|
||||
styles,
|
||||
token.colorTextTertiary,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
presentationStyle="pageSheet"
|
||||
visible={visible}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* 标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>选择模型</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<X color={token.colorTextSecondary} size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.scrollContainer}
|
||||
>
|
||||
{renderContent}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
ModelSelectModal.displayName = 'ModelSelectModal';
|
||||
|
||||
export default ModelSelectModal;
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
abilitiesContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
abilityTag: {
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
checkIcon: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
closeButton: {
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyModelItem: {
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
emptyProviderItem: {
|
||||
alignItems: 'center',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
emptyText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
functionTag: {
|
||||
backgroundColor: token.colorSuccessBg,
|
||||
},
|
||||
functionTagText: {
|
||||
color: token.colorSuccess,
|
||||
fontSize: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
borderBottomColor: token.colorBorder,
|
||||
borderBottomWidth: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
headerTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modelInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
modelItem: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
marginHorizontal: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
modelItemNormal: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modelItemSelected: {
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
},
|
||||
modelList: {
|
||||
paddingTop: 8,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 16,
|
||||
},
|
||||
modelNameNormal: {
|
||||
color: token.colorText,
|
||||
fontWeight: '400',
|
||||
},
|
||||
modelNameSelected: {
|
||||
color: token.colorPrimaryText,
|
||||
fontWeight: '600',
|
||||
},
|
||||
providerGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
providerHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
providerTitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
selectedIndicator: {
|
||||
backgroundColor: token.colorPrimary,
|
||||
borderRadius: 2,
|
||||
height: 4,
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -2 }],
|
||||
width: 4,
|
||||
},
|
||||
statusContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
subText: {
|
||||
color: token.colorTextQuaternary,
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
visionTag: {
|
||||
backgroundColor: token.colorInfoBg,
|
||||
},
|
||||
visionTagText: {
|
||||
color: token.colorInfo,
|
||||
fontSize: 10,
|
||||
},
|
||||
}));
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import React, { memo } from 'react';
|
||||
import { TouchableOpacity, ViewStyle } from 'react-native';
|
||||
import { ModelIcon } from '@lobehub/icons-rn';
|
||||
|
||||
import { useCurrentAgent } from '@/hooks/useCurrentAgent';
|
||||
import { useStyles } from './styles';
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
|
||||
interface ModelSwitchButtonProps {
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型切换按钮组件
|
||||
* 显示当前选中的模型图标,点击后可以切换模型
|
||||
*/
|
||||
const ModelSwitchButton = memo<ModelSwitchButtonProps>(({ onPress, style }) => {
|
||||
const { currentModel } = useCurrentAgent();
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={onPress} style={[styles.button, style]}>
|
||||
<ModelIcon model={currentModel} size={ICON_SIZE} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
ModelSwitchButton.displayName = 'ModelSwitchButton';
|
||||
|
||||
export default ModelSwitchButton;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
const PADDING_SIZE = 16;
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadiusLG + PADDING_SIZE,
|
||||
borderWidth: 1,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
},
|
||||
}));
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, Text, Image } from 'react-native';
|
||||
import { ProviderIcon } from '@lobehub/icons-rn';
|
||||
|
||||
import { AiProviderSourceType } from '@/types/aiProvider';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
interface ProviderItemRenderProps {
|
||||
logo?: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
source?: AiProviderSourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供商项渲染组件
|
||||
* 显示提供商图标和名称,对齐Web端实现
|
||||
*/
|
||||
const ProviderItemRender = memo<ProviderItemRenderProps>(({ provider, name, source, logo }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 提供商图标 */}
|
||||
{source === 'custom' && logo ? (
|
||||
<Image
|
||||
resizeMode="contain"
|
||||
source={{ uri: logo }}
|
||||
style={[styles.icon, styles.customIcon]}
|
||||
/>
|
||||
) : (
|
||||
<ProviderIcon provider={provider} size={20} type="mono" />
|
||||
)}
|
||||
|
||||
{/* 提供商名称 */}
|
||||
<Text style={styles.name}>{name}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
ProviderItemRender.displayName = 'ProviderItemRender';
|
||||
|
||||
export default ProviderItemRender;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
|
||||
customIcon: {
|
||||
// 应用灰度滤镜,类似Web端的 filter: 'grayscale(1)'
|
||||
// React Native 中可以通过 tintColor 实现类似效果
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
icon: {
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
|
||||
name: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { memo, useState, useCallback } from 'react';
|
||||
import { ViewStyle } from 'react-native';
|
||||
|
||||
import ModelSwitchButton from './ModelSwitchButton';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
|
||||
interface ModelSwitchProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型切换组合组件
|
||||
* 包含切换按钮和选择模态框,管理状态
|
||||
*/
|
||||
const ModelSwitch = memo<ModelSwitchProps>(({ style }) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalVisible(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelSwitchButton onPress={handleOpenModal} style={style} />
|
||||
|
||||
<ModelSelectModal onClose={handleCloseModal} visible={modalVisible} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ModelSwitch.displayName = 'ModelSwitch';
|
||||
|
||||
export default ModelSwitch;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles(() => ({
|
||||
// 主组件样式(当前为空,后续可以添加)
|
||||
container: {
|
||||
// 预留样式定义
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MessageSquarePlus } from 'lucide-react-native';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { useCallback } from 'react';
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
import IconBtn from '../IconBtn';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
const NewChatBtn = () => {
|
||||
const token = useThemeToken();
|
||||
const { activeId } = useSessionStore();
|
||||
|
||||
const { clearMessages } = useChat();
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
clearMessages(activeId);
|
||||
}, [activeId, clearMessages]);
|
||||
|
||||
return (
|
||||
<IconBtn
|
||||
icon={<MessageSquarePlus color={token.colorText} size={ICON_SIZE} />}
|
||||
onPress={handleClearMessages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewChatBtn;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ArrowUp } from 'lucide-react-native';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, View, ViewStyle } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import IconBtn from './(components)/IconBtn';
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import ModelSwitch from './(components)/ModelSwitch';
|
||||
|
||||
import StopLoadingIcon from '../StopLoadingIcon';
|
||||
import { useStyles } from './style';
|
||||
import NewChatBtn from './(components)/NewChatBtn';
|
||||
|
||||
const PADDING_SIZE = 16;
|
||||
|
||||
interface ChatInputProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const ChatInput = memo(({ style }: ChatInputProps) => {
|
||||
const { t } = useTranslation(['chat']);
|
||||
const { input, handleInputChange, handleSubmit, isLoading, canSend, stopGenerating } = useChat();
|
||||
const insets = useSafeAreaInsets();
|
||||
const token = useThemeToken();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const senderIconColor = token.colorText;
|
||||
|
||||
const SenderBtn = useMemo(
|
||||
() => () =>
|
||||
isLoading ? (
|
||||
<IconBtn
|
||||
icon={<StopLoadingIcon color={senderIconColor} size={ICON_SIZE + PADDING_SIZE} />}
|
||||
onPress={stopGenerating}
|
||||
variant="primary"
|
||||
/>
|
||||
) : (
|
||||
<IconBtn
|
||||
disabled={!canSend}
|
||||
icon={<ArrowUp color={senderIconColor} size={ICON_SIZE} />}
|
||||
onPress={handleSubmit}
|
||||
style={{ opacity: canSend ? 1 : 0.5 }}
|
||||
variant="primary"
|
||||
/>
|
||||
),
|
||||
[isLoading, stopGenerating, handleSubmit, canSend, senderIconColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingBottom: insets.bottom + 8 }, style]}>
|
||||
<View style={styles.inputArea}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="default"
|
||||
multiline={true}
|
||||
numberOfLines={4}
|
||||
onChangeText={handleInputChange}
|
||||
onSubmitEditing={handleSubmit}
|
||||
placeholder={t('placeholder', { ns: 'chat' })}
|
||||
placeholderTextColor={token.colorTextPlaceholder}
|
||||
scrollEnabled={true}
|
||||
spellCheck={false}
|
||||
style={styles.input}
|
||||
textAlignVertical="top"
|
||||
textBreakStrategy="highQuality"
|
||||
value={input}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.leftActions}>
|
||||
<NewChatBtn />
|
||||
<ModelSwitch />
|
||||
</View>
|
||||
<View style={styles.rightActions}>
|
||||
<SenderBtn />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
ChatInput.displayName = 'ChatInput';
|
||||
|
||||
export default ChatInput;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
|
||||
borderTopLeftRadius: token.borderRadiusLG * 4,
|
||||
|
||||
// 24
|
||||
borderTopRightRadius: token.borderRadiusLG * 4,
|
||||
borderWidth: 1,
|
||||
elevation: 8,
|
||||
|
||||
paddingHorizontal: token.padding,
|
||||
|
||||
paddingVertical: token.paddingSM,
|
||||
// 24
|
||||
shadowColor: token.colorText,
|
||||
shadowOffset: { height: -4, width: 0 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
extraBtn: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: token.marginXS,
|
||||
width: '100%',
|
||||
},
|
||||
iconGroup: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
input: {
|
||||
borderRadius: token.borderRadius,
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: 24,
|
||||
maxHeight: 96,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
inputArea: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
leftActions: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
rightActions: {},
|
||||
}));
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { useFetchMessages } from '@/hooks/useFetchMessages';
|
||||
// import ScrollToBottom from '../ScrollToBottom';
|
||||
import { useStyles } from './style';
|
||||
import { ChatMessage } from '@/types/message';
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
import ChatBubble from '../ChatBubble';
|
||||
|
||||
interface ChatListProps {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
scrollViewRef?: React.RefObject<FlatList<ChatMessage> | null>;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const ChatMessageItem = React.memo<{ index: number; item: ChatMessage; totalLength: number }>(
|
||||
({ item, index, totalLength }) => {
|
||||
const isLastMessage = index === totalLength - 1;
|
||||
const isAssistant = item.role === 'assistant';
|
||||
const isLoadingContent = item.content === LOADING_FLAT;
|
||||
|
||||
const shouldShowLoading = isLastMessage && isAssistant && isLoadingContent;
|
||||
|
||||
return <ChatBubble isLoading={shouldShowLoading} message={item} />;
|
||||
},
|
||||
);
|
||||
|
||||
ChatMessageItem.displayName = 'ChatMessageItem';
|
||||
|
||||
export default function ChatList({
|
||||
style,
|
||||
// onScroll,
|
||||
scrollViewRef,
|
||||
}: ChatListProps) {
|
||||
const internalRef = useRef<FlatList<ChatMessage>>(null);
|
||||
|
||||
// 触发消息加载
|
||||
useFetchMessages();
|
||||
|
||||
const { messages } = useChat();
|
||||
const { styles } = useStyles();
|
||||
// const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
|
||||
const renderItem: ListRenderItem<ChatMessage> = useCallback(
|
||||
({ item, index }) => (
|
||||
<ChatMessageItem index={index} item={item} key={item.id} totalLength={messages.length} />
|
||||
),
|
||||
[messages.length],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
|
||||
|
||||
// const handleScroll = useCallback(
|
||||
// (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
// const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||
// const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - 32;
|
||||
// setShowScrollToBottom(!isAtBottom);
|
||||
// onScroll?.(event);
|
||||
// },
|
||||
// [onScroll],
|
||||
// );
|
||||
|
||||
// const handleScrollToBottom = useCallback(() => {
|
||||
// const ref = scrollViewRef?.current || internalRef.current;
|
||||
// ref?.scrollToEnd({ animated: true });
|
||||
// }, [scrollViewRef]);
|
||||
|
||||
const handleContentSizeChange = useCallback(() => {
|
||||
const ref = scrollViewRef?.current || internalRef.current;
|
||||
if (ref && messages.length > 0) {
|
||||
ref.scrollToEnd({ animated: true });
|
||||
}
|
||||
}, [scrollViewRef, messages.length]);
|
||||
|
||||
return (
|
||||
<View style={[{ flex: 1 }, style]}>
|
||||
<FlatList
|
||||
data={messages}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={keyExtractor}
|
||||
maxToRenderPerBatch={10}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onLayout={handleContentSizeChange}
|
||||
// onScroll={handleScroll}
|
||||
ref={scrollViewRef || internalRef}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={renderItem}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.chatContainer}
|
||||
windowSize={10}
|
||||
/>
|
||||
{/* <ScrollToBottom onPress={handleScrollToBottom} visible={showScrollToBottom} /> */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: token.padding,
|
||||
paddingVertical: token.padding,
|
||||
},
|
||||
scrollToBottomBtn: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
bottom: 88,
|
||||
elevation: 8,
|
||||
left: '50%',
|
||||
padding: token.paddingSM,
|
||||
position: 'absolute',
|
||||
shadowColor: token.colorText,
|
||||
shadowOffset: { height: 2, width: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
transform: [{ translateX: -28 }],
|
||||
zIndex: 10,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, View } from 'react-native';
|
||||
|
||||
import { useThemeToken } from '@/theme';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface LoadingDotsProps {
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const LoadingDots: React.FC<LoadingDotsProps> = ({ size = 8, color }) => {
|
||||
const token = useThemeToken();
|
||||
const dotColor = color || token.colorTextSecondary;
|
||||
const { styles } = useStyles(size, dotColor);
|
||||
|
||||
// 创建三个动画值
|
||||
const dot1 = useRef(new Animated.Value(0.3)).current;
|
||||
const dot2 = useRef(new Animated.Value(0.3)).current;
|
||||
const dot3 = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const createAnimation = (animatedValue: Animated.Value, delay: number) => {
|
||||
return Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(delay),
|
||||
Animated.timing(animatedValue, {
|
||||
duration: 400,
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
duration: 400,
|
||||
toValue: 0.3,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
// 创建错开的动画
|
||||
const animation1 = createAnimation(dot1, 0);
|
||||
const animation2 = createAnimation(dot2, 150);
|
||||
const animation3 = createAnimation(dot3, 300);
|
||||
|
||||
// 启动所有动画
|
||||
animation1.start();
|
||||
animation2.start();
|
||||
animation3.start();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
animation1.stop();
|
||||
animation2.stop();
|
||||
animation3.stop();
|
||||
};
|
||||
}, [dot1, dot2, dot3]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.dot, { opacity: dot1 }]} />
|
||||
<Animated.View style={[styles.dot, { opacity: dot2 }]} />
|
||||
<Animated.View style={[styles.dot, { opacity: dot3 }]} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingDots;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token, size: number, dotColor: string) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
dot: {
|
||||
backgroundColor: dotColor,
|
||||
borderRadius: size / 2,
|
||||
height: size,
|
||||
marginHorizontal: 2,
|
||||
width: size,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Check, Copy, RefreshCw, Trash2 } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { useToast } from '@/components';
|
||||
import { ICON_SIZE_TINY } from '@/const/common';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { ChatMessage } from '@/types/message';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
const MessageActions: React.FC<MessageActionsProps> = ({ message }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { activeId } = useSessionStore();
|
||||
const { deleteMessage, regenerateMessage } = useChatStore();
|
||||
const toast = useToast();
|
||||
const token = useThemeToken();
|
||||
const { styles } = useStyles();
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
// 复制消息内容
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(message.content);
|
||||
setIsCopied(true);
|
||||
toast.success(t('messageCopied', { ns: 'chat' }));
|
||||
|
||||
// 2秒后恢复原始图标
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
toast.error(t('copyFailed', { ns: 'chat' }));
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成消息
|
||||
const handleRegenerate = async () => {
|
||||
try {
|
||||
await regenerateMessage(activeId, message.id);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('regenerateFailed', { ns: 'chat' }));
|
||||
}
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleDelete = () => {
|
||||
Alert.alert(t('confirmDelete', { ns: 'chat' }), t('deleteMessageConfirm', { ns: 'chat' }), [
|
||||
{
|
||||
style: 'cancel',
|
||||
text: t('cancel', { ns: 'common' }),
|
||||
},
|
||||
{
|
||||
onPress: () => {
|
||||
deleteMessage(activeId, message.id);
|
||||
},
|
||||
style: 'destructive',
|
||||
text: t('delete', { ns: 'common' }),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
disabled={isCopied}
|
||||
onPress={handleCopy}
|
||||
style={styles.actionButton}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check color={token.colorSuccess} size={ICON_SIZE_TINY} />
|
||||
) : (
|
||||
<Copy color={token.colorIcon} size={ICON_SIZE_TINY} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={handleRegenerate} style={styles.actionButton}>
|
||||
<RefreshCw color={token.colorIcon} size={ICON_SIZE_TINY} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={handleDelete} style={styles.actionButton}>
|
||||
<Trash2 color={token.colorIcon} size={ICON_SIZE_TINY} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageActions;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
actionButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: token.borderRadiusXS,
|
||||
justifyContent: 'center',
|
||||
minHeight: token.controlInteractiveSize,
|
||||
minWidth: token.controlInteractiveSize,
|
||||
padding: token.paddingXXS,
|
||||
},
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: token.marginXXS,
|
||||
justifyContent: 'flex-start',
|
||||
paddingHorizontal: token.paddingContentHorizontalSM,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ArrowDown } from 'lucide-react-native';
|
||||
import { TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { ICON_SIZE } from '@/const/common';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
interface ScrollToBottomProps {
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((token) => ({
|
||||
scrollToBottomBtn: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG * 3,
|
||||
padding: token.paddingXS,
|
||||
...token.boxShadow,
|
||||
},
|
||||
scrollToBottomWrapper: {
|
||||
alignItems: 'center',
|
||||
bottom: 8,
|
||||
left: 0,
|
||||
pointerEvents: 'box-none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function ScrollToBottom({ onPress, visible }: ScrollToBottomProps) {
|
||||
const { styles, token } = useStyles();
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.scrollToBottomWrapper}>
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={onPress} style={styles.scrollToBottomBtn}>
|
||||
<ArrowDown color={token.colorText} size={ICON_SIZE} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, Easing, View } from 'react-native';
|
||||
import Svg, { Circle, Rect } from 'react-native-svg';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface StopLoadingIconProps {
|
||||
color?: string;
|
||||
duration?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const StopLoadingIcon: React.FC<StopLoadingIconProps> = ({
|
||||
size = 24,
|
||||
color = '#FFF',
|
||||
duration = 1000,
|
||||
}) => {
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const loopRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||
const { styles } = useStyles(size);
|
||||
|
||||
useEffect(() => {
|
||||
rotateAnim.setValue(0);
|
||||
loopRef.current = Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
isInteraction: false,
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
);
|
||||
loopRef.current.start();
|
||||
return () => {
|
||||
if (loopRef.current) loopRef.current.stop();
|
||||
};
|
||||
}, [rotateAnim, duration]);
|
||||
|
||||
const spin = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const strokeWidth = 1.5;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const center = size / 2;
|
||||
const arcAngle = 140;
|
||||
const gapAngle = 360 - arcAngle;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const arcLength = (arcAngle / 360) * circumference;
|
||||
const gapLength = (gapAngle / 360) * circumference;
|
||||
|
||||
return (
|
||||
<View pointerEvents="none" style={styles.container}>
|
||||
{/* 旋转的弧 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.rotatingContainer,
|
||||
{
|
||||
transform: [{ rotate: spin }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Svg height={size} width={size}>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeDasharray={`${arcLength},${gapLength}`}
|
||||
strokeDashoffset={0}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
|
||||
{/* 静止的方块 */}
|
||||
<View style={styles.staticContainer}>
|
||||
<Svg height={size} width={size}>
|
||||
<Rect
|
||||
fill={color}
|
||||
height={size * 0.3}
|
||||
rx={2}
|
||||
width={size * 0.3}
|
||||
x={center - size * 0.15}
|
||||
y={center - size * 0.15}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default StopLoadingIcon;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((_, size: number) => ({
|
||||
container: {
|
||||
height: size,
|
||||
position: 'relative',
|
||||
width: size,
|
||||
},
|
||||
rotatingContainer: {
|
||||
height: size,
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
},
|
||||
staticContainer: {
|
||||
height: size,
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Copy, LucideIcon, RefreshCw, Trash2 } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { useToast, Tooltip } from '@/components';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { ChatMessage } from '@/types/message';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface ToolTipActionsProps {
|
||||
children: React.ReactNode;
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
const ToolTipActions: React.FC<ToolTipActionsProps> = ({ message, children }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { activeId } = useSessionStore();
|
||||
const { deleteMessage, regenerateMessage } = useChatStore();
|
||||
const toast = useToast();
|
||||
const token = useThemeToken();
|
||||
const { styles } = useStyles();
|
||||
const [tooltipVisible, setTooltipVisible] = React.useState(false);
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isAssistant = message.role === 'assistant';
|
||||
|
||||
// 复制消息内容
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(message.content);
|
||||
toast.success(t('messageCopied', { ns: 'chat' }));
|
||||
setTooltipVisible(false); // 关闭tooltip
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
toast.error(t('copyFailed', { ns: 'chat' }));
|
||||
setTooltipVisible(false); // 关闭tooltip
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成消息
|
||||
const handleRegenerate = async () => {
|
||||
try {
|
||||
await regenerateMessage(activeId, message.id);
|
||||
setTooltipVisible(false); // 关闭tooltip
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('regenerateFailed', { ns: 'chat' }));
|
||||
setTooltipVisible(false); // 关闭tooltip
|
||||
}
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleDelete = () => {
|
||||
setTooltipVisible(false); // 先关闭tooltip
|
||||
Alert.alert(t('confirmDelete', { ns: 'chat' }), t('deleteMessageConfirm', { ns: 'chat' }), [
|
||||
{
|
||||
style: 'cancel',
|
||||
text: t('cancel', { ns: 'common' }),
|
||||
},
|
||||
{
|
||||
onPress: () => {
|
||||
deleteMessage(activeId, message.id);
|
||||
},
|
||||
style: 'destructive',
|
||||
text: t('delete', { ns: 'common' }),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 渲染单个操作项
|
||||
const renderActionItem = (Icon: LucideIcon, label: string, onPress: () => void) => (
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={onPress} style={styles.actionItem}>
|
||||
<Icon color={token.colorBgContainer} size={20} />
|
||||
<Text style={[styles.actionLabel]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染操作网格
|
||||
const renderActions = () => {
|
||||
if (isAssistant) {
|
||||
// AI消息操作:复制、重新生成、删除
|
||||
return (
|
||||
<View style={styles.actionsContainer}>
|
||||
<View style={styles.actionsRow}>
|
||||
{renderActionItem(Copy, t('actions.copy', { ns: 'common' }), handleCopy)}
|
||||
{renderActionItem(RefreshCw, t('actions.retry', { ns: 'common' }), handleRegenerate)}
|
||||
{renderActionItem(Trash2, t('actions.delete', { ns: 'common' }), handleDelete)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
// 用户消息操作:复制、删除
|
||||
return (
|
||||
<View style={styles.actionsContainer}>
|
||||
<View style={styles.actionsRow}>
|
||||
{renderActionItem(Copy, t('actions.copy', { ns: 'common' }), handleCopy)}
|
||||
{renderActionItem(Trash2, t('actions.delete', { ns: 'common' }), handleDelete)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
arrow={true}
|
||||
onVisibleChange={setTooltipVisible}
|
||||
overlayStyle={styles.tooltipOverlay}
|
||||
placement={isUser ? 'topRight' : 'topLeft'}
|
||||
title={renderActions()}
|
||||
trigger="longPress"
|
||||
visible={tooltipVisible}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.touchableWrapper}>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolTipActions;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
actionIcon: {
|
||||
color: token.colorBgContainer,
|
||||
},
|
||||
actionItem: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 50,
|
||||
paddingHorizontal: token.paddingXS,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
actionLabel: {
|
||||
color: token.colorBgContainer,
|
||||
fontSize: token.fontSizeSM,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginTop: token.marginXXS,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actionLabelSuccess: {
|
||||
color: token.colorSuccess,
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: 'column',
|
||||
gap: token.marginXS,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: token.marginSM,
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
tooltipOverlay: {
|
||||
borderRadius: token.borderRadius,
|
||||
minWidth: 180,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
touchableWrapper: {
|
||||
borderRadius: token.borderRadius,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useThemedScreenOptions } from '@/const/navigation';
|
||||
import { NavigateBack } from '@/components';
|
||||
|
||||
export default function ChatRoutesLayout() {
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerLeft: () => <NavigateBack />,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="setting/index" options={{ headerShown: true }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRef } from 'react';
|
||||
import { FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||
import { Drawer } from 'react-native-drawer-layout';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { ChatMessage } from '@/types/message';
|
||||
import Hydration from '@/features/Hydration';
|
||||
import TopicDrawer from '@/features/TopicDrawer';
|
||||
import ChatHeader from './(components)/ChatHeader';
|
||||
import ChatInput from './(components)/ChatInput';
|
||||
import ChatList from './(components)/ChatList';
|
||||
import SessionList from '@/features/SideBar';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function ChatWithDrawer() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [drawerOpen, setDrawerOpen, toggleDrawer] = useGlobalStore((s) => [
|
||||
s.drawerOpen,
|
||||
s.setDrawerOpen,
|
||||
s.toggleDrawer,
|
||||
]);
|
||||
const chatListRef = useRef<FlatList<ChatMessage>>(null);
|
||||
|
||||
const ChatContent = () => (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
style={styles.chatContainer}
|
||||
>
|
||||
<ChatHeader onDrawerToggle={toggleDrawer} />
|
||||
|
||||
<ChatList scrollViewRef={chatListRef} />
|
||||
|
||||
<ChatInput />
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Hydration组件:处理URL和Store的双向同步 */}
|
||||
<Hydration />
|
||||
|
||||
{/* 左侧Session抽屉 */}
|
||||
<Drawer
|
||||
drawerPosition="left"
|
||||
drawerStyle={styles.drawerStyle}
|
||||
drawerType="slide"
|
||||
hideStatusBarOnOpen={false}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onOpen={() => setDrawerOpen(true)}
|
||||
open={drawerOpen}
|
||||
overlayStyle={styles.drawerOverlay}
|
||||
renderDrawerContent={() => <SessionList />}
|
||||
swipeEdgeWidth={50}
|
||||
swipeEnabled={true}
|
||||
>
|
||||
{/* 右侧Topic抽屉 */}
|
||||
<TopicDrawer>
|
||||
<ChatContent />
|
||||
</TopicDrawer>
|
||||
</Drawer>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { Avatar, ListItem } from '@/components';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionMetaSelectors } from '@/store/session/selectors';
|
||||
import { useStyles } from './styles';
|
||||
import { AVATAR_SIZE_LARGE } from '@/const/common';
|
||||
|
||||
export default function AgentDetail() {
|
||||
const { t } = useTranslation();
|
||||
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
|
||||
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
|
||||
const description = useSessionStore(sessionMetaSelectors.currentAgentDescription);
|
||||
const { styles } = useStyles();
|
||||
|
||||
// Mock 对话历史数据
|
||||
const history = [
|
||||
{ text: '二郎神杨戬', time: '15:07' },
|
||||
{ text: '孙悟空大闹天宫', time: '15:10' },
|
||||
{ text: '哪吒三太子', time: '15:12' },
|
||||
{ text: '托塔天王李靖', time: '15:15' },
|
||||
{ text: '嫦娥奔月', time: '15:18' },
|
||||
{ text: '牛魔王大战孙悟空', time: '15:22' },
|
||||
// 可以添加更多历史
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ alignItems: 'center' }} style={[styles.container]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar alt={title} avatar={avatar || '🤖'} size={AVATAR_SIZE_LARGE} />
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
|
||||
{/* 对话历史卡片 */}
|
||||
<View style={styles.historySection}>
|
||||
<Text style={styles.historyTitle}>{t('history', { ns: 'chat' })}</Text>
|
||||
{history.map((item, idx) => (
|
||||
<ListItem
|
||||
extra={<Text style={styles.historyTime}>{item.time}</Text>}
|
||||
key={idx}
|
||||
title={item.text}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
avatarContainer: {
|
||||
marginBottom: token.marginSM,
|
||||
marginTop: token.marginLG,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
description: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: token.lineHeightLG,
|
||||
marginBottom: token.marginSM,
|
||||
paddingHorizontal: token.paddingLG,
|
||||
textAlign: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: token.controlHeight,
|
||||
marginBottom: token.marginSM,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
width: '100%',
|
||||
},
|
||||
historyRow: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: token.borderRadius,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: token.marginXXS,
|
||||
paddingHorizontal: token.paddingXXS,
|
||||
paddingVertical: token.paddingXS,
|
||||
},
|
||||
historySection: {
|
||||
alignSelf: 'stretch',
|
||||
marginHorizontal: token.margin,
|
||||
marginTop: token.margin,
|
||||
},
|
||||
historyText: {
|
||||
color: token.colorPrimary,
|
||||
flex: 1,
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
textAlign: 'left',
|
||||
},
|
||||
historyTime: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: token.fontSizeSM,
|
||||
marginLeft: token.marginSM,
|
||||
minWidth: 44,
|
||||
textAlign: 'right',
|
||||
},
|
||||
historyTitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
marginLeft: token.marginXXS,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: token.borderRadius,
|
||||
borderWidth: 1,
|
||||
margin: token.marginXXS,
|
||||
paddingHorizontal: token.paddingSM,
|
||||
paddingVertical: token.paddingXXS,
|
||||
},
|
||||
tagText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
tagsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: token.marginXS,
|
||||
justifyContent: 'center',
|
||||
marginBottom: token.margin,
|
||||
marginTop: token.marginXXS,
|
||||
},
|
||||
title: {
|
||||
color: token.colorTextHeading,
|
||||
fontSize: token.fontSizeHeading3,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
marginBottom: token.marginXS,
|
||||
marginTop: token.marginXXS,
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createStyles, getAlphaColor } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
chatContainer: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
drawerOverlay: {
|
||||
backgroundColor: getAlphaColor(token.colorBorderBg, 0.9),
|
||||
},
|
||||
drawerStyle: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
width: '80%',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { ICON_SIZE_SMALL } from '@/const/common';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface SettingItemProps {
|
||||
customContent?: ReactNode;
|
||||
description?: string;
|
||||
extra?: string;
|
||||
href?: Href;
|
||||
isLast?: boolean;
|
||||
isSelected?: boolean;
|
||||
onPress?: () => void;
|
||||
onSwitchChange?: (value: boolean) => void;
|
||||
showCheckmark?: boolean;
|
||||
showNewBadge?: boolean;
|
||||
showSwitch?: boolean;
|
||||
switchValue?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const SettingItem = ({
|
||||
title,
|
||||
extra,
|
||||
onPress,
|
||||
href,
|
||||
showSwitch,
|
||||
switchValue,
|
||||
onSwitchChange,
|
||||
description,
|
||||
isLast,
|
||||
showNewBadge,
|
||||
isSelected = false,
|
||||
showCheckmark = false,
|
||||
customContent,
|
||||
}: SettingItemProps) => {
|
||||
const { styles, token } = useStyles();
|
||||
|
||||
const renderRightContent = () => {
|
||||
if (showSwitch) {
|
||||
return (
|
||||
<Switch
|
||||
onValueChange={onSwitchChange}
|
||||
thumbColor={switchValue ? token.colorBgContainer : token.colorBgContainer}
|
||||
trackColor={{
|
||||
false: token.colorBgContainer,
|
||||
true: token.colorPrimary,
|
||||
}}
|
||||
value={switchValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{extra && <Text style={styles.settingItemExtra}>{extra}</Text>}
|
||||
{showNewBadge && <View style={styles.badge} />}
|
||||
{showCheckmark && isSelected && (
|
||||
<Text style={styles.checkmark}>
|
||||
<Check color={token.colorText} size={ICON_SIZE_SMALL} />
|
||||
</Text>
|
||||
)}
|
||||
{(onPress || href) && !showCheckmark && <Text style={styles.settingItemArrow}>›</Text>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text style={styles.settingItemTitle}>{title}</Text>
|
||||
{description && <Text style={styles.settingItemDescription}>{description}</Text>}
|
||||
</View>
|
||||
<View style={styles.settingItemRight}>{renderRightContent()}</View>
|
||||
</View>
|
||||
{customContent && <View style={styles.customContent}>{customContent}</View>}
|
||||
{!isLast && <View style={styles.separator} />}
|
||||
</View>
|
||||
);
|
||||
|
||||
const touchableContent = <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link asChild href={href}>
|
||||
{touchableContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
badge: {
|
||||
backgroundColor: token.colorError,
|
||||
borderRadius: token.borderRadiusXS,
|
||||
height: 8,
|
||||
marginRight: token.marginXS,
|
||||
width: 8,
|
||||
},
|
||||
checkmark: {},
|
||||
customContent: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
paddingBottom: token.paddingMD,
|
||||
paddingHorizontal: token.paddingMD,
|
||||
},
|
||||
separator: {
|
||||
backgroundColor: token.colorBorderSecondary,
|
||||
height: 1,
|
||||
marginHorizontal: token.marginMD,
|
||||
},
|
||||
settingItem: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 56,
|
||||
paddingHorizontal: token.paddingMD,
|
||||
paddingVertical: token.paddingSM,
|
||||
},
|
||||
settingItemArrow: {
|
||||
color: token.colorBorder,
|
||||
fontSize: token.sizeLG,
|
||||
marginLeft: token.marginSM,
|
||||
},
|
||||
settingItemDescription: {
|
||||
color: token.colorTextDescription,
|
||||
fontSize: token.fontSizeSM,
|
||||
marginTop: token.marginXS,
|
||||
},
|
||||
settingItemExtra: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeLG,
|
||||
marginRight: token.marginXXS,
|
||||
},
|
||||
settingItemLeft: {
|
||||
flexDirection: 'column',
|
||||
flexShrink: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingItemRight: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
settingItemTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export { SettingItem } from './SettingItem';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NavigateBack from '@/components/NavigateBack';
|
||||
import { useThemedScreenOptions } from '@/const/navigation';
|
||||
|
||||
export default function SettingRoutesLayout() {
|
||||
const { t } = useTranslation(['setting']);
|
||||
const themedScreenOptions = useThemedScreenOptions();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...themedScreenOptions,
|
||||
headerLeft: () => <NavigateBack />,
|
||||
headerTitle: t('title', { ns: 'setting' }),
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="providers/index"
|
||||
options={{
|
||||
headerTitle: t('providers', { ns: 'setting' }),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="providers/openai"
|
||||
options={{
|
||||
headerTitle: t('openai', { ns: 'setting' }),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="locale/index"
|
||||
options={{
|
||||
headerTitle: t('locale.title', { ns: 'setting' }),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="account/index"
|
||||
options={{
|
||||
headerTitle: t('account.title', { ns: 'setting' }),
|
||||
}}
|
||||
/>
|
||||
{/* Developer options screen */}
|
||||
<Stack.Screen
|
||||
name="developer/index"
|
||||
options={{
|
||||
headerTitle: t('developer', { ns: 'setting' }),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, View, Alert } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack } from 'expo-router';
|
||||
import { ListGroup } from '@/components';
|
||||
import { useAuth, useAuthActions } from '@/store/user';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import Button from '@/components/Button';
|
||||
import { SettingItem } from '../(components)/SettingItem';
|
||||
import { useStyles } from './style';
|
||||
|
||||
export default function AccountScreen() {
|
||||
const { t } = useTranslation(['setting', 'auth', 'error']);
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { logout } = useAuthActions();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t('actions.confirm', { ns: 'common' }),
|
||||
t('account.signOut.confirm', { ns: 'setting' }),
|
||||
[
|
||||
{
|
||||
style: 'cancel',
|
||||
text: t('actions.cancel', { ns: 'common' }),
|
||||
},
|
||||
{
|
||||
onPress: async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Logout failed';
|
||||
Alert.alert(t('error.title', { ns: 'error' }), errorMessage);
|
||||
}
|
||||
},
|
||||
style: 'destructive',
|
||||
text: t('actions.confirm', { ns: 'common' }),
|
||||
},
|
||||
],
|
||||
{ cancelable: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t('account.title', { ns: 'setting' }) }} />
|
||||
<ScrollView style={styles.container}>
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
{/* Avatar Section */}
|
||||
<View style={styles.avatarSection}>
|
||||
<Avatar alt={user.name || user.email} avatar={user.avatar} size={80} />
|
||||
</View>
|
||||
|
||||
{/* User Information */}
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
extra={user.name || user.username || ''}
|
||||
title={t('account.profile.name', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
extra={user.email}
|
||||
isLast
|
||||
title={t('account.profile.email', { ns: 'setting' })}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
{/* Logout Section */}
|
||||
<View style={styles.signOutSection}>
|
||||
<Button block danger onPress={handleSignOut} size="large" type="primary">
|
||||
{t('account.signOut.label', { ns: 'setting' })}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: token.marginLG,
|
||||
paddingVertical: token.paddingXL,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
paddingHorizontal: token.padding,
|
||||
},
|
||||
signOutSection: {
|
||||
marginTop: token.marginXS,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, Alert } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ListGroup } from '@/components';
|
||||
import { SettingItem } from '../(components)';
|
||||
import { useStyles } from './styles';
|
||||
import {
|
||||
clearAuthData,
|
||||
expireAccessTokenNow,
|
||||
expireRefreshTokenNow,
|
||||
invalidateAccessToken,
|
||||
invalidateRefreshToken,
|
||||
} from './utils';
|
||||
|
||||
export default function DeveloperScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['setting']);
|
||||
|
||||
// 占位:后续可接入全局调试开关或日志级别控制
|
||||
const [enableVerboseLog, setEnableVerboseLog] = React.useState(false);
|
||||
|
||||
const toggleVerboseLog = (value: boolean) => {
|
||||
setEnableVerboseLog(value);
|
||||
Alert.alert(
|
||||
t('title', { ns: 'setting' }),
|
||||
`${t('developer', { ns: 'setting' })}: ${value ? 'ON' : 'OFF'}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.contentContainer} style={styles.container}>
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
onSwitchChange={toggleVerboseLog}
|
||||
showSwitch
|
||||
switchValue={enableVerboseLog}
|
||||
title="Verbose Log"
|
||||
/>
|
||||
<SettingItem
|
||||
description="Open a placeholder action"
|
||||
isLast
|
||||
onPress={() => Alert.alert('Developer', 'Placeholder action')}
|
||||
title="Placeholder"
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
onPress={async () => {
|
||||
try {
|
||||
await expireAccessTokenNow();
|
||||
Alert.alert('Developer', '已使访问令牌立即过期');
|
||||
} catch (e) {
|
||||
Alert.alert('Developer', `操作失败: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}}
|
||||
title="访问令牌过期"
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={async () => {
|
||||
try {
|
||||
await expireRefreshTokenNow();
|
||||
Alert.alert('Developer', '已使刷新令牌立即过期');
|
||||
} catch (e) {
|
||||
Alert.alert('Developer', `操作失败: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}}
|
||||
title="刷新令牌过期"
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={async () => {
|
||||
try {
|
||||
await invalidateAccessToken();
|
||||
Alert.alert('Developer', '已写入无效的访问令牌');
|
||||
} catch (e) {
|
||||
Alert.alert('Developer', `操作失败: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}}
|
||||
title="无效访问令牌"
|
||||
/>
|
||||
<SettingItem
|
||||
onPress={async () => {
|
||||
try {
|
||||
await invalidateRefreshToken();
|
||||
Alert.alert('Developer', '已写入无效的刷新令牌');
|
||||
} catch (e) {
|
||||
Alert.alert('Developer', `操作失败: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}}
|
||||
title="无效刷新令牌"
|
||||
/>
|
||||
<SettingItem
|
||||
isLast
|
||||
onPress={async () => {
|
||||
try {
|
||||
await clearAuthData();
|
||||
Alert.alert('Developer', '已清空认证数据');
|
||||
} catch (e) {
|
||||
Alert.alert('Developer', `操作失败: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}}
|
||||
title="清空认证数据"
|
||||
/>
|
||||
</ListGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Token } from '@/_types/user';
|
||||
import { TokenStorage } from '@/services/_auth/tokenStorage';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const updateToken = async (mutate: (token: Token) => Token): Promise<void> => {
|
||||
const current = await TokenStorage.getToken();
|
||||
if (!current) throw new Error('当前无可用 Token');
|
||||
const next = mutate(current);
|
||||
await TokenStorage.storeToken(next);
|
||||
};
|
||||
|
||||
export const expireAccessTokenNow = async (): Promise<void> => {
|
||||
const now = Math.floor(Date.now() / 1000) - 10;
|
||||
await updateToken((t) => ({ ...t, expiresAt: now }));
|
||||
};
|
||||
|
||||
export const expireRefreshTokenNow = async (): Promise<void> => {
|
||||
const now = Math.floor(Date.now() / 1000) - 10;
|
||||
await updateToken((t) => ({ ...t, refreshExpiresAt: now }));
|
||||
};
|
||||
|
||||
export const invalidateAccessToken = async (): Promise<void> => {
|
||||
await updateToken((t) => ({ ...t, accessToken: 'invalid-access-token' }));
|
||||
};
|
||||
|
||||
export const invalidateRefreshToken = async (): Promise<void> => {
|
||||
await updateToken((t) => ({ ...t, refreshToken: 'invalid-refresh-token' }));
|
||||
};
|
||||
|
||||
export const clearAuthData = async (): Promise<void> => {
|
||||
await TokenStorage.clearAll();
|
||||
useUserStore.getState().clearAuthState();
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, Text } from 'react-native';
|
||||
import { isDev } from '@/utils/env';
|
||||
|
||||
import { ListGroup, ColorSwatches } from '@/components';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { version } from '../../../package.json';
|
||||
import {
|
||||
useTheme as useAppTheme,
|
||||
findCustomThemeName,
|
||||
PrimaryColors,
|
||||
NeutralColors,
|
||||
primaryColors,
|
||||
neutralColors,
|
||||
} from '@/theme';
|
||||
import { useStyles } from './styles';
|
||||
import { useSettingStore } from '@/store/setting';
|
||||
import Slider from '@/components/Slider';
|
||||
|
||||
import { SettingItem } from './(components)';
|
||||
import { FONT_SIZE_STANDARD, FONT_SIZE_LARGE, FONT_SIZE_SMALL } from '@/const/common';
|
||||
|
||||
export default function SettingScreen() {
|
||||
const { t } = useTranslation(['setting', 'auth', 'common', 'error']);
|
||||
const { theme, setThemeMode } = useAppTheme();
|
||||
const { getLocaleDisplayName } = useLocale();
|
||||
|
||||
// 颜色配置状态
|
||||
const { primaryColor, neutralColor, setPrimaryColor, setNeutralColor, fontSize, setFontSize } =
|
||||
useSettingStore();
|
||||
|
||||
const { styles } = useStyles();
|
||||
|
||||
const isFollowSystem = theme.mode === 'auto';
|
||||
|
||||
// 主色配置数据
|
||||
const primaryColorSwatchesData = [
|
||||
{
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
title: 'Default',
|
||||
},
|
||||
{
|
||||
color: primaryColors.red,
|
||||
title: 'Red',
|
||||
},
|
||||
{
|
||||
color: primaryColors.orange,
|
||||
title: 'Orange',
|
||||
},
|
||||
{
|
||||
color: primaryColors.gold,
|
||||
title: 'Gold',
|
||||
},
|
||||
{
|
||||
color: primaryColors.yellow,
|
||||
title: 'Yellow',
|
||||
},
|
||||
{
|
||||
color: primaryColors.lime,
|
||||
title: 'Lime',
|
||||
},
|
||||
{
|
||||
color: primaryColors.green,
|
||||
title: 'Green',
|
||||
},
|
||||
{
|
||||
color: primaryColors.cyan,
|
||||
title: 'Cyan',
|
||||
},
|
||||
{
|
||||
color: primaryColors.blue,
|
||||
title: 'Blue',
|
||||
},
|
||||
{
|
||||
color: primaryColors.geekblue,
|
||||
title: 'Geekblue',
|
||||
},
|
||||
{
|
||||
color: primaryColors.purple,
|
||||
title: 'Purple',
|
||||
},
|
||||
{
|
||||
color: primaryColors.magenta,
|
||||
title: 'Magenta',
|
||||
},
|
||||
{
|
||||
color: primaryColors.volcano,
|
||||
title: 'Volcano',
|
||||
},
|
||||
];
|
||||
|
||||
// 中性色配置数据
|
||||
const neutralColorSwatchesData = [
|
||||
{
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
title: 'Default',
|
||||
},
|
||||
{
|
||||
color: neutralColors.mauve,
|
||||
title: 'Mauve',
|
||||
},
|
||||
{
|
||||
color: neutralColors.slate,
|
||||
title: 'Slate',
|
||||
},
|
||||
{
|
||||
color: neutralColors.sage,
|
||||
title: 'Sage',
|
||||
},
|
||||
{
|
||||
color: neutralColors.olive,
|
||||
title: 'Olive',
|
||||
},
|
||||
{
|
||||
color: neutralColors.sand,
|
||||
title: 'Sand',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Theme settings inline */}
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
onSwitchChange={(enabled) => setThemeMode(enabled ? 'auto' : 'light')}
|
||||
showSwitch
|
||||
switchValue={isFollowSystem}
|
||||
title={t('theme.auto', { ns: 'setting' })}
|
||||
/>
|
||||
{!isFollowSystem && (
|
||||
<>
|
||||
<SettingItem
|
||||
isSelected={theme.mode === 'light'}
|
||||
onPress={() => setThemeMode('light')}
|
||||
showCheckmark
|
||||
title={t('theme.light', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
isSelected={theme.mode === 'dark'}
|
||||
onPress={() => setThemeMode('dark')}
|
||||
showCheckmark
|
||||
title={t('theme.dark', { ns: 'setting' })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
customContent={
|
||||
<ColorSwatches
|
||||
colors={primaryColorSwatchesData}
|
||||
gap={8}
|
||||
onChange={(color: any) => {
|
||||
const name = findCustomThemeName('primary', color) as PrimaryColors;
|
||||
setPrimaryColor(name || '');
|
||||
}}
|
||||
size={32}
|
||||
value={primaryColor ? primaryColors[primaryColor] : undefined}
|
||||
/>
|
||||
}
|
||||
title={t('theme.primaryColor.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
customContent={
|
||||
<ColorSwatches
|
||||
colors={neutralColorSwatchesData}
|
||||
gap={8}
|
||||
onChange={(color: any) => {
|
||||
const name = findCustomThemeName('neutral', color) as NeutralColors;
|
||||
setNeutralColor(name || '');
|
||||
}}
|
||||
size={32}
|
||||
value={neutralColor ? neutralColors[neutralColor] : undefined}
|
||||
/>
|
||||
}
|
||||
title={t('theme.neutralColor.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
customContent={
|
||||
<Slider
|
||||
marks={{
|
||||
[FONT_SIZE_SMALL]: { label: <Text style={styles.fontSizeSmall}>A</Text> },
|
||||
[FONT_SIZE_STANDARD]: {
|
||||
label: (
|
||||
<Text style={styles.fontSizeStandard}>
|
||||
{t('theme.fontSize.standard', { ns: 'setting' })}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
|
||||
[FONT_SIZE_LARGE]: { label: <Text style={styles.fontSizeLarge}>A</Text> },
|
||||
}}
|
||||
max={FONT_SIZE_LARGE}
|
||||
min={FONT_SIZE_SMALL}
|
||||
onChangeComplete={setFontSize}
|
||||
step={1}
|
||||
value={fontSize}
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
title={t('theme.fontSize.title', { ns: 'setting' })}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
extra={getLocaleDisplayName()}
|
||||
href="/setting/locale"
|
||||
title={t('locale.title', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem href="/setting/account" title={t('account.title', { ns: 'setting' })} />
|
||||
<SettingItem href="/setting/providers" title={t('providers', { ns: 'setting' })} />
|
||||
</ListGroup>
|
||||
|
||||
{isDev && (
|
||||
<ListGroup>
|
||||
<SettingItem href="/setting/developer" isLast title={t('developer', { ns: 'setting' })} />
|
||||
</ListGroup>
|
||||
)}
|
||||
|
||||
<ListGroup>
|
||||
<SettingItem
|
||||
href="https://lobehub.com/docs?utm_source=mobile"
|
||||
title={t('help', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
href="https://github.com/lobehub/lobe-chat/issues/new/choose"
|
||||
title={t('feedback', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem
|
||||
href="https://lobehub.com/changelog"
|
||||
title={t('changelog', { ns: 'setting' })}
|
||||
/>
|
||||
<SettingItem href="mailto:support@lobehub.com" title={t('support', { ns: 'setting' })} />
|
||||
<SettingItem extra={version} isLast showNewBadge title={t('about', { ns: 'setting' })} />
|
||||
</ListGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
|
||||
import { ListGroup } from '@/components';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { LANGUAGE_OPTIONS, LocaleMode } from '@/i18n/resource';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SettingItem } from '../(components)';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export default function LocaleScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { localeMode, changeLocale } = useLocale();
|
||||
const { t } = useTranslation(['setting']);
|
||||
|
||||
const handleLocaleChange = async (locale: LocaleMode) => {
|
||||
await changeLocale(locale);
|
||||
};
|
||||
|
||||
const localeOptions = [
|
||||
{ label: t('locale.auto.title', { ns: 'setting' }), value: 'auto' },
|
||||
...LANGUAGE_OPTIONS,
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.contentContainer} style={styles.container}>
|
||||
<ListGroup>
|
||||
{localeOptions.map((option, index) => (
|
||||
<SettingItem
|
||||
description={
|
||||
option.value === 'auto' ? t('locale.auto.description', { ns: 'setting' }) : ''
|
||||
}
|
||||
isLast={index === localeOptions.length - 1}
|
||||
isSelected={localeMode === option.value}
|
||||
key={option.value}
|
||||
onPress={() => handleLocaleChange(option.value as LocaleMode)}
|
||||
showCheckmark={true}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</ListGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { memo } from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { AiProviderListItem } from '@/types/aiProvider';
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { InstantSwitch } from '@/components';
|
||||
|
||||
import { useStyles } from './style';
|
||||
import { ProviderCombine } from '@lobehub/icons-rn';
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: AiProviderListItem;
|
||||
}
|
||||
|
||||
const ProviderCard = memo<ProviderCardProps>(({ provider }) => {
|
||||
const { styles } = useStyles();
|
||||
const router = useRouter();
|
||||
const token = useThemeToken();
|
||||
const { description, id, enabled } = provider;
|
||||
|
||||
// 获取store中的方法
|
||||
const { toggleProviderEnabled } = useAiInfraStore();
|
||||
|
||||
const handlePress = () => {
|
||||
// 使用动态路由方案A: /setting/providers/[id]
|
||||
router.push(`/setting/providers/${id}` as any);
|
||||
};
|
||||
|
||||
const handleSwitchChange = async (value: boolean) => {
|
||||
try {
|
||||
// 执行切换操作
|
||||
await toggleProviderEnabled(id, value);
|
||||
console.log(`Successfully toggled provider ${id} to ${value ? 'enabled' : 'disabled'}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle provider ${id}:`, error);
|
||||
// TODO: 可以考虑添加toast提示用户操作失败
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
{/* 顶部:Logo + 名称(可点击跳转) */}
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={handlePress}>
|
||||
<View style={styles.header}>
|
||||
<ProviderCombine
|
||||
color={token.colorText}
|
||||
iconProps={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
provider={id}
|
||||
size={24}
|
||||
type={'color'}
|
||||
/>
|
||||
<InstantSwitch
|
||||
enabled={enabled}
|
||||
onChange={handleSwitchChange}
|
||||
size="small"
|
||||
thumbColor={token.colorBgContainer}
|
||||
trackColor={{
|
||||
false: '#e9e9eb', // iOS风格关闭状态
|
||||
true: '#34c759', // iOS风格开启状态
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 中部:描述文字(可点击跳转) */}
|
||||
<TouchableOpacity activeOpacity={0.7} onPress={handlePress}>
|
||||
<Text numberOfLines={2} style={styles.description}>
|
||||
{description}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
// 底部操作区域
|
||||
actionArea: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
|
||||
// 卡片容器 - 对标web端
|
||||
card: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
elevation: 1, // Android阴影
|
||||
overflow: 'hidden',
|
||||
padding: 16,
|
||||
shadowColor: token.colorTextQuaternary,
|
||||
shadowOffset: {
|
||||
height: 1,
|
||||
width: 0,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
},
|
||||
|
||||
// 最外层容器
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
|
||||
// 描述文字 - 对标web端colorTextDescription
|
||||
description: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 12,
|
||||
minHeight: 44, // 对标web端最小高度
|
||||
},
|
||||
|
||||
// 分割线 - 对标web端Divider
|
||||
divider: {
|
||||
backgroundColor: token.colorBorderSecondary,
|
||||
height: 1,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: -16, // 延伸到卡片边缘
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
// 顶部:Logo + 标题
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
// Loading容器 - 固定宽度避免布局跳跃
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 24, // 固定宽度,适配18px的ActivityIndicator + 一些边距
|
||||
},
|
||||
|
||||
// 占位元素,确保Switch右对齐
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Switch容器
|
||||
switchContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// 标题文字
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { memo } from 'react';
|
||||
import { ProviderIcon } from '@lobehub/icons-rn';
|
||||
|
||||
const ProviderLogo = memo<{ provider: string }>(({ provider }) => {
|
||||
return <ProviderIcon provider={provider} size={24} />;
|
||||
});
|
||||
|
||||
export default ProviderLogo;
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useCallback, useEffect, memo } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Eye, EyeOff } from 'lucide-react-native';
|
||||
import { useDebounceFn } from 'ahooks';
|
||||
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { AiProviderDetailItem } from '@/types/aiProvider';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface ConfigurationSectionProps {
|
||||
provider: AiProviderDetailItem;
|
||||
}
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
return !url || url.match(/^https?:\/\/.+/);
|
||||
};
|
||||
|
||||
const ConfigurationSection = memo<ConfigurationSectionProps>(({ provider }) => {
|
||||
const { styles } = useStyles();
|
||||
const token = useThemeToken();
|
||||
|
||||
// Store hooks
|
||||
const { useFetchAiProviderItem, updateAiProviderConfig } = useAiInfraStore();
|
||||
const { data: providerData } = useFetchAiProviderItem(provider.id);
|
||||
|
||||
// Form state
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [proxyUrl, setProxyUrl] = useState('');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Initialize form values from store
|
||||
useEffect(() => {
|
||||
if (providerData?.keyVaults) {
|
||||
setApiKey(providerData.keyVaults.apiKey || '');
|
||||
setProxyUrl(providerData.keyVaults.baseURL || '');
|
||||
}
|
||||
}, [providerData]);
|
||||
|
||||
// Debounced update function
|
||||
const { run: debouncedUpdate } = useDebounceFn(
|
||||
async (field: string, value: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updateData = {
|
||||
keyVaults: {
|
||||
...providerData?.keyVaults,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
|
||||
await updateAiProviderConfig(provider.id, updateData);
|
||||
console.log(`Updated ${field} for provider ${provider.id}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update ${field}:`, error);
|
||||
Alert.alert('Update Failed', `Failed to save ${field}. Please try again.`);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
{ wait: 500 },
|
||||
);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(value: string) => {
|
||||
setApiKey(value);
|
||||
debouncedUpdate('apiKey', value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const handleProxyUrlChange = useCallback(
|
||||
(value: string) => {
|
||||
setProxyUrl(value);
|
||||
|
||||
// Simple URL validation
|
||||
if (value && !/^https?:\/\/.+/.test(value)) {
|
||||
// Don't update invalid URLs, but still update the local state
|
||||
return;
|
||||
}
|
||||
|
||||
debouncedUpdate('baseURL', value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const toggleApiKeyVisibility = () => {
|
||||
setShowApiKey(!showApiKey);
|
||||
};
|
||||
|
||||
const proxyUrlConfig = provider.settings?.proxyUrl;
|
||||
const showProxyUrl = proxyUrlConfig || provider.source === 'custom';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.sectionTitle}>Configuration</Text>
|
||||
|
||||
{/* API Key Field */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.inputLabel}>API Key</Text>
|
||||
<Text style={styles.inputDescription}>
|
||||
Please enter your {provider.name || provider.id} API Key
|
||||
</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChangeText={handleApiKeyChange}
|
||||
placeholder={`Enter your ${provider.name || provider.id} API Key`}
|
||||
placeholderTextColor={token.colorTextTertiary}
|
||||
secureTextEntry={!showApiKey}
|
||||
style={styles.textInput}
|
||||
value={apiKey}
|
||||
/>
|
||||
<TouchableOpacity onPress={toggleApiKeyVisibility} style={styles.eyeButton}>
|
||||
{showApiKey ? (
|
||||
<EyeOff color={token.colorTextSecondary} size={18} />
|
||||
) : (
|
||||
<Eye color={token.colorTextSecondary} size={18} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* API Proxy URL Field */}
|
||||
{showProxyUrl && (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.inputLabel}>
|
||||
{(proxyUrlConfig && typeof proxyUrlConfig === 'object' && proxyUrlConfig.title) ||
|
||||
'API Proxy URL'}
|
||||
</Text>
|
||||
<Text style={styles.inputDescription}>
|
||||
{(proxyUrlConfig && typeof proxyUrlConfig === 'object' && proxyUrlConfig.desc) ||
|
||||
'Must include http(s)://'}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.inputContainer, !isValidUrl(proxyUrl) && styles.inputContainerError]}
|
||||
>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
onChangeText={handleProxyUrlChange}
|
||||
placeholder={
|
||||
(proxyUrlConfig &&
|
||||
typeof proxyUrlConfig === 'object' &&
|
||||
proxyUrlConfig.placeholder) ||
|
||||
'https://api.example.com/v1'
|
||||
}
|
||||
placeholderTextColor={token.colorTextTertiary}
|
||||
style={styles.textInput}
|
||||
value={proxyUrl}
|
||||
/>
|
||||
</View>
|
||||
{!isValidUrl(proxyUrl) && (
|
||||
<Text style={styles.errorText}>
|
||||
Please enter a valid URL starting with http:// or https://
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Update status indicator */}
|
||||
{isUpdating && <Text style={styles.updatingIndicator}>Saving configuration...</Text>}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default ConfigurationSection;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
// 主容器
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// 错误文字
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
// 眼睛按钮
|
||||
eyeButton: {
|
||||
padding: 12,
|
||||
},
|
||||
|
||||
// 输入框容器
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// 输入框容器错误状态
|
||||
inputContainerError: {
|
||||
borderColor: token.colorError,
|
||||
},
|
||||
|
||||
// 输入描述文字
|
||||
inputDescription: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
// 输入组容器
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
// 输入标签
|
||||
inputLabel: {
|
||||
color: token.colorText,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
// 章节标题
|
||||
sectionTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
// 文本输入框
|
||||
textInput: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
|
||||
// 更新状态指示器
|
||||
updatingIndicator: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
|
||||
import { RefreshCcw, Search } from 'lucide-react-native';
|
||||
import { ModelIcon } from '@lobehub/icons-rn';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiModelSelectors } from '@/store/aiInfra/selectors';
|
||||
import { AiProviderModelListItem } from '@/types/aiModel';
|
||||
import { InstantSwitch, ModelInfoTags, Tag, useToast } from '@/components';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface ModelsSectionProps {
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
interface ModelCardProps {
|
||||
model: AiProviderModelListItem;
|
||||
onToggle: (modelId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const ModelCard = memo<ModelCardProps>(({ model, onToggle }) => {
|
||||
const { styles } = useStyles();
|
||||
const token = useThemeToken();
|
||||
const toast = useToast();
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
onToggle(model.id, value);
|
||||
};
|
||||
|
||||
const handleCopyModelId = () => {
|
||||
Clipboard.setString(model.id);
|
||||
toast.success('复制成功');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardContent}>
|
||||
<ModelIcon model={model.id} size={32} />
|
||||
<View style={styles.modelInfo}>
|
||||
<View style={styles.topRow}>
|
||||
<Text style={styles.modelName}>{model.displayName || model.id}</Text>
|
||||
<ModelInfoTags {...model.abilities} contextWindowTokens={model.contextWindowTokens} />
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleCopyModelId} style={styles.bottomRow}>
|
||||
<Tag style={styles.modelIdTag} textStyle={styles.modelIdText}>
|
||||
{model.id}
|
||||
</Tag>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.switchContainer}>
|
||||
<InstantSwitch
|
||||
enabled={model.enabled}
|
||||
onChange={async (enabled) => {
|
||||
handleToggle(enabled);
|
||||
}}
|
||||
size="small"
|
||||
thumbColor={token.colorBgContainer}
|
||||
trackColor={{
|
||||
false: '#e9e9eb',
|
||||
true: '#34c759',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const ModelsSection = memo<ModelsSectionProps>(({ providerId }) => {
|
||||
const { styles } = useStyles();
|
||||
const token = useThemeToken();
|
||||
|
||||
// Store hooks
|
||||
const { useFetchAiProviderModels, fetchRemoteModelList, toggleModelEnabled } = useAiInfraStore();
|
||||
const { data: modelList, isLoading } = useFetchAiProviderModels(providerId);
|
||||
const totalModels = useAiInfraStore(aiModelSelectors.totalAiProviderModelList);
|
||||
|
||||
// State
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const handleFetchModels = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
await fetchRemoteModelList(providerId);
|
||||
Alert.alert('Success', 'Models fetched successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
Alert.alert('Error', 'Failed to fetch models. Please try again.');
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [providerId, fetchRemoteModelList]);
|
||||
|
||||
const handleToggleModel = useCallback(
|
||||
async (modelId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleModelEnabled({ enabled, id: modelId });
|
||||
console.log(`Model ${modelId} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle model ${modelId}:`, error);
|
||||
Alert.alert('Error', `Failed to ${enabled ? 'enable' : 'disable'} model.`);
|
||||
}
|
||||
},
|
||||
[toggleModelEnabled],
|
||||
);
|
||||
|
||||
// Filter and group models
|
||||
const filteredAndGroupedModels = useMemo(() => {
|
||||
if (!modelList) return [];
|
||||
|
||||
const filtered = modelList.filter((model) => {
|
||||
if (!searchKeyword.trim()) return true;
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
return (
|
||||
model.id.toLowerCase().includes(keyword) ||
|
||||
model.displayName?.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
|
||||
const enabled = filtered.filter((model) => model.enabled);
|
||||
const disabled = filtered.filter((model) => !model.enabled);
|
||||
|
||||
const sections = [];
|
||||
if (enabled.length > 0) {
|
||||
sections.push({
|
||||
data: enabled,
|
||||
title: `Enabled (${enabled.length})`,
|
||||
});
|
||||
}
|
||||
if (disabled.length > 0) {
|
||||
sections.push({
|
||||
data: disabled,
|
||||
title: `Disabled (${disabled.length})`,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [modelList, searchKeyword]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.sectionTitle}>Models</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.loadingContainerPage}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Loading models...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.sectionTitle}>Models</Text>
|
||||
<Text style={styles.modelCount}>{totalModels} models available</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsContainer}>
|
||||
<TouchableOpacity
|
||||
disabled={isFetching}
|
||||
onPress={handleFetchModels}
|
||||
style={[styles.fetchButton, isFetching && styles.fetchButtonDisabled]}
|
||||
>
|
||||
<RefreshCcw
|
||||
color={isFetching ? token.colorTextTertiary : token.colorWhite}
|
||||
size={14}
|
||||
/>
|
||||
<Text style={[styles.fetchButtonText, isFetching && styles.fetchButtonTextDisabled]}>
|
||||
{isFetching ? 'Fetching...' : 'Fetch models'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.searchContainer}>
|
||||
<Search color={token.colorTextSecondary} size={16} />
|
||||
<TextInput
|
||||
onChangeText={setSearchKeyword}
|
||||
placeholder="Search models..."
|
||||
placeholderTextColor={token.colorTextTertiary}
|
||||
style={styles.searchInput}
|
||||
value={searchKeyword}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{filteredAndGroupedModels.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchKeyword.trim()
|
||||
? 'No models match your search criteria'
|
||||
: 'No models found for this provider.\nTry fetching models from the server.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{filteredAndGroupedModels.map((section) => (
|
||||
<View key={section.title}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
{section.data.map((item) => (
|
||||
<ModelCard key={item.id} model={item} onToggle={handleToggleModel} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModelsSection;
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
// 操作按钮容器
|
||||
actionsContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
// 底部行(模型ID标签)
|
||||
bottomRow: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
|
||||
// 卡片
|
||||
card: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderBottomColor: token.colorBorderSecondary,
|
||||
borderBottomWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
|
||||
// 卡片内容
|
||||
cardContent: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// 主容器
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// 空状态容器
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
|
||||
// 空状态文字
|
||||
emptyText: {
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// 获取按钮
|
||||
fetchButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorPrimary,
|
||||
borderRadius: 6,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
|
||||
// 获取按钮禁用状态
|
||||
fetchButtonDisabled: {
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
},
|
||||
|
||||
// 获取按钮文字
|
||||
fetchButtonText: {
|
||||
color: token.colorWhite,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
// 获取按钮文字禁用状态
|
||||
fetchButtonTextDisabled: {
|
||||
color: token.colorTextTertiary,
|
||||
},
|
||||
|
||||
// 头部
|
||||
header: {
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// Loading容器 - 固定宽度避免布局跳跃
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 20,
|
||||
},
|
||||
|
||||
// Loading容器(页面级)
|
||||
loadingContainerPage: {
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
|
||||
// Loading文字
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// 模型数量
|
||||
modelCount: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
},
|
||||
|
||||
// 模型ID标签
|
||||
modelIdTag: {
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
},
|
||||
|
||||
// 模型ID文字
|
||||
modelIdText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 模型信息
|
||||
modelInfo: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
|
||||
// 模型元信息
|
||||
modelMeta: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
|
||||
// 模型名称
|
||||
modelName: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 搜索容器
|
||||
searchContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
|
||||
// 搜索输入框
|
||||
searchInput: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
|
||||
// 分组头部
|
||||
sectionHeader: {
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
|
||||
// 分组头部文字
|
||||
sectionHeaderText: {
|
||||
color: token.colorText,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 章节标题
|
||||
sectionTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
// Switch容器
|
||||
switchContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// 标题容器
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 标题行
|
||||
titleRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
// 顶部行(显示名称 + 能力图标)
|
||||
topRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
}));
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { useThemeToken } from '@/theme';
|
||||
import { AiProviderDetailItem } from '@/types/aiProvider';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiProviderSelectors } from '@/store/aiInfra/selectors';
|
||||
import { InstantSwitch } from '@/components';
|
||||
|
||||
import { useStyles } from './style';
|
||||
import { ProviderCombine } from '@lobehub/icons-rn';
|
||||
|
||||
interface ProviderInfoSectionProps {
|
||||
provider: AiProviderDetailItem;
|
||||
}
|
||||
|
||||
const ProviderInfoSection = memo<ProviderInfoSectionProps>(({ provider }) => {
|
||||
const { styles } = useStyles();
|
||||
const token = useThemeToken();
|
||||
|
||||
// Store hooks
|
||||
const { toggleProviderEnabled } = useAiInfraStore();
|
||||
const isEnabled = aiProviderSelectors.isProviderEnabled(provider.id)(useAiInfraStore.getState());
|
||||
|
||||
const handleSwitchChange = async (value: boolean) => {
|
||||
try {
|
||||
await toggleProviderEnabled(provider.id, value);
|
||||
console.log(
|
||||
`Successfully toggled provider ${provider.id} to ${value ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle provider ${provider.id}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<ProviderCombine
|
||||
color={token.colorText}
|
||||
iconProps={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
provider={provider.id}
|
||||
size={24}
|
||||
/>
|
||||
<Text style={styles.subtitle}>
|
||||
{provider.source === 'builtin' ? 'Built-in Provider' : 'Custom Provider'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* InstantSwitch control area */}
|
||||
<View style={styles.switchContainer}>
|
||||
<InstantSwitch
|
||||
enabled={isEnabled}
|
||||
onChange={handleSwitchChange}
|
||||
thumbColor={token.colorBgContainer}
|
||||
trackColor={{
|
||||
false: '#e9e9eb',
|
||||
true: '#34c759',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{provider.description && <Text style={styles.description}>{provider.description}</Text>}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProviderInfoSection;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
// 主容器
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// 描述文字
|
||||
description: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 8,
|
||||
},
|
||||
|
||||
// 头部区域
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
// Loading容器 - 固定宽度避免布局跳跃
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 24,
|
||||
},
|
||||
|
||||
// Logo容器
|
||||
logoContainer: {
|
||||
alignItems: 'flex-start',
|
||||
marginRight: 12,
|
||||
},
|
||||
|
||||
// 副标题
|
||||
subtitle: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Switch容器
|
||||
switchContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// 主标题
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
// 标题容器
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useLocalSearchParams, useNavigation } from 'expo-router';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, ScrollView, SafeAreaView } from 'react-native';
|
||||
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiProviderSelectors } from '@/store/aiInfra/selectors';
|
||||
|
||||
import ProviderInfoSection from './(components)/ProviderInfoSection';
|
||||
import ConfigurationSection from './(components)/ConfigurationSection';
|
||||
import ModelsSection from './(components)/ModelsSection';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const ProviderDetailPage = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const navigation = useNavigation();
|
||||
const { styles } = useStyles();
|
||||
|
||||
// 获取provider详细信息
|
||||
const { useFetchAiProviderItem } = useAiInfraStore();
|
||||
const { data: providerDetail, isLoading, error } = useFetchAiProviderItem(id!);
|
||||
// 获取provider配置状态
|
||||
const isConfigLoading = aiProviderSelectors.isAiProviderConfigLoading(id!)(
|
||||
useAiInfraStore.getState(),
|
||||
);
|
||||
|
||||
// 动态设置页面标题
|
||||
useEffect(() => {
|
||||
if (providerDetail?.name) {
|
||||
navigation.setOptions({
|
||||
headerTitle: providerDetail.name,
|
||||
title: providerDetail.name,
|
||||
});
|
||||
} else if (id) {
|
||||
navigation.setOptions({
|
||||
headerTitle: id,
|
||||
title: id,
|
||||
});
|
||||
}
|
||||
}, [navigation, providerDetail?.name, id]);
|
||||
|
||||
if (isLoading || isConfigLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Loading provider configuration...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.errorText}>Failed to load provider configuration</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Provider基本信息 */}
|
||||
<ProviderInfoSection provider={providerDetail!} />
|
||||
|
||||
{/* 配置区域 */}
|
||||
<ConfigurationSection provider={providerDetail!} />
|
||||
|
||||
{/* Model列表区域 */}
|
||||
<ModelsSection providerId={id!} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderDetailPage;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
errorText: {
|
||||
color: token.colorError,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
loadingText: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
scrollContainer: {
|
||||
gap: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SectionList, View, ActivityIndicator, Text } from 'react-native';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { aiProviderSelectors } from '@/store/aiInfra/selectors';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
import ProviderCard from './(components)/ProviderCard';
|
||||
|
||||
const ProviderList = () => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
// 获取store数据和方法
|
||||
const { useFetchAiProviderList } = useAiInfraStore();
|
||||
|
||||
// 使用SWR获取provider列表
|
||||
const { isLoading, error } = useFetchAiProviderList();
|
||||
|
||||
// 使用store selectors来响应状态变化
|
||||
const enabledProviders = useAiInfraStore(aiProviderSelectors.enabledAiProviderList, isEqual);
|
||||
const disabledProviders = useAiInfraStore(aiProviderSelectors.disabledAiProviderList, isEqual);
|
||||
|
||||
// Loading状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.label}>Loading providers...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Error状态
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.container, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<Text style={styles.label}>Failed to load providers</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 准备SectionList数据
|
||||
const sections = [
|
||||
{
|
||||
count: enabledProviders.length,
|
||||
data: enabledProviders,
|
||||
title: 'Enabled',
|
||||
},
|
||||
{
|
||||
count: disabledProviders.length,
|
||||
data: disabledProviders,
|
||||
title: 'Disabled',
|
||||
},
|
||||
].filter((section) => section.data.length > 0); // 只显示有数据的section
|
||||
|
||||
// 渲染section header
|
||||
const renderSectionHeader = ({ section }: { section: { count: number; title: string } }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{section.title} ({section.count})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SectionList
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
SectionSeparatorComponent={() => <View style={styles.sectionSeparator} />}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <ProviderCard provider={item} />}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
sections={sections}
|
||||
stickySectionHeadersEnabled={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderList;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Eye, EyeOff } from 'lucide-react-native';
|
||||
import OpenAI from 'openai';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { useOpenAIStore } from '@/store/openai';
|
||||
import { DEFAULT_MODEL } from '@/const/settings';
|
||||
|
||||
import { useStyles } from './styles';
|
||||
|
||||
const OpenAISettings = () => {
|
||||
const { t } = useTranslation(['setting', 'common', 'chat']);
|
||||
const { apiKey, proxy, setApiKey, setProxy } = useOpenAIStore();
|
||||
const { token, styles } = useStyles();
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const validateSettings = async () => {
|
||||
if (!apiKey) {
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('openaiSettings.pleaseEnterApiKey', { ns: 'setting' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const openai = new OpenAI({
|
||||
apiKey: apiKey,
|
||||
baseURL: proxy || 'https://api.openai.com',
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
|
||||
await openai.chat.completions.create({
|
||||
max_tokens: 1000,
|
||||
messages: [{ content: '你好', role: 'user' }],
|
||||
model: DEFAULT_MODEL,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
Alert.alert(
|
||||
t('success', { ns: 'common' }),
|
||||
t('openaiSettings.connectionSuccess', { ns: 'setting' }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('OpenAI API Error:', error);
|
||||
|
||||
if (error.message?.includes('API key')) {
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('openaiSettings.checkApiKey', { ns: 'setting' }),
|
||||
);
|
||||
} else if (error.message?.includes('network')) {
|
||||
Alert.alert(
|
||||
t('error', { ns: 'common' }),
|
||||
t('openaiSettings.checkProxyAddress', { ns: 'setting' }),
|
||||
);
|
||||
} else {
|
||||
Alert.alert(t('error', { ns: 'common' }), t('regenerateFailed', { ns: 'chat' }));
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: token.colorBgContainer }]}>
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.label, { color: token.colorText }]}>
|
||||
{t('openaiSettings.apiKey', { ns: 'setting' })}
|
||||
</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
onChangeText={setApiKey}
|
||||
placeholder={t('openaiSettings.apiKeyPlaceholder', { ns: 'setting' })}
|
||||
placeholderTextColor={token.colorTextPlaceholder}
|
||||
secureTextEntry={!showApiKey}
|
||||
style={[
|
||||
styles.input,
|
||||
styles.inputWithIcon,
|
||||
{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
color: token.colorText,
|
||||
},
|
||||
]}
|
||||
value={apiKey}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowApiKey(!showApiKey)} style={styles.eyeButton}>
|
||||
{showApiKey ? (
|
||||
<EyeOff color={token.colorTextSecondary} size={20} />
|
||||
) : (
|
||||
<Eye color={token.colorTextSecondary} size={20} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, { color: token.colorText }]}>
|
||||
{t('openaiSettings.proxyAddress', { ns: 'setting' })}
|
||||
</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
onChangeText={setProxy}
|
||||
placeholder={t('openaiSettings.proxyPlaceholder', { ns: 'setting' })}
|
||||
placeholderTextColor={token.colorTextPlaceholder}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
color: token.colorText,
|
||||
},
|
||||
]}
|
||||
value={proxy}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={isValidating}
|
||||
onPress={validateSettings}
|
||||
size="large"
|
||||
style={styles.validateButton}
|
||||
type="primary"
|
||||
>
|
||||
{t('openaiSettings.testConnectivity', { ns: 'setting' })}
|
||||
</Button>
|
||||
|
||||
<Text style={[styles.hint, { color: token.colorTextSecondary }]}>
|
||||
{t('openaiSettings.connectivityHint', { ns: 'setting' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAISettings;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
backButton: {
|
||||
marginRight: 8,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
paddingBottom: 16, // 底部留白
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
eyeButton: {
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 0,
|
||||
width: 36,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
color: token.colorText,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginRight: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
hint: {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
height: 44,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
inputWithIcon: {
|
||||
paddingRight: 44,
|
||||
},
|
||||
label: {
|
||||
color: token.colorText,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
// Section样式 - 对标web端
|
||||
sectionHeader: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
sectionSeparator: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
height: 16, // 增加section间距
|
||||
},
|
||||
sectionTitle: {
|
||||
color: token.colorText,
|
||||
fontSize: 18, // 对标web端字体大小
|
||||
fontWeight: '600',
|
||||
},
|
||||
// 卡片间分隔 - 对标web端Grid gap
|
||||
separator: {
|
||||
backgroundColor: 'transparent',
|
||||
height: 16, // 对标web端16px gap
|
||||
},
|
||||
validateButton: {
|
||||
marginTop: 16,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createStyles } from '@/theme';
|
||||
import { FONT_SIZE_LARGE, FONT_SIZE_SMALL, FONT_SIZE_STANDARD } from '@/const/common';
|
||||
|
||||
export const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flex: 1,
|
||||
padding: token.paddingContentHorizontal,
|
||||
},
|
||||
fontSizeLarge: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_LARGE,
|
||||
},
|
||||
fontSizeSmall: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
},
|
||||
fontSizeStandard: {
|
||||
color: token.colorText,
|
||||
fontSize: FONT_SIZE_STANDARD,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createStyles } from '@/theme';
|
||||
|
||||
const useStyles = createStyles((token) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: token.paddingLG,
|
||||
},
|
||||
link: {
|
||||
marginTop: token.marginLG,
|
||||
paddingVertical: token.paddingLG,
|
||||
},
|
||||
linkText: {
|
||||
color: token.colorPrimary,
|
||||
fontSize: token.fontSizeLG,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
title: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeHeading3,
|
||||
fontWeight: token.fontWeightStrong,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['error', 'common']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t('page.notFoundTitle', { ns: 'error' }) }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t('page.notFoundMessage', { ns: 'error' })}</Text>
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>{t('navigation.goToHomeScreen', { ns: 'common' })}</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
||||
import { PortalProvider } from '@gorhom/portal';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import React, { useEffect, useRef, useState, PropsWithChildren } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
|
||||
import { ToastProvider } from '@/components';
|
||||
import '@/i18n';
|
||||
import { useAuth, useUserStore } from '@/store/user';
|
||||
import { ThemeProvider } from '@/theme';
|
||||
import { authLogger } from '@/utils/logger';
|
||||
import { tokenRefreshManager } from '@/services/_auth/tokenRefresh';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { trpcClient, TRPCProvider } from '@/services/_auth/trpc';
|
||||
|
||||
import '../polyfills';
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
function AuthProvider({ children }: PropsWithChildren) {
|
||||
const { isAuthenticated, isInitialized, error } = useAuth();
|
||||
const router = useRouter();
|
||||
const hasRedirectedRef = useRef(false);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
|
||||
// 初始化认证状态和令牌刷新管理器
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
// 避免重复初始化
|
||||
if (isInitialized || isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
authLogger.info('Initializing auth in RootLayout');
|
||||
setIsInitializing(true);
|
||||
|
||||
try {
|
||||
// 启动令牌刷新管理器
|
||||
tokenRefreshManager.start();
|
||||
|
||||
// 初始化用户认证状态
|
||||
await useUserStore.getState().initialize();
|
||||
authLogger.info('Auth initialization completed');
|
||||
} catch (error) {
|
||||
authLogger.error('Failed to initialize auth in RootLayout', error);
|
||||
console.error('Failed to initialize auth in RootLayout:', error);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
// Hide splash screen after auth initialization
|
||||
await SplashScreen.hideAsync();
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
tokenRefreshManager.stop();
|
||||
};
|
||||
}, [isInitialized, isInitializing]);
|
||||
|
||||
// 只在真正需要重新认证时才重定向
|
||||
useEffect(() => {
|
||||
if (!isInitialized || isInitializing || hasRedirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRedirectToLogin = async (): Promise<boolean> => {
|
||||
// 如果已认证,不需要重定向
|
||||
if (isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否是因为 refresh token 过期导致的未认证
|
||||
if (error?.includes('refresh_token') || error?.includes('Refresh token expired')) {
|
||||
authLogger.info('Refresh token expired, need to redirect to login');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果没有任何错误且未认证,检查是否有有效token
|
||||
if (!error) {
|
||||
const { TokenStorage } = await import('@/services/_auth/tokenStorage');
|
||||
const hasToken = await TokenStorage.hasValidToken();
|
||||
if (!hasToken) {
|
||||
authLogger.info('No valid token found, need to redirect to login');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleAuthCheck = async () => {
|
||||
try {
|
||||
const needsLogin = await shouldRedirectToLogin();
|
||||
if (needsLogin) {
|
||||
authLogger.info('Auth state requires login, redirecting');
|
||||
hasRedirectedRef.current = true;
|
||||
// 使用 setTimeout 确保在 Root Layout 挂载完成后进行导航
|
||||
setTimeout(() => {
|
||||
router.replace('/login');
|
||||
}, 0);
|
||||
}
|
||||
} catch (checkError) {
|
||||
authLogger.error('Error checking auth state:', checkError);
|
||||
}
|
||||
};
|
||||
|
||||
handleAuthCheck();
|
||||
|
||||
// 重置重定向标志当用户重新认证时
|
||||
if (isAuthenticated && hasRedirectedRef.current) {
|
||||
authLogger.info('User authenticated, resetting redirect flag');
|
||||
hasRedirectedRef.current = false;
|
||||
}
|
||||
}, [isInitialized, isInitializing, isAuthenticated, error, router]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
const QueryProvider = ({ children }: PropsWithChildren) => {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
|
||||
{children}
|
||||
</TRPCProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<ActionSheetProvider>
|
||||
<PortalProvider>
|
||||
<ToastProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 指定首页, 防止 expo 路由错乱 */}
|
||||
<Stack.Screen name="index" options={{ animation: 'none' }} />
|
||||
{/* main page should not have animation */}
|
||||
<Stack.Screen name="(main)/chat" options={{ animation: 'none' }} />
|
||||
{/* auth page should not have animation */}
|
||||
<Stack.Screen name="(auth)" options={{ animation: 'none' }} />
|
||||
</Stack>
|
||||
</ToastProvider>
|
||||
</PortalProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user