Compare commits

..

11 Commits

Author SHA1 Message Date
arvinxx 1eaf28e498 clean deps 2025-09-25 22:11:01 +02:00
arvinxx 5bfb876340 improve code 2025-09-25 22:10:45 +02:00
Arvin Xu 273e0277d1 🔨 chore: pre-merge group chat relative implement (#9432)
* pre-merge code

* fix tests

* fix circular

* remove redirectUri

* fix types

* improve sql

* fix docs

* fix lint

* update model runtime
2025-09-26 03:47:25 +08:00
lobehubbot 4fb18ac6a8 📝 docs(bot): Auto sync agents & plugin to readme 2025-09-25 19:45:50 +00:00
semantic-release-bot 74c8ef2686 🔖 chore(release): v1.132.15 [skip ci]
### [Version 1.132.15](https://github.com/lobehub/lobe-chat/compare/v1.132.14...v1.132.15)
<sup>Released on **2025-09-25**</sup>

#### 🐛 Bug Fixes

- **misc**: Add proxyUrl configuration for NEW API provider.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Add proxyUrl configuration for NEW API provider, closes [#9426](https://github.com/lobehub/lobe-chat/issues/9426) [#9420](https://github.com/lobehub/lobe-chat/issues/9420) ([e35e378](https://github.com/lobehub/lobe-chat/commit/e35e378))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-09-25 19:44:50 +00:00
Maple Gao e35e3787c0 🐛 fix: add proxyUrl configuration for NEW API provider (#9426)
This fixes the missing API URL configuration field in the NEW API provider settings UI.

The showEndpoint logic requires either proxyUrl or isCustom to be true for the
configuration field to be displayed. Since NEW API is a built-in provider
(not custom), it needs the proxyUrl field defined in its configuration.

This enables users to configure the API endpoint through the UI as originally
intended, while maintaining compatibility with existing NEWAPI_PROXY_URL
environment variable configuration.

Closes #9420
2025-09-26 03:34:18 +08:00
lobehubbot c0e5a1d6e3 📝 docs(bot): Auto sync agents & plugin to readme 2025-09-25 19:25:45 +00:00
semantic-release-bot abba6a9eff 🔖 chore(release): v1.132.14 [skip ci]
### [Version&nbsp;1.132.14](https://github.com/lobehub/lobe-chat/compare/v1.132.13...v1.132.14)
<sup>Released on **2025-09-25**</sup>

#### 💄 Styles

- **misc**: Update i18n.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Update i18n, closes [#9413](https://github.com/lobehub/lobe-chat/issues/9413) ([4ea45b1](https://github.com/lobehub/lobe-chat/commit/4ea45b1))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-09-25 19:24:40 +00:00
LobeHub Bot 4ea45b18d0 🤖 style: update i18n (#9413)
💄 style: update i18n

Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-09-26 03:14:36 +08:00
Guanyu Zhang a21e71833b 📝 docs: add missing environment variables in online-search docs (#9424)
docs: add missing environment variables in online-search docs (en & zh-CN)

Co-authored-by: evan <evancodeforfun@gmail.com>
2025-09-26 03:12:02 +08:00
peerless-hero dd9bbdc362 🔨 chore(config): add assetPrefix to nextConfig for environment variable support (#9427)
*  feat(config): add assetPrefix to nextConfig for environment variable support

*  feat(docs): add NEXT_PUBLIC_ASSET_PREFIX environment variable for CDN support
2025-09-26 02:57:18 +08:00
1263 changed files with 8069 additions and 138791 deletions
+35
View File
@@ -0,0 +1,35 @@
---
description: Explain how group chat works in LobeHub (Multi-agent orchestratoin)
globs:
alwaysApply: false
---
This rule explains how group chat (multi-agent orchestration) works. Not confused with session group, which is a organization method to manage session.
## Key points
- A supervisor will devide who and how will speak next
- Each agent will speak just like in single chat (if was asked to speak)
- Not coufused with session group
## Related Files
- src/store/chat/slices/message/supervisor.ts
- src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts
- src/prompts/groupChat/index.ts (All prompts here)
## Snippets
```tsx
// Detect whether in group chat
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
// Member actions
const addAgentsToGroup = useChatGroupStore((s) => s.addAgentsToGroup);
const removeAgentFromGroup = useChatGroupStore((s) => s.removeAgentFromGroup);
const persistReorder = useChatGroupStore((s) => s.reorderGroupMembers);
// Get group info
const groupConfig = useChatGroupStore(chatGroupSelectors.currentGroupConfig);
const currentGroupMemebers = useSessionStore(sessionSelectors.currentGroupAgents);
```
-7
View File
@@ -1,7 +0,0 @@
# 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
@@ -117,12 +117,3 @@ CLAUDE.local.md
prd
GEMINI.md
# for local prd docs
docs/prd
# eas
service-account-key.json
repomix-output-lobehub-lobe-ui.xml
-1
View File
@@ -3,7 +3,6 @@ resolution-mode=highest
ignore-workspace-root-check=true
enable-pre-post-scripts=true
strict-store-pkg-content-check=false
# Load dotenv files for all the npm scripts
node-options="--require dotenv-expand/config"
+50
View File
@@ -2,6 +2,56 @@
# Changelog
### [Version 1.132.15](https://github.com/lobehub/lobe-chat/compare/v1.132.14...v1.132.15)
<sup>Released on **2025-09-25**</sup>
#### 🐛 Bug Fixes
- **misc**: Add proxyUrl configuration for NEW API provider.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Add proxyUrl configuration for NEW API provider, closes [#9426](https://github.com/lobehub/lobe-chat/issues/9426) [#9420](https://github.com/lobehub/lobe-chat/issues/9420) ([e35e378](https://github.com/lobehub/lobe-chat/commit/e35e378))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.132.14](https://github.com/lobehub/lobe-chat/compare/v1.132.13...v1.132.14)
<sup>Released on **2025-09-25**</sup>
#### 💄 Styles
- **misc**: Update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Update i18n, closes [#9413](https://github.com/lobehub/lobe-chat/issues/9413) ([4ea45b1](https://github.com/lobehub/lobe-chat/commit/4ea45b1))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.132.13](https://github.com/lobehub/lobe-chat/compare/v1.132.12...v1.132.13)
<sup>Released on **2025-09-25**</sup>
+1 -1
View File
@@ -819,7 +819,7 @@ Every bit counts and your one-time donation sparkles in our galaxy of support! Y
</details>
Copyright © 2025 [LobeHub][profile-link]. <br />
This project is [Apache 2.0](./LICENSE) licensed.
This project is [LobeHub Community License](./LICENSE) licensed.
<!-- LINK GROUP -->
+1 -1
View File
@@ -840,7 +840,7 @@ $ pnpm run dev
</details>
Copyright © 2025 [LobeHub][profile-link]. <br />
This project is [Apache 2.0](./LICENSE) licensed.
This project is [LobeHub Community License](./LICENSE) licensed.
<!-- LINK GROUP -->
@@ -1,21 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,68 +0,0 @@
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
@@ -1,12 +0,0 @@
name: Publish preview update
on:
push:
branches: ['*']
jobs:
publish_preview_update:
name: Publish preview update
type: update
params:
branch: ${{ github.ref_name || 'test' }}
@@ -1,19 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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 }}
-9
View File
@@ -1,9 +0,0 @@
# OAuth 2.0 OIDC 认证配置
# 客户端 ID - 从认证服务器获取
EXPO_PUBLIC_OAUTH_CLIENT_ID=lobehub-mobile
# 官方云服务器地址
EXPO_PUBLIC_OFFICIAL_CLOUD_SERVER=https://lobechat.com
# 回调地址 - 必须与认证服务器配置一致
EXPO_PUBLIC_OAUTH_REDIRECT_URI=com.lobehub.app://auth/callback
-36
View File
@@ -1,36 +0,0 @@
# 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
temp
-55
View File
@@ -1,55 +0,0 @@
# 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
-50
View File
@@ -1,50 +0,0 @@
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,
// chatgpt-4o-latest 和 gpt-chat 翻译效果更好
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`;
}
},
},
});
-22
View File
@@ -1,22 +0,0 @@
lockfile=true
node-linker=hoisted
enable-pre-post-scripts=true
# Load dotenv files for all the npm scripts
node-options="--require dotenv-expand/config"
public-hoist-pattern[]=*@umijs/lint*
public-hoist-pattern[]=*changelog*
public-hoist-pattern[]=*commitlint*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*remark*
public-hoist-pattern[]=*semantic-release*
public-hoist-pattern[]=*stylelint*
public-hoist-pattern[]=@auth/core
public-hoist-pattern[]=@clerk/backend
public-hoist-pattern[]=@clerk/types
public-hoist-pattern[]=pdfjs-dist
-67
View File
@@ -1,67 +0,0 @@
# Prettierignore for LobeHub
################################################################
# general
.DS_Store
.editorconfig
.idea
.vscode
.history
.temp
.env.local
.husky
.npmrc
.gitkeep
venv
temp
tmp
LICENSE
# dependencies
node_modules
*.log
*.lock
package-lock.json
# ci
coverage
.coverage
.eslintcache
.stylelintcache
test-output
__snapshots__
*.snap
# production
dist
es
lib
logs
# umi
.umi
.umi-production
.umi-test
.dumi/tmp*
# ignore files
.*ignore
# docker
docker
Dockerfile*
# image
*.webp
*.gif
*.png
*.jpg
# misc
# add other ignore file below
./src/icons/MaterialFileTypeIcon/icon-map.json
*.ico
*.backup
*.mdc
*.ttf
-1
View File
@@ -1 +0,0 @@
module.exports = require('@lobehub/lint').prettier;
-1
View File
@@ -1 +0,0 @@
module.exports = require('@lobehub/lint').remarklint;
-10
View File
@@ -1,10 +0,0 @@
const config = require('@lobehub/lint').stylelint;
module.exports = {
...config,
rules: {
'custom-property-pattern': null,
'no-descending-specificity': null,
...config.rules,
},
};
-54
View File
@@ -1,54 +0,0 @@
# LobeChat Mobile Agent Guide
## Project Overview
- React Native + Expo app delivering the LobeChat AI chat experience for iOS and Android from a shared codebase.
- Core entry points: `app/_layout.tsx` wires global providers, while `app/index.tsx` (and nested routes under `app/(main)`) drive navigation via Expo Router.
- State flows through colocated Zustand stores such as `store/chat`, `store/session`, and `store/openai`; selectors in `store/session/selectors` keep components efficient.
- Key platform features include Markdown + math rendering (React Native Markdown, Shiki, MathJax), streaming chat transport via `utils/fetchSSE`, and tRPC clients configured in `utils/trpc`.
- Follow the domain guides in `rules/` (e.g., `project-overview.mdc`, `state-management.mdc`, `api-integration.mdc`) for deeper architecture context before implementing changes.
## Build and Test Commands
| Task | Command | Notes |
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------- |
| Install dependencies | `pnpm install` | Requires Node 18+ and pnpm 8+. |
| Start Expo dev server | `pnpm start` | Opens the Metro bundler with QR code pairing. |
| Run on iOS simulator/device | `pnpm ios` / `pnpm device:ios` | Uses `expo run:ios`; ensure Xcode tooling is installed. |
| Run on Android emulator/device | `pnpm android` / `pnpm device:android` | Uses `expo run:android`; start an emulator first. |
| Web preview | `pnpm web` | Launches the web bundle for quick UI checks. |
| Jest unit tests | `pnpm test` | Runs through `jest-expo` with coverage enabled. |
| Lint TypeScript/JS | `pnpm lint` | Delegates to Expo + ESLint rules defined in repo. |
| Format sources | `pnpm prettier` | Applies Prettier across the workspace. |
| Generate translations | `pnpm i18n` | Executes scripted workflow from `rules/internationalization.mdc`. |
| Production builds | `pnpm production:ios` / `pnpm production:android` | Wraps EAS build profiles from `eas.json`. |
## Code Style Guidelines
- TypeScript runs in strict mode; define interfaces/types for component props and store state, avoiding `any`. Reference `rules/app-structure.mdc` and `rules/state-management.mdc` for patterns.
- File naming: Components in PascalCase, hooks in camelCase prefixed with `use`, utilities in camelCase, constants in UPPER_SNAKE_CASE (see `rules/development-workflow.mdc`).
- Use absolute imports with the `@/` alias; group imports by React, third-party, then internal modules.
- Keep UI logic declarative; colocate styles in `styles.ts` companions and respect theming conventions from `rules/color-system.mdc`.
- Run `pnpm lint` and `pnpm prettier` before committing. Git hooks enforce Conventional Commits and formatting, so align commit messages with `feat|fix|chore` etc.
## Testing Instructions
- Place tests under `test/` or alongside components as `*.test.ts(x)` per `rules/react-native.mdc` guidance.
- Use `@testing-library/react-native` for rendering and interaction assertions; rely on user-centered queries rather than implementation details.
- Mock async integrations (SecureStore, AsyncStorage, network services) using Jest mocks; reference `rules/debug-usage.mdc` for logging utilities during tests.
- Prefer explicit assertions over brittle snapshots; when snapshots are required, keep them under `__snapshots__` directories.
- Run `pnpm test --watch` during iteration and ensure `pnpm test` + `pnpm lint` pass before opening a PR.
## Security Considerations
- Keep provider keys in `.env.local` (copied from `.env.example`) and load them through the secure configuration flows in `store/openai`; never commit secrets.
- Persist sensitive tokens with `expo-secure-store`; avoid plain AsyncStorage for credentials as outlined in `rules/api-integration.mdc`.
- Validate API responses and sanitize Markdown before render—Shiki/remark plugins are already configured, but keep dependencies patched.
- Enforce HTTPS endpoints for remote calls; review EAS credentials and signing configs before production builds.
- Monitor dependency upgrades touching auth libraries (`expo-auth-session`, `jose`, `jwt-decode`) and align with guidance in `rules/development-workflow.mdc`.
## Additional References
- `rules/quick-reference.mdc` for command cheatsheets.
- `rules/internationalization.mdc` to extend locale coverage.
- `rules/debug-usage.mdc` for logging and diagnostics standards.
-15
View File
@@ -1,15 +0,0 @@
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
@@ -1,202 +0,0 @@
# 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.
-202
View File
@@ -1,202 +0,0 @@
# 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) 协议开源.
-154
View File
@@ -1,154 +0,0 @@
{
"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"
},
"appleTeamId": "4684H589ZU"
},
"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",
"./plugins/withFbjniFix",
[
"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",
{
"fonts": [
"./assets/fonts/Hack-Regular.ttf",
"./assets/fonts/Hack-Bold.ttf",
"./assets/fonts/Hack-Italic.ttf",
"./assets/fonts/Hack-BoldItalic.ttf",
"./assets/fonts/HarmonyOS_Sans_Regular.ttf",
"./assets/fonts/HarmonyOS_Sans_Medium.ttf",
"./assets/fonts/HarmonyOS_Sans_Bold.ttf",
"./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf",
"./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf",
"./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf"
],
"android": [
{
"fontFamily": "Hack",
"fontDefinitions": [
{
"path": "./assets/fonts/Hack-Regular.ttf",
"weight": 400
},
{
"path": "./assets/fonts/Hack-Bold.ttf",
"weight": 700
},
{
"path": "./assets/fonts/Hack-Italic.ttf",
"weight": 400,
"style": "italic"
},
{
"path": "./assets/fonts/Hack-BoldItalic.ttf",
"weight": 700,
"style": "italic"
}
]
},
{
"fontFamily": "HarmonyOS-Sans",
"fontDefinitions": [
{
"path": "./assets/fonts/HarmonyOS_Sans_Regular.ttf",
"weight": 400
},
{
"path": "./assets/fonts/HarmonyOS_Sans_Medium.ttf",
"weight": 500
},
{
"path": "./assets/fonts/HarmonyOS_Sans_Bold.ttf",
"weight": 700
}
]
},
{
"fontFamily": "HarmonyOS-Sans-SC",
"fontDefinitions": [
{
"path": "./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf",
"weight": 400
},
{
"path": "./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf",
"weight": 500
},
{
"path": "./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf",
"weight": 700
}
]
}
]
}
],
"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
@@ -1,17 +0,0 @@
import { Stack } from 'expo-router';
import { useThemedScreenOptions } from '@/_const/navigation';
export default function ChatRoutesLayout() {
const themedScreenOptions = useThemedScreenOptions();
return (
<Stack
screenOptions={{
...themedScreenOptions,
headerShown: false,
}}
>
<Stack.Screen name="setting/index" options={{ headerShown: false }} />
</Stack>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { ActionIcon, Avatar, PageContainer, Space } from '@lobehub/ui-rn';
import { useRouter } from 'expo-router';
import { AlignJustify, MoreHorizontal } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { AVATAR_SIZE } from '@/_const/common';
import Hydration from '@/features/Hydration';
import SideBar from '@/features/SideBar';
import TopicDrawer from '@/features/TopicDrawer';
import ChatInput from '@/features/chat/ChatInput';
import ChatList from '@/features/chat/ChatList';
import { useGlobalStore } from '@/store/global';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
import { useStyles } from './style';
export default function ChatWithDrawer() {
const { styles, theme } = useStyles();
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
const toggleDrawer = useGlobalStore((s) => s.toggleDrawer);
const { t } = useTranslation(['chat']);
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
const router = useRouter();
const displayTitle = isInbox ? t('inbox.title', { ns: 'chat' }) : title;
const renderContent = () => {
return (
<PageContainer
extra={<ActionIcon icon={MoreHorizontal} onPress={() => router.push('/chat/setting')} />}
left={<ActionIcon icon={AlignJustify} onPress={toggleDrawer} />}
title={
<Space>
<Avatar avatar={avatar} size={AVATAR_SIZE} />
<Text ellipsizeMode="tail" numberOfLines={1} style={styles.title}>
{displayTitle}
</Text>
</Space>
}
>
<KeyboardAvoidingView
behavior="padding"
enabled
keyboardVerticalOffset={theme.marginXS}
style={{ flex: 1 }}
>
<ChatList />
<ChatInput />
</KeyboardAvoidingView>
</PageContainer>
);
};
return (
<View style={styles.root}>
{/* Hydration组件:处理URL和Store的双向同步 */}
<Hydration />
<SideBar>
<TopicDrawer>{renderContent()}</TopicDrawer>
</SideBar>
</View>
);
}
@@ -1,38 +0,0 @@
import { Avatar, PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { AVATAR_SIZE_LARGE } from '@/_const/common';
import { AgentRoleEditSection } from '@/features/AgentRoleEdit/AgentRoleEditSection';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import { useStyles } from './styles';
export default function AgentDetail() {
const { t } = useTranslation(['chat']);
const avatar = useSessionStore(sessionMetaSelectors.currentAgentAvatar);
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
const description = useSessionStore(sessionMetaSelectors.currentAgentDescription);
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title={t('setting.title', { ns: 'chat' })}>
<KeyboardAwareScrollView
bottomOffset={40}
extraKeyboardSpace={60}
showsVerticalScrollIndicator={false}
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}
<AgentRoleEditSection />
</KeyboardAwareScrollView>
</PageContainer>
);
}
@@ -1,98 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
avatarContainer: {
alignItems: 'center',
marginBottom: token.marginSM,
marginTop: token.marginLG,
},
container: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
description: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
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,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
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.fontSizeHeading4,
fontWeight: token.fontWeightStrong,
marginBottom: token.marginXS,
marginTop: token.marginXXS,
textAlign: 'center',
},
}));
-15
View File
@@ -1,15 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
root: {
flex: 1,
},
title: {
color: token.colorText,
flexShrink: 1,
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
maxWidth: '100%',
textAlign: 'left',
},
}));
@@ -1,28 +0,0 @@
import React, { ReactElement } from 'react';
import { Text, View, type ViewProps } from 'react-native';
import { useStyles } from './style';
interface SettingGroupProps extends ViewProps {
title?: React.ReactNode;
}
export const SettingGroup = ({ children, style, title, ...rest }: SettingGroupProps) => {
const { styles } = useStyles();
const items = React.Children.toArray(children);
const newItems = items.map((child, index) => {
if (React.isValidElement(child)) {
const isLast = index === items.length - 1;
return React.cloneElement(child as ReactElement<any>, { isLast });
}
return child;
});
return (
<View {...rest}>
{title && (typeof title === 'string' ? <Text style={styles.title}>{title}</Text> : title)}
<View style={[styles.container, style]}>{newItems}</View>
</View>
);
};
@@ -1,17 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
marginBottom: token.marginLG,
overflow: 'hidden',
},
title: {
color: token.colorTextLabel,
fontSize: token.fontSize,
fontWeight: token.fontWeightStrong,
marginBottom: token.marginXS,
paddingHorizontal: token.padding,
},
}));
@@ -1,103 +0,0 @@
import { Icon, Switch } from '@lobehub/ui-rn';
import { Href, Link } from 'expo-router';
import { Check } from 'lucide-react-native';
import React, { ReactNode } from 'react';
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import { useStyles } from './style';
interface SettingItemProps {
customContent?: ReactNode;
description?: string;
extra?: string;
href?: Href;
isLast?: boolean;
isSelected?: boolean;
loading?: 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,
loading = false,
customContent,
}: SettingItemProps) => {
const { styles } = useStyles();
const renderRightContent = () => {
if (showSwitch) {
return <Switch onValueChange={onSwitchChange} value={switchValue} />;
}
return (
<>
{extra && <Text style={styles.settingItemExtra}>{extra}</Text>}
{showNewBadge && <View style={styles.badge} />}
{loading && (
<View style={styles.checkmark}>
<ActivityIndicator size="small" />
</View>
)}
{!loading && showCheckmark && isSelected && (
<Text style={styles.checkmark}>
<Icon icon={Check} size="small" />
</Text>
)}
{(onPress || href) && !showCheckmark && !loading && (
<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 activeOpacity={1} onPress={onPress}>
{content}
</TouchableOpacity>
);
if (href) {
return (
<Link asChild href={href}>
{touchableContent}
</Link>
);
}
return (
<TouchableOpacity activeOpacity={1} onPress={onPress}>
{content}
</TouchableOpacity>
);
};
@@ -1,59 +0,0 @@
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,
},
}));
@@ -1,2 +0,0 @@
export { SettingGroup } from './SettingGroup';
export { SettingItem } from './SettingItem';
@@ -1,18 +0,0 @@
import { Stack } from 'expo-router';
import { useThemedScreenOptions } from '@/_const/navigation';
export default function SettingRoutesLayout() {
const themedScreenOptions = useThemedScreenOptions();
return (
<Stack screenOptions={themedScreenOptions}>
<Stack.Screen name="providers/index" />
<Stack.Screen name="locale/index" />
<Stack.Screen name="account/index" />
<Stack.Screen name="developer/index" />
<Stack.Screen name="color/index" />
<Stack.Screen name="themeMode/index" />
<Stack.Screen name="fontSize/index" />
</Stack>
);
}
@@ -1,90 +0,0 @@
import { Avatar, Button, PageContainer } from '@lobehub/ui-rn';
import { useRouter } from 'expo-router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, View } from 'react-native';
import { safeReplaceLogin } from '@/navigation/safeLogin';
import { useAuth, useAuthActions } from '@/store/user';
import { SettingGroup, SettingItem } from '../(components)';
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 router = useRouter();
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();
// Ensure we leave the authenticated stack immediately after logout
safeReplaceLogin(router);
} 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 (
<PageContainer
showBack
style={styles.safeAreaView}
title={t('account.title', { ns: 'setting' })}
>
<View 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 */}
<SettingGroup>
<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' })}
/>
</SettingGroup>
{/* Logout Section */}
<View style={styles.signOutSection}>
<Button block danger onPress={handleSignOut} size="large" type="primary">
{t('account.signOut.label', { ns: 'setting' })}
</Button>
</View>
</>
)}
</View>
</PageContainer>
);
}
@@ -1,19 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
avatarSection: {
alignItems: 'center',
marginBottom: token.marginLG,
paddingVertical: token.paddingXL,
},
container: {
paddingHorizontal: token.padding,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
signOutSection: {
marginTop: token.marginXS,
},
}));
@@ -1,80 +0,0 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import { useStyles } from './style';
const Preview = memo(() => {
const { styles } = useStyles();
const { t } = useTranslation(['setting']);
// 移动端导航栏
const navbar = (
<View style={styles.navbar}>
<View style={styles.navLeft}>
<View style={styles.backButton} />
<View style={styles.navTitle} />
</View>
<View style={styles.navRight}>
<View style={styles.navIcon} />
<View style={styles.navIcon} />
</View>
</View>
);
// 聊天消息内容
const chatContent = (
<View style={styles.chatContent}>
{/* 用户消息 1 */}
<View style={[styles.messageContainer, styles.messageContainerUser]}>
<View style={styles.messageBubbleUser}>
<Text style={[styles.messageText, styles.messageTextUser]}>
{t('color.previewMessages.userHowToUse', { ns: 'setting' })}
</Text>
</View>
</View>
{/* AI 回复消息 1 */}
<View style={[styles.messageContainer, styles.messageContainerBot]}>
<View style={styles.messageBubbleBot}>
<Text style={[styles.messageText, styles.messageTextBot]}>
{t('color.previewMessages.botHowToUse', { ns: 'setting' })}
</Text>
</View>
</View>
<View style={[styles.messageContainer, styles.messageContainerUser]}>
<View style={styles.messageBubbleUser}>
<Text style={[styles.messageText, styles.messageTextUser]}></Text>
</View>
</View>
{/* AI 回复消息 3 */}
<View style={[styles.messageContainer, styles.messageContainerBot]}>
<View style={styles.messageBubbleBot}>
<Text style={[styles.messageText, styles.messageTextBot]}>
</Text>
</View>
</View>
</View>
);
// 输入区域
const inputArea = (
<View style={styles.inputArea}>
<View style={styles.inputBox} />
<View style={styles.sendButton} />
</View>
);
return (
<View style={[styles.container]}>
{navbar}
{chatContent}
{inputArea}
</View>
);
});
export default Preview;
@@ -1,175 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => {
return {
backButton: {
alignItems: 'center',
backgroundColor: token.colorTextSecondary,
borderColor: token.colorPrimary,
borderRadius: 24,
height: 24,
justifyContent: 'center',
width: 24,
},
// 聊天内容区域
chatArea: {},
chatContent: {
backgroundColor: token.colorBgLayout,
gap: token.marginSM,
paddingHorizontal: token.paddingSM,
paddingVertical: token.paddingSM,
},
container: {
backgroundColor: token.colorBgContainer,
borderColor: token.colorBorder,
borderRadius: token.borderRadiusLG,
borderWidth: 0.5,
},
// 输入区域
inputArea: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
flexDirection: 'row',
gap: token.marginXS,
height: 60,
paddingHorizontal: token.paddingSM,
},
inputBox: {
backgroundColor: token.colorBgLayout,
borderColor: token.colorBorder,
borderRadius: 18,
borderWidth: 0.5,
flex: 1,
height: 36,
justifyContent: 'center',
paddingHorizontal: token.paddingSM,
},
inputPlaceholder: {
backgroundColor: token.colorTextTertiary,
borderRadius: 1,
height: 2,
width: '60%',
},
messageBubbleBot: {
backgroundColor: token.colorBgContainer,
borderColor: token.colorBorderSecondary,
borderRadius: token.borderRadius,
borderWidth: 0.5,
marginBottom: 2,
paddingHorizontal: 10,
paddingVertical: token.paddingXS,
},
messageBubbleUser: {
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadius,
marginBottom: 2,
paddingHorizontal: 10,
paddingVertical: token.paddingXS,
},
// 消息样式
messageContainer: {
maxWidth: '75%',
},
messageContainerBot: {
alignSelf: 'flex-start',
},
messageContainerUser: {
alignSelf: 'flex-end',
},
messageLine: {
borderRadius: 1,
height: 2,
marginVertical: 1,
},
messageLineBot: {
backgroundColor: token.colorTextQuaternary,
},
messageLineFull: {
width: '100%',
},
messageLinePartial: {
width: '75%',
},
messageLineShort: {
width: '45%',
},
messageLineUser: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
messageText: {
fontSize: token.fontSize,
lineHeight: token.lineHeight,
},
messageTextBot: {
color: token.colorText,
},
messageTextUser: {
color: token.colorText,
},
navIcon: {
backgroundColor: token.colorTextSecondary,
borderRadius: token.borderRadiusXS,
height: 10,
width: 10,
},
navLeft: {
alignItems: 'center',
flexDirection: 'row',
gap: token.marginXS,
},
navRight: {
alignItems: 'center',
flexDirection: 'row',
gap: token.marginXS,
},
navTitle: {
backgroundColor: token.colorTextSecondary,
borderRadius: token.borderRadiusXS,
height: 10,
width: 80,
},
// 移动端顶部导航栏
navbar: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
flexDirection: 'row',
height: 44,
justifyContent: 'space-between',
paddingHorizontal: token.padding,
},
sendButton: {
alignItems: 'center',
backgroundColor: token.colorPrimary,
borderRadius: 18,
height: 36,
justifyContent: 'center',
width: 36,
},
};
});
@@ -1,89 +0,0 @@
import { ColorSwatches, PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { View } from 'react-native';
import { useSettingStore } from '@/store/setting';
import {
NeutralColors,
PrimaryColors,
findCustomThemeName,
neutralColors,
primaryColors,
} from '@/theme';
import { SettingGroup, SettingItem } from '../(components)';
import { useStyles } from '../styles';
import Preview from './(components)/Preview';
export default function ThemeSettingScreen() {
const { t } = useTranslation(['setting']);
const { styles } = useStyles();
const { primaryColor, neutralColor, setPrimaryColor, setNeutralColor } = useSettingStore();
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 (
<PageContainer showBack style={styles.safeAreaView} title={t('color.title', { ns: 'setting' })}>
<View style={styles.container}>
<Preview />
<SettingGroup style={{ marginTop: 16 }}>
<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('color.primary.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('color.neutral.title', { ns: 'setting' })}
/>
</SettingGroup>
</View>
</PageContainer>
);
}
@@ -1,23 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { View } from 'react-native';
import CustomServer from '@/features/setting/developer/CustomServer';
import { useStyles } from './style';
const CustomServerScreen = () => {
const { t } = useTranslation(['setting']);
const { styles } = useStyles();
return (
<PageContainer showBack title={t('developer.customServer.title', { ns: 'setting' })}>
<View style={styles.container}>
<CustomServer />
</View>
</PageContainer>
);
};
export default CustomServerScreen;
@@ -1,7 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
}));
@@ -1,134 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, View } from 'react-native';
import { useSettingStore } from '@/store/setting';
import { SettingGroup, 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 { developerMode, setDeveloperMode } = useSettingStore();
const confirmThenExecute = (
confirmMessage: string,
execute: () => Promise<void>,
successMessage: string,
) => {
const confirmTitle = t('actions.confirm', { ns: 'common' });
const confirmText = t('actions.confirm', { ns: 'common' });
const cancelText = t('actions.cancel', { ns: 'common' });
const developerTitle = t('developer.title', { ns: 'setting' });
const failurePrefix = t('developer.failurePrefix', { ns: 'setting' });
Alert.alert(
confirmTitle,
confirmMessage,
[
{ style: 'cancel', text: cancelText },
{
onPress: async () => {
try {
await execute();
Alert.alert(developerTitle, successMessage);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Alert.alert(developerTitle, `${failurePrefix}${msg}`);
}
},
style: 'destructive',
text: confirmText,
},
],
{ cancelable: true },
);
};
return (
<PageContainer showBack title={t('developer.title', { ns: 'setting' })}>
<View style={styles.container}>
<SettingGroup>
<SettingItem
onSwitchChange={(value) => setDeveloperMode(value)}
showSwitch
switchValue={developerMode}
title={t('developer.mode.title', { ns: 'setting' })}
/>
</SettingGroup>
{developerMode && (
<>
<SettingGroup>
<SettingItem
href="/setting/developer/custom-server"
title={t('developer.customServer.title', { ns: 'setting' })}
/>
</SettingGroup>
<SettingGroup>
<SettingItem
onPress={() =>
confirmThenExecute(
t('developer.accessToken.expire.title', { ns: 'setting' }),
expireAccessTokenNow,
t('developer.accessToken.expire.success', { ns: 'setting' }),
)
}
title={t('developer.accessToken.expire.title', { ns: 'setting' })}
/>
<SettingItem
onPress={() =>
confirmThenExecute(
t('developer.refreshToken.expire.title', { ns: 'setting' }),
expireRefreshTokenNow,
t('developer.refreshToken.expire.success', { ns: 'setting' }),
)
}
title={t('developer.refreshToken.expire.title', { ns: 'setting' })}
/>
<SettingItem
onPress={() =>
confirmThenExecute(
t('developer.accessToken.invalidate.title', { ns: 'setting' }),
invalidateAccessToken,
t('developer.accessToken.invalidate.success', { ns: 'setting' }),
)
}
title={t('developer.accessToken.invalidate.title', { ns: 'setting' })}
/>
<SettingItem
onPress={() =>
confirmThenExecute(
t('developer.refreshToken.invalidate.title', { ns: 'setting' }),
invalidateRefreshToken,
t('developer.refreshToken.invalidate.success', { ns: 'setting' }),
)
}
title={t('developer.refreshToken.invalidate.title', { ns: 'setting' })}
/>
<SettingItem
onPress={() =>
confirmThenExecute(
t('developer.clearAuthData.title', { ns: 'setting' }),
clearAuthData,
t('developer.clearAuthData.success', { ns: 'setting' }),
)
}
title={t('developer.clearAuthData.title', { ns: 'setting' })}
/>
</SettingGroup>
</>
)}
</View>
</PageContainer>
);
}
@@ -1,11 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -1,33 +0,0 @@
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();
};
@@ -1,68 +0,0 @@
import { Markdown } from '@lobehub/ui-rn';
import React from 'react';
import { View } from 'react-native';
import { useSettingStore } from '@/store/setting';
import { createStyles } from '@/theme';
const usePreviewStyles = createStyles(({ token }) => ({
aiBubble: {
width: '100%',
},
bubble: {
borderRadius: token.borderRadius,
paddingHorizontal: token.paddingSM,
},
container: {
width: '100%',
},
row: {
alignItems: 'flex-start',
flexDirection: 'row',
gap: token.marginXS,
marginVertical: token.marginXS,
},
rowRight: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
userBubble: {
backgroundColor: token.colorBgContainer,
},
}));
const Preview = () => {
const { styles } = usePreviewStyles();
const { fontSize } = useSettingStore();
return (
<View style={styles.container}>
<View style={[styles.row, styles.rowRight]}>
<View style={[styles.bubble, styles.userBubble]}>
<Markdown fontSize={fontSize}>{'我想把对话字体调大一些,该怎么做?'}</Markdown>
</View>
</View>
<View style={[styles.row, styles.aiBubble]}>
<View style={[styles.bubble]}>
<Markdown
fontSize={fontSize}
>{`**如何调整字体大小?**\n\n使用下方的滑块即可调节字体大小:向左变小,向右变大。拖动时,这里会实时预览效果。\n\n小提示:选择“${'标准'}”刻度可快速恢复默认大小。`}</Markdown>
</View>
</View>
<View style={[styles.row, styles.rowRight]}>
<View style={[styles.bubble, styles.userBubble]}>
<Markdown fontSize={fontSize}>{'很棒!'}</Markdown>
</View>
</View>
<View style={[styles.row, styles.aiBubble]}>
<View style={[styles.bubble]}>
<Markdown fontSize={fontSize}>
{'很高兴你喜欢!这个预览功能让你可以在应用设置之前直观地看到在对话框中的对话效果。'}
</Markdown>
</View>
</View>
</View>
);
};
export default Preview;
@@ -1,55 +0,0 @@
import { PageContainer, Slider } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, View } from 'react-native';
import { FONT_SIZE_LARGE, FONT_SIZE_SMALL, FONT_SIZE_STANDARD } from '@/_const/common';
import { useSettingStore } from '@/store/setting';
import Preview from './components/Preview';
import { useStyles } from './styles';
export default function FontSizeSettingScreen() {
const { t } = useTranslation(['setting']);
const { styles } = useStyles();
const { fontSize, setFontSize } = useSettingStore();
return (
<PageContainer
showBack
style={styles.safeAreaView}
title={t('fontSize.title', { ns: 'setting' })}
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, paddingBottom: 120 }}
style={styles.container}
>
<Preview />
</ScrollView>
<View style={styles.bottomBarWrapper}>
<Slider
marks={{
[FONT_SIZE_LARGE]: { label: <Text style={styles.fontSizeLarge}>A</Text> },
[FONT_SIZE_SMALL]: { label: <Text style={styles.fontSizeSmall}>A</Text> },
[FONT_SIZE_STANDARD]: {
label: (
<Text style={styles.fontSizeStandard}>
{t('fontSize.standard', { ns: 'setting' })}
</Text>
),
},
}}
max={FONT_SIZE_LARGE}
min={FONT_SIZE_SMALL}
onChangeComplete={setFontSize}
step={1}
value={fontSize}
/>
<View style={styles.fontSizeContainer}>
<Text style={styles.fontSizeText}>{t('fontSize.text', { ns: 'setting' })}</Text>
</View>
</View>
</PageContainer>
);
}
@@ -1,48 +0,0 @@
import { FONT_SIZE_LARGE, FONT_SIZE_SMALL, FONT_SIZE_STANDARD } from '@/_const/common';
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
bottomBarWrapper: {
backgroundColor: token.colorBgContainer,
bottom: 0,
height: 160,
left: 0,
paddingHorizontal: token.paddingLG,
paddingVertical: token.paddingXL,
position: 'absolute',
right: 0,
width: '100%',
},
container: {
backgroundColor: token.colorBgLayout,
flex: 1,
padding: token.paddingContentHorizontal,
width: '100%',
},
fontSizeContainer: {
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
marginTop: token.marginLG,
},
fontSizeLarge: {
color: token.colorText,
fontSize: FONT_SIZE_LARGE,
},
fontSizeSmall: {
color: token.colorText,
fontSize: FONT_SIZE_SMALL,
},
fontSizeStandard: {
color: token.colorText,
fontSize: FONT_SIZE_STANDARD,
},
fontSizeText: {
color: token.colorTextDescription,
fontSize: token.fontSize,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
-139
View File
@@ -1,139 +0,0 @@
import { PageContainer, Toast } from '@lobehub/ui-rn';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useLocale } from '@/hooks/useLocale';
import { useSettingStore } from '@/store/setting';
import { useThemeMode } from '@/theme';
import { version } from '../../../package.json';
import { SettingGroup, SettingItem } from './(components)';
import { useStyles } from './styles';
export default function SettingScreen() {
const { t } = useTranslation(['setting', 'auth', 'common', 'error']);
const { getLocaleDisplayName } = useLocale();
const { themeMode } = useThemeMode();
const { developerMode, setDeveloperMode } = useSettingStore();
const [tapCount, setTapCount] = useState(0);
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getThemeModeDisplayName = () => {
if (themeMode === 'auto') {
return t('themeMode.auto', { ns: 'setting' });
}
return t(`themeMode.${themeMode}`, { ns: 'setting' });
};
const handleVersionTap = () => {
const newTapCount = tapCount + 1;
setTapCount(newTapCount);
// 清除之前的定时器
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
}
// 开发者模式开启后提示
if (developerMode) {
if (newTapCount >= 3) {
Toast.info(t('developer.mode.already', { ns: 'setting' }));
setTapCount(0);
return;
}
} else {
// 连续点击7次开启开发者模式
if (newTapCount >= 7) {
setDeveloperMode(true);
setTapCount(0);
Toast.success(t('developer.mode.enabled', { ns: 'setting' }));
return;
}
if (newTapCount >= 3) {
const remaining = 7 - newTapCount;
Toast.info(
t('developer.mode.remaining', {
count: remaining,
ns: 'setting',
}),
1500,
);
}
}
// 2秒后重置计数器
tapTimeoutRef.current = setTimeout(() => {
setTapCount(0);
}, 2000);
};
const { styles } = useStyles();
// 清理定时器
useEffect(() => {
return () => {
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
}
};
}, []);
return (
<PageContainer showBack title={t('title', { ns: 'setting' })}>
<ScrollView style={[styles.container]}>
<SettingGroup>
<SettingItem
extra={getThemeModeDisplayName()}
href={'/setting/themeMode'}
title={t('themeMode.title', { ns: 'setting' })}
/>
<SettingItem href={'/setting/color'} title={t('color.title', { ns: 'setting' })} />
<SettingItem href={'/setting/fontSize'} title={t('fontSize.title', { ns: 'setting' })} />
<SettingItem
extra={getLocaleDisplayName()}
href="/setting/locale"
title={t('locale.title', { ns: 'setting' })}
/>
</SettingGroup>
<SettingGroup>
<SettingItem href="/setting/account" title={t('account.title', { ns: 'setting' })} />
<SettingItem href="/setting/providers" title={t('providers', { ns: 'setting' })} />
</SettingGroup>
{developerMode && (
<SettingGroup>
<SettingItem
href="/setting/developer"
title={t('developer.title', { ns: 'setting' })}
/>
</SettingGroup>
)}
<SettingGroup>
<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}
onPress={handleVersionTap}
title={t('version', { ns: 'setting' })}
/>
</SettingGroup>
</ScrollView>
</PageContainer>
);
}
@@ -1,59 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useLocale } from '@/hooks/useLocale';
import { LANGUAGE_OPTIONS, LocaleMode } from '@/i18n/resource';
import { SettingGroup, SettingItem } from '../(components)';
import { useStyles } from './styles';
export default function LocaleScreen() {
const { styles } = useStyles();
const { localeMode, changeLocale } = useLocale();
const { t } = useTranslation(['setting']);
const [pendingLocale, setPendingLocale] = React.useState<LocaleMode | null>(null);
const handleLocaleChange = async (locale: LocaleMode) => {
if (pendingLocale || localeMode === locale) return;
try {
setPendingLocale(locale);
await changeLocale(locale);
} finally {
setPendingLocale(null);
}
};
const localeOptions = [
{ label: t('locale.auto.title', { ns: 'setting' }), value: 'auto' },
...LANGUAGE_OPTIONS,
];
return (
<PageContainer
showBack
style={styles.safeAreaView}
title={t('locale.title', { ns: 'setting' })}
>
<ScrollView contentContainerStyle={styles.contentContainer}>
<SettingGroup>
{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}
loading={pendingLocale === (option.value as LocaleMode)}
onPress={() => handleLocaleChange(option.value as LocaleMode)}
showCheckmark={true}
title={option.label}
/>
))}
</SettingGroup>
</ScrollView>
</PageContainer>
);
}
@@ -1,11 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
contentContainer: {
padding: token.paddingContentHorizontal,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -1,521 +0,0 @@
import { AiProviderDetailItem } from '@lobechat/types';
import { ModelIcon } from '@lobehub/icons-rn';
import {
Button,
Input,
InstantSwitch,
ModelInfoTags,
PageContainer,
Tag,
useToast,
} from '@lobehub/ui-rn';
import Clipboard from '@react-native-clipboard/clipboard';
import { FlashList } from '@shopify/flash-list';
import { useLocalSearchParams, useNavigation } from 'expo-router';
import { RefreshCcw } from 'lucide-react-native';
import { AiProviderModelListItem } from 'model-bank';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from 'react-native';
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
import ConfigurationSection from '@/features/setting/providers/ConfigurationSection';
import ProviderInfoSection from '@/features/setting/providers/ProviderInfoSection';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { aiModelSelectors } from '@/store/aiInfra/selectors';
import { useTheme } from '@/theme';
import { useStyles } from './styles';
// 定义FlashList数据项类型
type FlashListItem =
| { data: AiProviderDetailItem; id: string; type: 'provider-info' }
| { data: AiProviderDetailItem; id: string; type: 'configuration' }
| {
data: { isFetching: boolean; searchKeyword: string; totalCount: number };
id: string;
type: 'models-header';
}
| { data: { count: number; title: string }; id: string; type: 'section-header' }
| { data: AiProviderModelListItem; id: string; type: 'model' }
| { data: { message: string }; id: string; type: 'empty' };
// ModelCard组件(从ModelsSection提取)
const ModelCard = React.memo<{
model: AiProviderModelListItem;
onToggle: (_modelId: string, _enabled: boolean) => void;
}>(({ model, onToggle }) => {
const { styles } = useStyles();
const toast = useToast();
const { t } = useTranslation(['setting']);
const handleToggle = (value: boolean) => {
onToggle(model.id, value);
};
const handleCopyModelId = () => {
Clipboard.setString(model.id);
toast.success(t('aiProviders.models.copySuccess', { ns: 'setting' }));
};
return (
<View style={styles.modelCard}>
<View style={styles.modelCardContent}>
<ModelIcon model={model.id} size={32} />
<View style={styles.modelInfo}>
<View style={styles.modelTopRow}>
<Text style={styles.modelName}>{model.displayName || model.id}</Text>
<ModelInfoTags {...model.abilities} contextWindowTokens={model.contextWindowTokens} />
</View>
<TouchableOpacity onPress={handleCopyModelId} style={styles.modelBottomRow}>
<Tag style={styles.modelIdTag} textStyle={styles.modelIdText}>
{model.id}
</Tag>
</TouchableOpacity>
</View>
<View style={styles.modelSwitchContainer}>
<InstantSwitch
enabled={model.enabled}
onChange={async (enabled) => {
handleToggle(enabled);
}}
size="small"
/>
</View>
</View>
</View>
);
});
const ProviderDetailPage = () => {
const navigation = useNavigation();
const { id } = useLocalSearchParams<{ id: string }>();
const { styles } = useStyles();
const { t } = useTranslation(['setting']);
const token = useTheme();
const toast = useToast();
// Models相关状态(从ModelsSection移过来)
const [searchKeyword, setSearchKeyword] = useState('');
const [isFetching, setIsFetching] = useState(false);
const [displayedCount, setDisplayedCount] = useState(20);
// Store hooks for models
const { useFetchAiProviderModels, fetchRemoteModelList, toggleModelEnabled } = useAiInfraStore();
const { data: modelList, isLoading: isModelsLoading } = useFetchAiProviderModels(id!);
const totalModels = useAiInfraStore(aiModelSelectors.totalAiProviderModelList);
// 先从本地配置获取基础信息
const builtinProviderCard = useMemo(
() => DEFAULT_MODEL_PROVIDER_LIST.find((v) => v.id === id),
[id],
);
// 获取用户的provider配置
const { useFetchAiProviderItem } = useAiInfraStore();
const { data: userConfig, isLoading, error } = useFetchAiProviderItem(id!);
// 获取provider配置状态
const isConfigLoading = useAiInfraStore(aiProviderSelectors.isAiProviderConfigLoading(id!));
// 合并配置
const providerDetail = useMemo<AiProviderDetailItem | undefined>(() => {
if (!builtinProviderCard && !userConfig) return undefined;
// 如果是内置provider,合并本地配置和用户配置
if (builtinProviderCard) {
return {
...builtinProviderCard,
...userConfig,
// 保持checkModel的默认值,只有用户明确配置了才使用用户的值
checkModel: userConfig?.checkModel ?? builtinProviderCard.checkModel,
// 保留用户配置的这些字段
enabled: userConfig?.enabled ?? builtinProviderCard.enabled,
keyVaults: userConfig?.keyVaults || {},
// 确保settings字段来自本地配置
settings: builtinProviderCard.settings,
} as AiProviderDetailItem;
}
// 如果是自定义provider,直接使用用户配置
return userConfig;
}, [builtinProviderCard, userConfig]);
// 模型相关处理函数
const handleFetchModels = useCallback(async () => {
setIsFetching(true);
try {
await fetchRemoteModelList(id!);
toast.success(t('aiProviders.models.fetchSuccess', { ns: 'setting' }));
} catch (error) {
console.error('Failed to fetch models:', error);
Alert.alert(
t('error', { ns: 'common' }),
t('aiProviders.models.fetchFailed', { ns: 'setting' }),
);
} finally {
setIsFetching(false);
}
}, [id, fetchRemoteModelList, toast, t]);
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(
t('error', { ns: 'common' }),
enabled
? t('aiProviders.models.enableFailed', { ns: 'setting' })
: t('aiProviders.models.disableFailed', { ns: 'setting' }),
);
}
},
[toggleModelEnabled, t],
);
// 动态设置页面标题
useEffect(() => {
const displayName = providerDetail?.name || builtinProviderCard?.name || id;
if (displayName) {
navigation.setOptions({
headerTitle: displayName,
title: displayName,
});
}
}, [navigation, providerDetail?.name, builtinProviderCard?.name, id]);
// 重置显示数量当搜索关键词变化时
useEffect(() => {
setDisplayedCount(20);
}, [searchKeyword]);
const headerTitle = providerDetail?.name || id || '';
// 处理模型数据(不包含UI逻辑)
const { enabledModels, disabledModels, totalFilteredCount } = useMemo(() => {
if (!modelList) return { disabledModels: [], enabledModels: [], totalFilteredCount: 0 };
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);
return {
disabledModels: disabled,
enabledModels: enabled,
totalFilteredCount: enabled.length + disabled.length,
};
}, [modelList, searchKeyword]);
// 构建FlashList统一数据结构
const flashListData = useMemo<FlashListItem[]>(() => {
if (!providerDetail) return [];
const items: FlashListItem[] = [];
// 1. Provider信息section和Configuration section
items.push(
{
data: providerDetail,
id: 'provider-info',
type: 'provider-info',
},
{
data: providerDetail,
id: 'configuration',
type: 'configuration',
},
{
data: {
isFetching,
searchKeyword,
totalCount: totalModels,
},
id: 'models-header',
type: 'models-header',
},
);
// 4. Models数据处理
if (isModelsLoading) {
return items;
}
if (totalFilteredCount === 0) {
items.push({
data: {
message: searchKeyword.trim()
? t('aiProviders.models.emptyWithSearch', { ns: 'setting' })
: t('aiProviders.models.emptyNoSearch', { ns: 'setting' }),
},
id: 'empty-models',
type: 'empty',
});
return items;
}
// 5. 启用的模型
if (enabledModels.length > 0) {
items.push({
data: {
count: enabledModels.length,
title: t('aiProviders.list.enabled', { ns: 'setting' }),
},
id: 'enabled-header',
type: 'section-header',
});
// 添加显示的启用模型
const displayedEnabled = enabledModels.slice(
0,
Math.min(displayedCount, enabledModels.length),
);
displayedEnabled.forEach((model) => {
items.push({
data: model,
id: `model-${model.id}`,
type: 'model',
});
});
}
// 6. 禁用的模型
const remainingCount = Math.max(0, displayedCount - enabledModels.length);
if (disabledModels.length > 0 && remainingCount > 0) {
items.push({
data: {
count: disabledModels.length,
title: t('aiProviders.list.disabled', { ns: 'setting' }),
},
id: 'disabled-header',
type: 'section-header',
});
// 添加显示的禁用模型
const displayedDisabled = disabledModels.slice(0, remainingCount);
displayedDisabled.forEach((model) => {
items.push({
data: model,
id: `model-${model.id}`,
type: 'model',
});
});
}
return items;
}, [
providerDetail,
searchKeyword,
totalModels,
isFetching,
isModelsLoading,
enabledModels,
disabledModels,
totalFilteredCount,
displayedCount,
t,
]);
// 统一的renderItem函数
const renderItem = useCallback(
({ item }: { item: FlashListItem }) => {
switch (item.type) {
case 'provider-info': {
return <ProviderInfoSection provider={item.data} />;
}
case 'configuration': {
return <ConfigurationSection provider={item.data} />;
}
case 'models-header': {
return (
<View style={styles.modelsHeader}>
<View style={styles.modelsTitleRow}>
<View style={styles.modelsTitleContainer}>
<Text style={styles.modelsTitle}>
{t('aiProviders.models.title', { ns: 'setting' })}
</Text>
<Text style={styles.modelsCount}>
{t('aiProviders.models.modelsAvailable', {
count: item.data.totalCount,
ns: 'setting',
})}
</Text>
</View>
<View style={styles.modelsActions}>
<Button
disabled={item.data.isFetching}
icon={<RefreshCcw />}
loading={item.data.isFetching}
onPress={handleFetchModels}
type="primary"
>
{item.data.isFetching
? t('aiProviders.models.fetching', { ns: 'setting' })
: t('aiProviders.models.fetch', { ns: 'setting' })}
</Button>
</View>
</View>
<Input.Search
onChangeText={setSearchKeyword}
placeholder={t('aiProviders.models.searchPlaceholder', { ns: 'setting' })}
style={styles.modelsSearchInput}
value={searchKeyword}
variant="outlined"
/>
</View>
);
}
case 'section-header': {
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>
{item.data.title} ({item.data.count})
</Text>
</View>
);
}
case 'model': {
return <ModelCard model={item.data} onToggle={handleToggleModel} />;
}
case 'empty': {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{item.data.message}</Text>
</View>
);
}
default: {
return null;
}
}
},
[
styles,
token,
t,
handleFetchModels,
handleToggleModel,
searchKeyword,
setSearchKeyword,
setDisplayedCount,
],
);
// FlashList的keyExtractor
const keyExtractor = useCallback((item: FlashListItem) => item.id, []);
// 自动加载更多
const handleEndReached = useCallback(() => {
if (displayedCount < totalFilteredCount) {
setDisplayedCount((prev) => Math.min(prev + 20, totalFilteredCount));
}
}, [displayedCount, totalFilteredCount]);
// ListFooterComponent - 优雅的加载提示
const renderFooter = useCallback(() => {
if (isModelsLoading) {
return (
<View style={styles.footerLoading}>
<ActivityIndicator color={token.colorPrimary} size="small" />
<Text style={styles.footerText}>
{t('aiProviders.models.loading', { ns: 'setting' })}
</Text>
</View>
);
}
if (displayedCount < totalFilteredCount) {
return (
<View style={styles.footerLoading}>
<ActivityIndicator color={token.colorTextSecondary} size="small" />
<Text style={styles.footerText}>
{t('aiProviders.models.loadingMore', { ns: 'setting' })} ({displayedCount}/
{totalFilteredCount})
</Text>
</View>
);
}
if (totalFilteredCount > 0) {
return (
<View style={styles.footerComplete}>
<Text style={styles.footerCompleteText}>
{t('aiProviders.models.allLoaded', { ns: 'setting' })} ({totalFilteredCount})
</Text>
</View>
);
}
return null;
}, [isModelsLoading, displayedCount, totalFilteredCount, token, t]);
if (isLoading || isConfigLoading) {
return (
<PageContainer showBack style={styles.container} title={headerTitle}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>
{t('aiProviders.detail.loading', { ns: 'setting' })}
</Text>
</View>
</PageContainer>
);
}
if (error || (!providerDetail && !isLoading && !isConfigLoading)) {
return (
<PageContainer showBack style={styles.container} title={headerTitle}>
<View style={styles.loadingContainer}>
<Text style={styles.errorText}>
{t('aiProviders.detail.loadFailed', { ns: 'setting' })}
</Text>
</View>
</PageContainer>
);
}
if (!providerDetail) {
return null;
}
return (
<PageContainer showBack style={styles.container} title={headerTitle}>
<FlashList
ListFooterComponent={renderFooter}
contentContainerStyle={styles.scrollContainer}
data={flashListData}
drawDistance={500}
getItemType={(item) => item.type}
keyExtractor={keyExtractor}
onEndReached={handleEndReached}
onEndReachedThreshold={0.2}
removeClippedSubviews={true}
renderItem={renderItem}
showsVerticalScrollIndicator={true}
/>
</PageContainer>
);
};
export default ProviderDetailPage;
@@ -1,193 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
// 空状态
emptyContainer: {
alignItems: 'center',
padding: token.paddingXL,
},
emptyText: {
color: token.colorTextTertiary,
fontSize: token.fontSize,
textAlign: 'center',
},
errorText: {
color: token.colorError,
fontSize: token.fontSize,
textAlign: 'center',
},
footerComplete: {
alignItems: 'center',
paddingVertical: token.paddingSM,
},
footerCompleteText: {
color: token.colorTextTertiary,
fontSize: token.fontSize,
fontStyle: 'italic',
},
// Footer样式 - 优雅的加载提示
footerLoading: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: token.paddingLG,
},
footerText: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
marginLeft: token.marginXS,
},
// 加载更多按钮
loadMoreButton: {
alignItems: 'center',
backgroundColor: token.colorFillSecondary,
borderRadius: token.borderRadius,
marginHorizontal: token.padding,
marginVertical: token.marginSM,
paddingVertical: token.paddingSM,
},
loadMoreText: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
fontWeight: '500',
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: token.paddingXL,
},
loadingText: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
textAlign: 'center',
},
modelBottomRow: {
alignSelf: 'flex-start',
},
// ModelCard样式
modelCard: {
// backgroundColor: token.colorBgContainer,
// borderBottomColor: token.colorBorderSecondary,
// borderBottomWidth: 1,
paddingHorizontal: token.padding,
paddingVertical: token.paddingSM,
},
modelCardContent: {
alignItems: 'center',
flexDirection: 'row',
},
modelIdTag: {
// backgroundColor: token.colorFillTertiary,
marginBottom: 0,
marginLeft: 0,
},
modelIdText: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
fontWeight: '500',
},
modelInfo: {
flex: 1,
paddingHorizontal: token.padding,
paddingVertical: token.paddingSM,
},
modelName: {
color: token.colorText,
flex: 1,
fontSize: token.fontSize,
fontWeight: '500',
},
modelSwitchContainer: {
alignItems: 'center',
flexDirection: 'row',
},
modelTopRow: {
alignItems: 'center',
flexDirection: 'row',
gap: token.marginXS,
marginBottom: token.marginXS,
},
modelsActions: {
alignItems: 'center',
flexDirection: 'row',
gap: token.marginXS,
},
modelsCount: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
},
// Models相关样式(从ModelsSection迁移)
modelsHeader: {
// backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
marginBottom: token.marginSM,
padding: token.padding,
},
modelsSearchInput: {
// paddingVertical: 10,
},
modelsTitle: {
color: token.colorText,
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
marginBottom: token.marginXXS,
},
modelsTitleContainer: {
flex: 1,
},
modelsTitleRow: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: token.marginSM,
},
scrollContainer: {
paddingHorizontal: token.paddingContentHorizontal,
paddingTop: token.paddingContentVertical,
},
// Section header样式
sectionHeader: {
// backgroundColor: token.colorFillQuaternary,
paddingHorizontal: token.padding,
paddingVertical: token.paddingXS,
},
sectionHeaderText: {
color: token.colorText,
fontSize: token.fontSize,
fontWeight: token.fontWeightStrong,
},
}));
@@ -1,171 +0,0 @@
import { AiProviderListItem } from '@lobechat/types';
import { PageContainer } from '@lobehub/ui-rn';
import { FlashList } from '@shopify/flash-list';
import isEqual from 'fast-deep-equal';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import ProviderCard from '@/features/setting/providers/ProviderCard';
import ProviderListSkeleton from '@/features/setting/providers/ProviderListSkeleton';
import { useAiInfraStore } from '@/store/aiInfra';
import { aiProviderSelectors } from '@/store/aiInfra/selectors';
import { useStyles } from './styles';
// 定义FlashList数据项类型
type ProviderFlashListItem =
| { data: { count: number; title: string }; id: string; type: 'section-header' }
| { data: AiProviderListItem; id: string; type: 'provider' }
| { data: { message: string }; id: string; type: 'empty' };
const ProviderList = () => {
const { styles } = useStyles();
const { t } = useTranslation(['setting']);
// 获取store数据和方法
const { useFetchAiProviderList } = useAiInfraStore();
// 使用SWR获取provider列表
const { isLoading, error } = useFetchAiProviderList();
// 使用store selectors来响应状态变化
const enabledProviders = useAiInfraStore(aiProviderSelectors.enabledAiProviderList, isEqual);
const disabledProviders = useAiInfraStore(aiProviderSelectors.disabledAiProviderList, isEqual);
// 构建FlashList统一数据结构
const flashListData = useMemo<ProviderFlashListItem[]>(() => {
const items: ProviderFlashListItem[] = [];
// 启用的Provider section
if (enabledProviders.length > 0) {
items.push({
data: {
count: enabledProviders.length,
title: t('aiProviders.list.enabled', { ns: 'setting' }),
},
id: 'enabled-header',
type: 'section-header',
});
enabledProviders.forEach((provider) => {
items.push({
data: provider,
id: `provider-${provider.id}`,
type: 'provider',
});
});
}
// 禁用的Provider section
if (disabledProviders.length > 0) {
items.push({
data: {
count: disabledProviders.length,
title: t('aiProviders.list.disabled', { ns: 'setting' }),
},
id: 'disabled-header',
type: 'section-header',
});
disabledProviders.forEach((provider) => {
items.push({
data: provider,
id: `provider-${provider.id}`,
type: 'provider',
});
});
}
// 如果没有任何Provider
if (items.length === 0) {
items.push({
data: {
message: t('aiProviders.list.empty', { ns: 'setting' }),
},
id: 'empty-providers',
type: 'empty',
});
}
return items;
}, [enabledProviders, disabledProviders, t]);
// 统一的renderItem函数
const renderItem = useCallback(
({ item }: { item: ProviderFlashListItem }) => {
switch (item.type) {
case 'section-header': {
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{item.data.title} ({item.data.count})
</Text>
</View>
);
}
case 'provider': {
return <ProviderCard provider={item.data} />;
}
case 'empty': {
return (
<View style={styles.container}>
<Text style={styles.label}>{item.data.message}</Text>
</View>
);
}
default: {
return null;
}
}
},
[styles],
);
// FlashList的keyExtractor
const keyExtractor = useCallback((item: ProviderFlashListItem) => item.id, []);
const renderSeparator = useCallback(() => <View style={styles.separator} />, [styles.separator]);
// Loading状态
if (isLoading) {
return (
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
<ProviderListSkeleton />
</PageContainer>
);
}
// Error状态
if (error) {
return (
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
<View style={[styles.container, { alignItems: 'center', justifyContent: 'center' }]}>
<Text style={styles.label}>{t('aiProviders.list.loadFailed', { ns: 'setting' })}</Text>
</View>
</PageContainer>
);
}
return (
<PageContainer showBack style={styles.safeAreaView} title={t('providers', { ns: 'setting' })}>
<View style={styles.container}>
<FlashList
ItemSeparatorComponent={renderSeparator}
data={flashListData}
drawDistance={400}
getItemType={(item) => item.type}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
showsVerticalScrollIndicator={true}
/>
</View>
</PageContainer>
);
};
export default ProviderList;
@@ -1,115 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
backButton: {
marginRight: token.marginXS,
},
container: {
flex: 1,
},
content: {
padding: token.padding,
},
// 空状态样式
emptyContainer: {
alignItems: 'center',
padding: token.paddingXL,
},
emptyText: {
color: token.colorTextTertiary,
fontSize: token.fontSize,
textAlign: 'center',
},
eyeButton: {
alignItems: 'center',
bottom: 0,
justifyContent: 'center',
position: 'absolute',
right: token.marginXS,
top: 0,
width: 36,
},
header: {
alignItems: 'center',
borderBottomWidth: 1,
flexDirection: 'row',
paddingHorizontal: token.padding,
},
headerTitle: {
color: token.colorText,
flex: 1,
fontSize: 17,
fontWeight: token.fontWeightStrong,
marginRight: 40,
textAlign: 'center',
},
hint: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
marginTop: token.marginXS,
},
input: {
backgroundColor: token.colorBgContainer,
borderColor: token.colorBorder,
borderRadius: token.borderRadiusLG,
borderWidth: 1,
color: token.colorText,
fontSize: token.fontSizeLG,
height: 44,
paddingHorizontal: token.paddingSM,
},
inputContainer: {
marginBottom: token.margin,
position: 'relative',
},
inputWithIcon: {
paddingRight: 44,
},
label: {
color: token.colorText,
fontSize: token.fontSize,
marginBottom: token.marginXS,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
// Section样式 - 对标web端
sectionHeader: {
backgroundColor: token.colorBgLayout,
paddingBottom: token.paddingXS,
paddingHorizontal: token.padding,
paddingTop: token.paddingLG,
},
sectionSeparator: {
height: 16, // 增加section间距
},
sectionTitle: {
color: token.colorText,
fontSize: 18, // 对标web端字体大小
fontWeight: token.fontWeightStrong,
},
// 卡片间分隔 - 对标web端Grid gap
separator: {
backgroundColor: 'transparent',
height: 16, // 对标web端16px gap
},
validateButton: {
marginTop: token.margin,
},
}));
-7
View File
@@ -1,7 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
}));
@@ -1,52 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { View } from 'react-native';
import { useThemeMode as useAppTheme } from '@/theme';
import { SettingGroup, SettingItem } from '../(components)';
import { useStyles } from '../styles';
export default function ThemeModeSettingScreen() {
const { t } = useTranslation(['setting']);
const { styles } = useStyles();
const { themeMode, setThemeMode } = useAppTheme();
const isFollowSystem = themeMode === 'auto';
return (
<PageContainer
showBack
style={styles.safeAreaView}
title={t('themeMode.title', { ns: 'setting' })}
>
<View style={styles.container}>
<SettingGroup>
<SettingItem
onSwitchChange={(enabled) => setThemeMode(enabled ? 'auto' : 'light')}
showSwitch
switchValue={isFollowSystem}
title={t('themeMode.auto', { ns: 'setting' })}
/>
</SettingGroup>
{!isFollowSystem && (
<SettingGroup>
<SettingItem
isSelected={themeMode === 'light'}
onPress={() => setThemeMode('light')}
showCheckmark
title={t('themeMode.light', { ns: 'setting' })}
/>
<SettingItem
isSelected={themeMode === 'dark'}
onPress={() => setThemeMode('dark')}
showCheckmark
title={t('themeMode.dark', { ns: 'setting' })}
/>
</SettingGroup>
)}
</View>
</PageContainer>
);
}
-47
View File
@@ -1,47 +0,0 @@
import { Link, Stack } from 'expo-router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
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>
</>
);
}
-197
View File
@@ -1,197 +0,0 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import { PortalProvider } from '@gorhom/portal';
import { ToastProvider } from '@lobehub/ui-rn';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as NavigationBar from 'expo-navigation-bar';
import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { Platform } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { RootSiblingParent } from 'react-native-root-siblings';
import i18n from '@/i18n';
import { I18nReadyGate } from '@/i18n/ReadyGate';
import { safeReplaceLogin } from '@/navigation/safeLogin';
import { tokenRefreshManager } from '@/services/_auth/tokenRefresh';
import { TRPCProvider, trpcClient } from '@/services/_auth/trpc';
import { useAuth, useUserStore } from '@/store/user';
import { ThemeProvider, useTheme, useThemeMode } from '@/theme';
import { authLogger } from '@/utils/logger';
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(() => {
safeReplaceLogin(router);
}, 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>
);
};
function ThemedSystemBars() {
const { isDarkMode } = useThemeMode();
const token = useTheme();
useEffect(() => {
if (Platform.OS !== 'android') return;
NavigationBar.setBackgroundColorAsync(token.colorBgLayout).catch(() => {});
NavigationBar.setButtonStyleAsync(isDarkMode ? 'light' : 'dark').catch(() => {});
}, [isDarkMode, token.colorBgLayout]);
return (
<StatusBar
animated
backgroundColor={token.colorBgLayout}
style={isDarkMode ? 'light' : 'dark'}
/>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<ThemeProvider>
<AuthProvider>
<QueryProvider>
<ActionSheetProvider>
<PortalProvider>
<I18nextProvider i18n={i18n}>
<I18nReadyGate>
<ToastProvider>
<RootSiblingParent>
<ThemedSystemBars />
<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>
</RootSiblingParent>
</ToastProvider>
</I18nReadyGate>
</I18nextProvider>
</PortalProvider>
</ActionSheetProvider>
</QueryProvider>
</AuthProvider>
</ThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { Stack } from 'expo-router';
import React from 'react';
const AuthLayout = () => {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen
name="login"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="callback"
options={{
headerShown: false,
}}
/>
</Stack>
);
};
export default AuthLayout;
-23
View File
@@ -1,23 +0,0 @@
import { useRouter } from 'expo-router';
import { useEffect } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
// 处理 OAuth 回调的占位页面
// Android 在完成外部浏览器登录后会通过 deep link 打开 com.lobehub.app://auth/callback
// 如果没有该页面,会短暂进入 +not-found。这里立即跳转到首页,由首页再根据认证状态进入 /chat。
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
// 立即导航到首页,避免展示 404 页面
const t = setTimeout(() => router.replace('/'), 0);
return () => clearTimeout(t);
}, [router]);
return (
<View style={{ alignItems: 'center', flex: 1, justifyContent: 'center' }}>
<ActivityIndicator size="large" />
<Text style={{ marginTop: 12 }}>...</Text>
</View>
);
}
-82
View File
@@ -1,82 +0,0 @@
import { Button } from '@lobehub/ui-rn';
import { Link, useRouter } from 'expo-router';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Image, Text, View } from 'react-native';
import { setLoginMounted } from '@/navigation/loginState';
import { useAuth, useAuthActions } from '@/store/user';
import { getLoginErrorKey } from '@/utils/error';
import { useStyles } from './styles';
const LoginPage = () => {
const { t } = useTranslation(['auth', 'error', 'common']);
const { isLoading } = useAuth();
const { login } = useAuthActions();
const router = useRouter();
const { styles } = useStyles();
useEffect(() => {
setLoginMounted(true);
return () => setLoginMounted(false);
}, []);
const handleLogin = async () => {
try {
await login();
// 立即导航到对话页,避免展示 404 页面
setTimeout(() => router.replace('/chat'), 0);
} catch (error) {
const key = getLoginErrorKey(error);
const message = t(key, { ns: 'error' });
Alert.alert(t('error.title', { ns: 'error' }), message);
}
};
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
@@ -1,72 +0,0 @@
import { LOGO_SIZE } from '@/_const/common';
import { createStyles } from '@/theme';
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,
},
}));
-18
View File
@@ -1,18 +0,0 @@
import { Stack } from 'expo-router';
import React from 'react';
import { useThemedScreenOptions } from '@/_const/navigation';
export default function RoutesLayout() {
const themedScreenOptions = useThemedScreenOptions();
return (
<Stack
screenOptions={{
...themedScreenOptions,
headerShown: false,
}}
>
<Stack.Screen name="assistant/[...slugs]" options={{ headerShown: false }} />
</Stack>
);
}
@@ -1,196 +0,0 @@
import { Button, Icon, Markdown, PageContainer, Tag } from '@lobehub/ui-rn';
import { router, useLocalSearchParams } from 'expo-router';
import { BotMessageSquare } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, InteractionManager, ScrollView, Text, View } from 'react-native';
import DetailHeader from '@/features/discover/assistant/components/DetailHeader';
import SkeletonDetail from '@/features/discover/assistant/components/SkeletonDetail';
import { useDiscoverStore } from '@/store/discover';
import { useGlobalStore } from '@/store/global';
import { useSessionStore } from '@/store/session';
import { useStyles } from './styles';
const AssistantDetail = () => {
const { slugs } = useLocalSearchParams<{ slugs: string[] }>();
const identifier = decodeURIComponent(slugs.join('/'));
const { styles } = useStyles();
const { t } = useTranslation(['common', 'discover']);
const [isAdding, setIsAdding] = useState(false);
const createSession = useSessionStore((s) => s.createSession);
const toggleDrawer = useGlobalStore((s) => s.toggleDrawer);
const useAssistantDetail = useDiscoverStore((s) => s.useAssistantDetail);
const {
data: agent,
error,
isLoading,
} = useAssistantDetail({
identifier: identifier as string,
});
// const handleAddAgentAndConverse = async () => {
// if (!config) return;
//
// setIsLoading(true);
// const session = await createSession({
// config,
// meta,
// });
// setIsLoading(false);
// message.success(t('assistants.addAgentSuccess'));
// router.push(SESSION_CHAT_URL(session, mobile));
// };
const handleAddAssistant = async () => {
if (!agent?.config) return;
const { config } = agent;
const meta = {
avatar: agent.avatar,
backgroundColor: agent.backgroundColor,
description: agent.description,
tags: agent.tags,
title: agent.title,
};
setIsAdding(true);
try {
// 添加到会话列表
const session = await createSession(
{
config,
meta,
},
false,
);
toggleDrawer();
InteractionManager.runAfterInteractions(() => {
// 导航到会话页面
router.replace({
params: { session: session },
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 (
<PageContainer
showBack
style={styles.safeAreaContainer}
title={t('assistant.detail.title', { ns: 'discover' })}
>
<ScrollView style={styles.scrollContainer}>
<SkeletonDetail />
</ScrollView>
</PageContainer>
);
}
if (error || !agent) {
return (
<PageContainer
showBack
style={styles.errorContainer}
title={t('assistant.detail.title', { ns: 'discover' })}
>
<Text style={styles.errorText}>
{!identifier
? t('assistant.detail.notFoundIdentifier', { ns: 'discover' })
: t('assistant.detail.loadFailed', { ns: 'discover' })}
</Text>
</PageContainer>
);
}
// 获取系统角色内容(可能存在于不同的位置)
const systemRoleContent = agent.config.systemRole;
return (
<PageContainer
showBack
style={styles.safeAreaContainer}
title={t('assistant.detail.title', { ns: 'discover' })}
>
<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.avatar || '🤖'}
createdAt={agent.createdAt}
title={agent.title}
/>
{/* 描述信息 */}
<Text style={styles.description}>{agent.description}</Text>
{/* 标签列表 */}
{agent.tags && agent.tags.length > 0 && (
<View style={styles.tagsContainer}>
{agent.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>
</View>
{/* 系统提示区域 */}
{systemRoleContent && (
<View style={styles.systemRoleContainer}>
<View style={styles.settingsTitleContainer}>
<Icon icon={BotMessageSquare} />
<Text style={styles.systemRoleTitle}>
{t('assistant.detail.assistantSettings', { ns: 'discover' })}
</Text>
</View>
<View style={styles.systemRoleContentContainer}>
<Markdown>{systemRoleContent}</Markdown>
</View>
</View>
)}
</View>
</ScrollView>
</PageContainer>
);
};
export default AssistantDetail;
@@ -1,150 +0,0 @@
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',
gap: token.marginXXS,
marginBottom: token.marginLG,
},
}));
@@ -1,199 +0,0 @@
import { AssistantCategory, DiscoverAssistantItem } from '@lobechat/types';
import { CapsuleTabs, Input, PageContainer } from '@lobehub/ui-rn';
import { useDebounce } from 'ahooks';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, FlatList, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AgentCard from '@/features/discover/assistant/components/AgentCard';
import {
AssistantListSkeleton,
CategoryTabsSkeleton,
} from '@/features/discover/assistant/components/SkeletonList';
import useCategory from '@/features/discover/assistant/hooks/useCategory';
import { useDiscoverStore } from '@/store/discover';
import { useStyles } from './styles';
const INITIAL_PAGE_SIZE = 21;
const AssistantList = () => {
const { styles, theme } = useStyles();
const { t } = useTranslation(['common', 'discover']);
const [searchText, setSearchText] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>(AssistantCategory.All);
const [currentPage, setCurrentPage] = useState(1);
const [allItems, setAllItems] = useState<DiscoverAssistantItem[]>([]);
const [hasMoreData, setHasMoreData] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const categories = useCategory();
const debouncedSearchText = useDebounce(searchText, { wait: 500 });
const finalSearchQuery = useMemo(() => {
if (searchQuery !== '') return searchQuery;
if (searchText === '') return '';
return debouncedSearchText;
}, [searchQuery, searchText, debouncedSearchText]);
const useAssistantCategories = useDiscoverStore((s) => s.useAssistantCategories);
const { data: categoryStats = [], isLoading: isCategoryLoading } = useAssistantCategories({
q: finalSearchQuery,
});
const categoriesWithStats = useMemo(() => {
const total = categoryStats.reduce((acc, item) => acc + item.count, 0);
return categories.map((category) => {
const itemData = categoryStats.find((stat) => stat.category === category.key);
return {
...category,
count: category.key === AssistantCategory.All ? total : itemData?.count || 0,
};
});
}, [categories, categoryStats]);
const queryParams = useMemo(
() => ({
category: selectedCategory === AssistantCategory.All ? undefined : selectedCategory,
page: currentPage,
pageSize: INITIAL_PAGE_SIZE,
q: finalSearchQuery || undefined,
}),
[finalSearchQuery, selectedCategory, currentPage],
);
const useAssistantList = useDiscoverStore((s) => s.useAssistantList);
const { data: agents, error, isLoading } = useAssistantList(queryParams);
const handleImmediateSearch = useCallback(
(query?: string) => {
const searchValue = query !== undefined ? query : searchText;
setSearchQuery(searchValue);
setTimeout(() => setSearchQuery(''), 100);
},
[searchText],
);
const handleSearchSubmit = useCallback(() => {
handleImmediateSearch();
}, [handleImmediateSearch]);
const handleCategorySelect = useCallback((key: string) => {
setSelectedCategory(key);
}, []);
useEffect(() => {
setCurrentPage(1);
setAllItems([]);
setHasMoreData(true);
setIsLoadingMore(false);
}, [finalSearchQuery, selectedCategory]);
// 处理新数据
useEffect(() => {
if (agents?.items) {
if (currentPage === 1) {
// 重置数据
setAllItems(agents.items);
} else {
// 追加数据
setAllItems((prev) => [...prev, ...agents.items]);
}
// 检查是否还有更多数据
setHasMoreData(agents.items.length === INITIAL_PAGE_SIZE);
setIsLoadingMore(false);
}
}, [agents, currentPage]);
// 处理加载更多
const handleLoadMore = useCallback(() => {
if (!isLoadingMore && hasMoreData && !isLoading) {
setIsLoadingMore(true);
setCurrentPage((prev) => prev + 1);
}
}, [isLoadingMore, hasMoreData, isLoading]);
// 渲染底部加载指示器
const renderFooter = useCallback(() => {
if (!hasMoreData) return null;
return (
<View style={{ alignItems: 'center', padding: 20 }}>
<ActivityIndicator color={theme.colorPrimary} size="small" />
</View>
);
}, [hasMoreData, theme.colorPrimary]);
const renderEmptyComponent = useCallback(
() => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{finalSearchQuery
? t('assistant.noMatch', { ns: 'common' })
: t('assistant.noData', { ns: 'common' })}
</Text>
</View>
),
[finalSearchQuery],
);
const renderItem = useCallback(
({ item }: { item: DiscoverAssistantItem }) => <AgentCard item={item} />,
[],
);
const keyExtractor = useCallback((item: DiscoverAssistantItem) => item.identifier, []);
if (error) {
return (
<SafeAreaView style={styles.errorContainer}>
<Text style={styles.errorText}>{t('assistant.fetchError', { ns: 'common' })}</Text>
</SafeAreaView>
);
}
return (
<PageContainer showBack style={styles.safeAreaContainer} title={t('title', { ns: 'discover' })}>
<View style={styles.filterContainer}>
<Input.Search
onChangeText={setSearchText}
onSubmitEditing={handleSearchSubmit}
placeholder={t('assistant.search', { ns: 'common' })}
size="large"
style={styles.searchContainer}
/>
{isCategoryLoading ? (
<CategoryTabsSkeleton />
) : (
<CapsuleTabs
items={categoriesWithStats}
onSelect={handleCategorySelect}
selectedKey={selectedCategory}
size="large"
/>
)}
</View>
{isLoading && currentPage === 1 ? (
<AssistantListSkeleton />
) : (
<FlatList
ListEmptyComponent={renderEmptyComponent}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContainer}
data={allItems}
keyExtractor={keyExtractor}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
renderItem={renderItem}
/>
)}
</PageContainer>
);
};
export default AssistantList;
@@ -1,79 +0,0 @@
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: {
paddingHorizontal: 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: {
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,
},
}));
-15
View File
@@ -1,15 +0,0 @@
import { Redirect } from 'expo-router';
import { useAuth } from '@/store/user';
export default function Page() {
const { isAuthenticated, isInitialized } = useAuth();
// 只有在认证完全初始化后才进行导航
if (isInitialized && isAuthenticated) {
return <Redirect href="/chat" />;
}
// 在认证状态未确定时不渲染任何内容,让 SplashScreen 继续显示
return null;
}
-15
View File
@@ -1,15 +0,0 @@
import { Stack } from 'expo-router';
import { useThemedScreenOptions } from '@/_const/navigation';
export default function RoutesLayout() {
const themedScreenOptions = useThemedScreenOptions();
return (
<Stack
screenOptions={{
...themedScreenOptions,
headerShown: false,
}}
/>
);
}
@@ -1,33 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import {
BasicDemo,
ColorsDemo,
DisabledDemo,
LoadingDemo,
SizesDemo,
VariantsDemo,
} from '@lobehub/ui-rn/ActionIcon/demos';
import README from '@lobehub/ui-rn/ActionIcon/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <ColorsDemo />, key: 'colors', title: '颜色' },
{ component: <VariantsDemo />, key: 'variants', title: '不同视觉风格' },
{ component: <SizesDemo />, key: 'sizes', title: '尺寸' },
{ component: <LoadingDemo />, key: 'loading', title: '加载状态' },
{ component: <DisabledDemo />, key: 'disabled', title: '禁用状态' },
];
export default function ActionIconPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="ActionIcon 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,23 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, BordersDemo, ErrorDemo, SizesDemo } from '@lobehub/ui-rn/Avatar/demos';
import README from '@lobehub/ui-rn/Avatar/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
{ component: <BordersDemo />, key: 'borders', title: '边框样式' },
{ component: <ErrorDemo />, key: 'error', title: '错误处理' },
];
export default function AvatarPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Avatar 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,19 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, ClickableDemo, LayoutDemo } from '@lobehub/ui-rn/Block/demos';
import README from '@lobehub/ui-rn/Block/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <ClickableDemo />, key: 'clickable', title: '可点击状态' },
{ component: <LayoutDemo />, key: 'layout', title: '布局示例' },
];
export default function BlockPlaygroundPage() {
return (
<PageContainer showBack title="Block 块容器">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,38 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import {
BasicDemo,
BlockDemo,
DangerDemo,
DisabledDemo,
IconDemo,
LoadingDemo,
ShapeDemo,
SizesDemo,
VariantColorDemo,
} from '@lobehub/ui-rn/Button/demos';
import README from '@lobehub/ui-rn/Button/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
{ component: <DisabledDemo />, key: 'disabled', title: '禁用状态' },
{ component: <BlockDemo />, key: 'block', title: '块级按钮' },
{ component: <IconDemo />, key: 'icon', title: '图标按钮' },
{ component: <ShapeDemo />, key: 'shape', title: '按钮形状' },
{ component: <DangerDemo />, key: 'danger', title: '危险态按钮' },
{ component: <VariantColorDemo />, key: 'variant-color', title: '变体与颜色' },
{ component: <LoadingDemo />, key: 'loading', title: '加载状态' },
];
export default function ButtonPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Button 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,23 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, IconsDemo, ScrollingDemo, SizesDemo } from '@lobehub/ui-rn/CapsuleTabs/demos';
import README from '@lobehub/ui-rn/CapsuleTabs/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <SizesDemo />, key: 'sizes', title: '尺寸大小' },
{ component: <IconsDemo />, key: 'icons', title: '图标组合' },
{ component: <ScrollingDemo />, key: 'scrolling', title: '水平滚动' },
];
export default function CapsuleTabsPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="CapsuleTabs 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,18 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, WithCoverDemo } from '@lobehub/ui-rn/Card/demos';
import README from '@lobehub/ui-rn/Card/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础卡片' },
{ component: <WithCoverDemo />, key: 'with-cover', title: '带封面卡片' },
];
export default function CardPlaygroundPage() {
return (
<PageContainer showBack title="Card 卡片">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,15 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo } from '@lobehub/ui-rn/Center/demos';
import README from '@lobehub/ui-rn/Center/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
export default function CenterPlaygroundPage() {
return (
<PageContainer showBack title="Center 居中组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,22 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, FullDemo, TokenDemo } from '@lobehub/ui-rn/ColorScales/demos';
import README from '@lobehub/ui-rn/ColorScales/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础演示' },
{ component: <FullDemo />, key: 'full', title: '完整色板' },
{ component: <TokenDemo />, key: 'token', title: 'Token 使用' },
];
export default function ColorScalesPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="ColorScales 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,21 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { AdvancedDemo, BasicDemo } from '@lobehub/ui-rn/ColorSwatches/demos';
import README from '@lobehub/ui-rn/ColorSwatches/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础演示' },
{ component: <AdvancedDemo />, key: 'advanced', title: '高级演示' },
];
export default function ColorSwatchesPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="ColorSwatches 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,15 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo } from '@lobehub/ui-rn/Flexbox/demos';
import README from '@lobehub/ui-rn/Flexbox/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
export default function FlexboxPlaygroundPage() {
return (
<PageContainer showBack title="FlexBox 弹性布局">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,23 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, ComparisonDemo, SizesDemo, TypeDemo } from '@lobehub/ui-rn/FluentEmoji/demos';
import README from '@lobehub/ui-rn/FluentEmoji/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
{ component: <ComparisonDemo />, key: 'comparison', title: '3D vs 原始' },
{ component: <TypeDemo />, key: 'type', title: '不同类型' },
];
export default function FluentEmojiPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="FluentEmoji 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,25 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, RequiredMarkDemo, UseFormDemo, ValidatorDemo } from '@lobehub/ui-rn/Form/demos';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import README from '@/components/Form/README';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <UseFormDemo />, key: 'use-form', title: '使用 Form.useForm' },
{ component: <RequiredMarkDemo />, key: 'required-mark', title: '必填标记' },
{ component: <ValidatorDemo />, key: 'validator', title: '自定义校验' },
];
export default function FormPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Form 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,28 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import {
BasicHighlighterDemo,
CompactHighlighterDemo,
FullFeaturedHighlighterDemo,
LanguagesHighlighterDemo,
} from '@lobehub/ui-rn/Highlighter/demos';
import README from '@lobehub/ui-rn/Highlighter/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicHighlighterDemo />, key: 'basic', title: '基础高亮' },
{ component: <FullFeaturedHighlighterDemo />, key: 'fullFeatured', title: '完整功能' },
{ component: <CompactHighlighterDemo />, key: 'compact', title: '紧凑型' },
{ component: <LanguagesHighlighterDemo />, key: 'languages', title: '多语言' },
];
export default function HighlighterPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Highlighter 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,24 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, ColorsDemo, SizesDemo, SpinDemo } from '@lobehub/ui-rn/Icon/demos';
import README from '@lobehub/ui-rn/Icon/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <ColorsDemo />, key: 'colors', title: '颜色' },
{ component: <SizesDemo />, key: 'sizes', title: '尺寸' },
{ component: <SpinDemo />, key: 'spin', title: '旋转动画' },
];
export default function IconPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Icon 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,39 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import {
BasicDemo,
CompoundDemo,
PasswordDemo,
PrefixDemo,
SearchDemo,
SizesDemo,
SuffixDemo,
TextAreaDemo,
VariantDemo,
} from '@lobehub/ui-rn/Input/demos';
import README from '@lobehub/ui-rn/Input/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <PrefixDemo />, key: 'prefix', title: '带前缀' },
{ component: <SuffixDemo />, key: 'suffix', title: '带后缀' },
{ component: <SearchDemo />, key: 'search', title: '搜索输入框' },
{ component: <PasswordDemo />, key: 'password', title: '密码输入框' },
{ component: <CompoundDemo />, key: 'compound', title: '复合组件' },
{ component: <VariantDemo />, key: 'variant', title: '外观变体' },
{ component: <TextAreaDemo />, key: 'textarea', title: '多行文本(autoSize' },
{ component: <SizesDemo />, key: 'sizes', title: '尺寸大小' },
];
export default function InputPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Input 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,22 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo, SizesDemo, StatesDemo } from '@lobehub/ui-rn/InstantSwitch/demos';
import README from '@lobehub/ui-rn/InstantSwitch/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <SizesDemo />, key: 'sizes', title: '不同尺寸' },
{ component: <StatesDemo />, key: 'states', title: '状态演示' },
];
export default function InstantSwitchPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="InstantSwitch 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,28 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import {
AdvancedDemo,
AvatarsDemo,
BasicDemo,
NavigationDemo,
} from '@lobehub/ui-rn/ListItem/demos';
import README from '@lobehub/ui-rn/ListItem/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <AvatarsDemo />, key: 'avatars', title: '头像类型' },
{ component: <NavigationDemo />, key: 'navigation', title: '导航交互' },
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
];
export default function ListItemPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="ListItem 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,18 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import { BasicDemo } from '@lobehub/ui-rn/Markdown/demos';
import README from '@lobehub/ui-rn/Markdown/readme';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
export default function MarkdownPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Markdown 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,32 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import {
AnimatedDemo,
AvatarDemo,
BasicDemo,
ComplexDemo,
CompoundDemo,
ParagraphDemo,
} from '@lobehub/ui-rn/Skeleton/demos';
import README from '@lobehub/ui-rn/Skeleton/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <AnimatedDemo />, key: 'animated', title: '动画效果' },
{ component: <AvatarDemo />, key: 'avatar', title: '头像骨架屏' },
{ component: <ParagraphDemo />, key: 'paragraph', title: '段落骨架屏' },
{ component: <CompoundDemo />, key: 'compound', title: '复合组件' },
{ component: <ComplexDemo />, key: 'complex', title: '复杂示例' },
];
export default function SkeletonPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Skeleton 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,23 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import { BasicDemo, ControlledDemo, MarksDemo, RangeDemo } from '@lobehub/ui-rn/Slider/demos';
import README from '@lobehub/ui-rn/Slider/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <RangeDemo />, key: 'range', title: '不同范围' },
{ component: <ControlledDemo />, key: 'controlled', title: '受控模式' },
{ component: <MarksDemo />, key: 'marks', title: '刻度标记' },
];
export default function SliderPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Slider 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,30 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import {
AdvancedDemo,
AlignmentDemo,
BasicDemo,
DirectionsDemo,
SizesDemo,
} from '@lobehub/ui-rn/Space/demos';
import README from '@lobehub/ui-rn/Space/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <DirectionsDemo />, key: 'directions', title: '方向' },
{ component: <SizesDemo />, key: 'sizes', title: '间距大小' },
{ component: <AlignmentDemo />, key: 'alignment', title: '对齐方式' },
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
];
export default function SpacePlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Space 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,8 +0,0 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -1,18 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import { BasicDemo } from '@lobehub/ui-rn/Switch/demos';
import README from '@lobehub/ui-rn/Switch/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [{ component: <BasicDemo />, key: 'basic', title: '基础用法' }];
export default function SwitchPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Switch 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,30 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import {
BasicDemo,
BorderDemo,
ColorsDemo,
PresetDemo,
UseCaseDemo,
} from '@lobehub/ui-rn/Tag/demos';
import README from '@lobehub/ui-rn/Tag/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <ColorsDemo />, key: 'colors', title: '颜色样式' },
{ component: <PresetDemo />, key: 'preset', title: '预设颜色' },
{ component: <BorderDemo />, key: 'border', title: '无边框' },
{ component: <UseCaseDemo />, key: 'usecase', title: '实际应用' },
];
export default function TagPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Tag 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,51 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import README from '@/theme/ThemeProvider/README';
import {
BasicDemo,
CustomAlgorithmDemo,
CustomTokenAndAlgorithmDemo,
CustomTokenDemo,
MultipleAlgorithmsDemo,
} from '@/theme/ThemeProvider/demos';
import { useStyles } from './style';
const themeProviderDemos: DemoItem[] = [
{
component: <BasicDemo />,
key: 'basic',
title: '基础用法',
},
{
component: <CustomTokenDemo />,
key: 'customToken',
title: '自定义 Token',
},
{
component: <CustomAlgorithmDemo />,
key: 'customAlgorithm',
title: '自定义算法',
},
{
component: <CustomTokenAndAlgorithmDemo />,
key: 'customTokenAndAlgorithm',
title: 'Token + 算法',
},
{
component: <MultipleAlgorithmsDemo />,
key: 'multipleAlgorithms',
title: '多算法组合',
},
];
export default function ThemeProviderPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="ThemeProvider 组件">
<ComponentPlayground demos={themeProviderDemos} readmeContent={README} />
</PageContainer>
);
}
@@ -1,5 +0,0 @@
import ThemeTokensPlayground from '@lobehub/ui-rn/theme/theme-token';
export default function ThemeTokensPlaygroundPage() {
return <ThemeTokensPlayground />;
}
@@ -1,30 +0,0 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import {
AdvancedDemo,
BasicDemo,
IntegrationDemo,
StaticDemo,
TypesDemo,
} from '@lobehub/ui-rn/Toast/demos';
import README from '@lobehub/ui-rn/Toast/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <TypesDemo />, key: 'types', title: '类型演示' },
{ component: <StaticDemo />, key: 'static', title: '静态方法' },
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
{ component: <IntegrationDemo />, key: 'integration', title: '集成示例' },
];
export default function ToastPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Toast 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}

Some files were not shown because too many files have changed in this diff Show More