Compare commits

...

52 Commits

Author SHA1 Message Date
rdmclin2 894caf77ce fix: login expired handle 2025-08-28 16:38:28 +08:00
rdmclin2 b06c210adf feat: add developer setting 2025-08-28 14:52:42 +08:00
rdmclin2 b8d52ffe5b feat: handle auth expired redirect 2025-08-28 14:30:38 +08:00
rdmclin2 943cebec3f fix: auth expired handle 2025-08-26 17:33:22 +08:00
rdmclin2 e0d164588a fix: theme-token type error 2025-08-26 16:06:15 +08:00
rdmclin2 f0f483a310 fix: slider demo 2025-08-25 14:42:16 +08:00
rdmclin2 173bde4872 chore: remove unnecessary code 2025-08-24 21:45:32 +08:00
rdmclin2 ba7b0c5a36 chore: optimize theme provider tokens 2025-08-24 21:15:40 +08:00
rdmclin2 4a0958d130 chore: update i18n keys 2025-08-24 20:46:33 +08:00
rdmclin2 f61190bfa7 chore: prevent slider change 2025-08-24 20:30:38 +08:00
rdmclin2 18cb9ec2f9 fix: Slider 样式 2025-08-24 20:18:58 +08:00
rdmclin2 2112597c9f chore: Playground support fontSize setting 2025-08-24 19:37:57 +08:00
rdmclin2 3383f1470d feat: setting support fontSize 2025-08-24 19:18:55 +08:00
rdmclin2 2a4250fc15 chore: optimize Slider token usage 2025-08-24 18:54:34 +08:00
rdmclin2 76a9ba70ae fix: 修复 mark dot 位置 2025-08-24 18:03:07 +08:00
rdmclin2 08c31f129a feat: support marks props 2025-08-24 17:21:34 +08:00
rdmclin2 13ad481cd8 fix: accurately control step slider 2025-08-24 17:04:07 +08:00
rdmclin2 cdd005d3c8 chore: remove unnecessary props for slider 2025-08-24 17:01:34 +08:00
rdmclin2 c974500011 chore: unified api with web version 2025-08-24 16:41:13 +08:00
rdmclin2 15d2c323c8 chore: optimize Markdown Render and add Slider Component 2025-08-24 16:36:06 +08:00
rdmclin2 06aebb3d25 chore: remove unnecessary custtom renderers 2025-08-24 15:03:26 +08:00
rdmclin2 7af217cba7 chore: optimize Toast implementation 2025-08-22 13:04:51 +08:00
rdmclin2 28e84a49b6 feat: animate loading message and simplify Toast props 2025-08-22 13:04:51 +08:00
rdmclin2 3f4b3aadba feat: Toast 支持静态方法 2025-08-22 13:04:51 +08:00
rdmclin2 c5068a781f feat: add Remark components 2025-08-22 13:04:51 +08:00
rdmclin2 3b6e41561e feat: migrate to remark 2025-08-22 13:04:29 +08:00
rdmclin2 76e482ea42 chore: 优化 playground Card style 2025-08-22 13:03:49 +08:00
rdmclin2 e23c171a6d feat: Tag Component support preset color 2025-08-22 13:03:49 +08:00
rdmclin2 431e5467ad feat: Tag support color prop 2025-08-22 13:03:49 +08:00
rdmclin2 1cc58b2dad chore: modify Markdown styles and Tag style 2025-08-22 13:03:48 +08:00
Tsuki 48f4e2e9b7 fix(mobile): provider icon color when change theme 2025-08-21 12:39:24 +08:00
rdmclin2 ce43aa4907 feat: ColorSwatch support Conic Gradient 2025-08-20 21:50:14 +08:00
rdmclin2 b841e25e01 fix: color token 2025-08-20 20:50:48 +08:00
rdmclin2 9045fb9fa5 Merge branch 'feat/mobile-app' of github.com:lobehub/lobe-chat into feat/mobile-app 2025-08-20 20:24:34 +08:00
Rdmclin2 c2cd755e63 Fix/token values (#8862)
* fix: detail style token and lineHeight Problem

* fix: optimize market tokens

* fix:  style token

* chore: tokenize setting style

* feat: add Button danger prop and use in setting SignOut

* chore: add static token value

* chore: optimze theme-token style

* fix: token colorWhite lost

* chore: migrage components style

* fix: Tooltip style

* fix: Tooltip ref problem

* fix: neutral colors

* fix: ListItem token
2025-08-20 17:56:11 +08:00
rdmclin2 434ce366ee fix: detail style token and lineHeight Problem 2025-08-19 21:06:55 +08:00
Tsuki 434795976f chore(mobile): update chat input 2025-08-19 17:19:01 +08:00
Rdmclin2 ec016ca003 Feat/mobile workflow (#8762)
* chore: add ipa ignore

* chore: update expo realted package version

* fix: production build command

* feat: add ios submit workflow

* chore: add android submit process

* fix: vitest and jest test cases

* test: fix all components test cases

* fix: test cases and lint errors

* chore: update jest config json and fix testcases

* fix: login animation

* chore: add test cases for utils

* feat: refactor theme page

* feat: move theme page to settings page

* feat: add title prop for ListGroup

* feat: add theme token playground

* fix: login asset image and setting style

* chore: remove localhost:3020

* feat: support theme token pass and add demos for playground

* fix: theme token logic

* feat:support colorPrimary and fontSize

* feat: add ColorSwatches and local ThemeProvider

* fix: color token display

* chore: move colors to theme dir

* feat: add  ColorScale  components to  show color system

* chore: update colorscales

* feat: optimize design token generate

* fix: lint error

* feat: systemly orgnize lobe ui tokens

* fix: fix playground components style

* feat: 添加 primaryColor 配置

* fix: theme token  implementation

* fix: 支持中性色配置

* feat: primaryColor and netualColor config

* fix: text icon color

* feat: optimize theme-token components

* chore: extract components rom theme-token

* chore: optimize token display

* chore: optimize theme token style

* fix: _auth redirect logic

* fix: 避免 <Stack.Screen > 和 <Slot> 混用,指定 index 首页

* fix: auth redirect on hotload

* chore: migrate @/theme import
2025-08-19 17:19:01 +08:00
Tsuki eb3085fa57 feat: connect session & chat & message & topic feature (#8755)
* chore: update mobile server routers

* chore: move original theme types

* chore(mobile): remove types files

* fix(mobile): type errors

* chore(mobile): remove useless model providers

* chore(mobile): session migrate

* chore(mobile): migrate const

* chore(mobile): add useFetchMessage hook

* chore(mobile): support package types

* chore(mobile): add utils fn

* chore(mobile): migrate user

* feat(mobile): add chat message slice

* fix(mobile): agent mobile server

* chore(mobile): update const

* chore(mobile): update fetch utils

* chore(mobile): update aiChat slice

* chore(mobile): update agent store

* chore(mobile): update tokenizer utils

* chore(mobile): update utils client

* chore(mobile): update parse.ts

* chore(mobile): update utils

* chore(mobile): update chains

* chore(mobile): update prompts chatMessages

* chore(mobile): update ChatList index loading issue

* chore(mobile): update chat header.ts

* chore(mobile): update chat relative components

* chore(mobile): update aiInfra store

* chore(mobile): update chat store

* chore(mobile): update utils

* chore(mobile): update store middleware createDevtools.ts

* chore(mobile): update global store

* chore(mobile): update services

* chore(mobile): update libs model-runtime

* chore(mobile): update i18n default

* chore(mobile): update hooks

* chore(mobile): update features

* chore(mobile): update settings const

* chore(mobile): update auth config

* chore(mobile): update ListItem component

* chore(mobile): update i18n zh-CN chat.json
2025-08-19 17:19:00 +08:00
Tsuki 9bee6ff9f3 feat: add provider list & model switch (#8662) 2025-08-19 17:19:00 +08:00
Rdmclin2 0b568c1e24 feat: sidebar implementation (#8658)
* chore: migrate sidebar to features

* chore: migrate SIdeBar

* fix: add pnpm lockfile support

* chore: update .npmrc

* chore: optimize Session List

* fix:  stoploading animation

* chore: use mobile trpc

* fix: optimize consent page for mobile

* chore: optimize Consent page for mobile
2025-08-19 17:19:00 +08:00
rdmclin2 aea3475e9f feat: migrate mobile trpc api 2025-08-19 17:19:00 +08:00
rdmclin2 0d8ef517e0 fix: trpc client problem 2025-08-19 17:19:00 +08:00
rdmclin2 0acb40c97f feat: add trpc services 2025-08-19 17:19:00 +08:00
rdmclin2 2a4e644c83 fix: eas preview build env 2025-08-19 17:19:00 +08:00
rdmclin2 a0ade39dde chore: update locale config 2025-08-19 17:19:00 +08:00
rdmclin2 4c31ccd5fb chore: refactor src dir 2025-08-19 17:19:00 +08:00
rdmclin2 394845f9eb fix: build error 2025-08-19 17:18:59 +08:00
rdmclin2 8540f11b41 feat: add mobile app 2025-08-19 17:18:59 +08:00
rdmclin2 3ed29e2f83 fix: mobile oidc process 2025-08-19 17:18:59 +08:00
rdmclin2 0aab229825 feat: add oidc claims 2025-08-19 17:18:59 +08:00
rdmclin2 540f9f3f1d feat: add oidc config for mobile 2025-08-19 17:18:59 +08:00
816 changed files with 80190 additions and 19 deletions
+7
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -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 }}
+19
View File
@@ -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 }}
+12
View File
@@ -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
+35
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: '../../.eslintrc.js',
};
+55
View File
@@ -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
+49
View File
@@ -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`;
}
},
},
});
+3
View File
@@ -0,0 +1,3 @@
lockfile=true
shamefully-hoist=true
ignore-workspace-root-check=true
+125
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
printWidth: 100,
proseWrap: 'never',
quoteProps: 'consistent',
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
};
+15
View File
@@ -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.
+202
View File
@@ -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)
[![GitHub release](https://img.shields.io/github/v/release/lobehub/lobe-chat-react-native?color=369eff&labelColor=black&logo=github&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/releases) [![Expo SDK](https://img.shields.io/badge/Expo-52.0.5-000020?labelColor=black&logo=expo&style=flat-square)](https://expo.dev) [![React Native](https://img.shields.io/badge/React%20Native-0.76.6-61dafb?labelColor=black&logo=react&style=flat-square)](https://reactnative.dev) [![TypeScript](https://img.shields.io/badge/TypeScript-5.8.2-3178c6?labelColor=black&logo=typescript&style=flat-square)](https://www.typescriptlang.org)
[![GitHub stars](https://img.shields.io/github/stars/lobehub/lobe-chat-react-native?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/stargazers) [![GitHub forks](https://img.shields.io/github/forks/lobehub/lobe-chat-react-native?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/network/members) [![GitHub issues](https://img.shields.io/github/issues/lobehub/lobe-chat-react-native?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/issues) [![GitHub license](https://img.shields.io/badge/license-apache%202.0-white?labelColor=black&style=flat-square)](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.
![](https://img.shields.io/badge/-POWERED%20BY%20LOBEHUB-151515?labelColor=black&logo=github&style=flat-square)
## 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.
+203
View File
@@ -0,0 +1,203 @@
# LobeChat React Native
现代化设计的开源 AI 聊天应用,基于 React Native 构建\
一键**免费**拥有你自己的跨平台 ChatGPT/Claude/Gemini 应用
**简体中文** · [English](./README.md)
[![GitHub release](https://img.shields.io/github/v/release/lobehub/lobe-chat-react-native?color=369eff&labelColor=black&logo=github&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/releases) [![Expo SDK](https://img.shields.io/badge/Expo-52.0.5-000020?labelColor=black&logo=expo&style=flat-square)](https://expo.dev) [![React Native](https://img.shields.io/badge/React%20Native-0.76.6-61dafb?labelColor=black&logo=react&style=flat-square)](https://reactnative.dev) [![TypeScript](https://img.shields.io/badge/TypeScript-5.8.2-3178c6?labelColor=black&logo=typescript&style=flat-square)](https://www.typescriptlang.org)
[![GitHub stars](https://img.shields.io/github/stars/lobehub/lobe-chat-react-native?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/stargazers) [![GitHub forks](https://img.shields.io/github/forks/lobehub/lobe-chat-react-native?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/network/members) [![GitHub issues](https://img.shields.io/github/issues/lobehub/lobe-chat-react-native?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/issues) [![GitHub license](https://img.shields.io/badge/license-apache%202.0-white?labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
探索移动端 AI 对话的无限可能,在个体崛起的时代中为你打造
![](https://img.shields.io/badge/-POWERED%20BY%20LOBEHUB-151515?labelColor=black&logo=github&style=flat-square)
## 目录
- [✨ 特性一览](#-特性一览)
- [📱 支持平台](#-支持平台)
- [🚀 快速开始](#-快速开始)
- [🛠️ 技术栈](#-技术栈)
- [📦 项目结构](#-项目结构)
- [⌨️ 本地开发](#-本地开发)
- [🤝 参与贡献](#-参与贡献)
- [🔗 更多工具](#-更多工具)
## ✨ 特性一览
### `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) 协议开源.
+77
View File
@@ -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"
}
}
}
+17
View File
@@ -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;
+72
View File
@@ -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;
+72
View File
@@ -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,
},
}));
+26
View File
@@ -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,
});
@@ -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;
@@ -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,
},
}));
@@ -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;
@@ -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,
},
}));
@@ -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;
@@ -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,
},
}));
@@ -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;
@@ -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,
},
}));
+19
View File
@@ -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>
);
}
+67
View File
@@ -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',
},
}));
+19
View File
@@ -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();
};
+239
View File
@@ -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;
@@ -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;
@@ -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,
},
}));
@@ -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;
@@ -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,
},
}));
+22
View File
@@ -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,
},
}));
+47
View File
@@ -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>
</>
);
}
+161
View File
@@ -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