Compare commits

...

102 Commits

Author SHA1 Message Date
rdmclin2 700ed2b0bb feat: add custom server 2025-10-10 21:32:39 +08:00
rdmclin2 3349b6ad44 fix: ts error 2025-10-10 21:10:53 +08:00
rdmclin2 2191a83075 feat: add Card component 2025-10-10 21:08:47 +08:00
rdmclin2 b584985767 feat: add developer custom server and Form Components 2025-10-10 19:32:01 +08:00
rdmclin2 df78753ab3 chore: adjust fade effect 2025-10-10 14:38:54 +08:00
rdmclin2 21fded5d95 feat: CapsuleTabs support facade effect 2025-10-10 14:35:16 +08:00
rdmclin2 99411fd48e chore: support inbox search 2025-10-10 14:18:51 +08:00
rdmclin2 ea82260edb chore: remove group header 2025-10-10 14:10:03 +08:00
rdmclin2 d96cd6045f feat: add NewAgentButton 2025-10-10 12:11:52 +08:00
rdmclin2 bc2903a6e5 fix: i18n problems 2025-10-09 22:16:02 +08:00
rdmclin2 96f4e12347 chore: update i18n 2025-10-09 22:06:35 +08:00
rdmclin2 3affb884dd fix: SideBar Footer align 2025-10-09 21:23:56 +08:00
Tsuki 61ae82fca8 fix(mobile): resolve drawer white border flickering issue 2025-10-06 00:43:09 +08:00
Tsuki 81ab5168c4 chore(mobile): optimized renderContent component 2025-10-04 21:36:16 +08:00
Tsuki 23a1efff46 style(mobile): text area font size 2025-10-04 21:35:39 +08:00
Tsuki ee0b1775a1 fix(mobile): monorepo production build script 2025-10-04 21:03:52 +08:00
rdmclin2 3e1280a697 chore: trigger deployment [deploy] 2025-09-29 22:23:48 +08:00
Rdmclin2 d0ef24e2c5 chore: performance issues (#9471) 2025-09-29 22:05:44 +08:00
Tsuki 046a0d2bd7 fix(mobile): adjust zIndex and positioning for page container 2025-09-29 01:20:26 +08:00
Tsuki b65ffdb39d fix(mobile): text area hot zone & style issues 2025-09-29 00:48:17 +08:00
canisminor1990 9146d1d0a4 fix: fix demo 2025-09-26 17:44:19 +08:00
Tsuki c75c5d0c4c fix(mobile): choose agent stuck app (#9440)
fix(mobile): resolve agent selection stuck app
2025-09-26 17:29:22 +08:00
canisminor1990 6af5790f3d 🐛 fix: Fix jest type 2025-09-26 13:56:19 +08:00
canisminor1990 0ac4857091 🐛 fix: Fix some type error 2025-09-26 13:42:37 +08:00
canisminor1990 42cdd46424 🔧 chore: Update @lobehub/ui-rn alias 2025-09-26 11:58:30 +08:00
Tsuki 1df0e65f8d refactor(mobile): metro fix & use new module structure & build error & chatV2 2025-09-26 01:04:16 +08:00
Rdmclin2 7794a077da feat: support context menu bubble and fix highligter scroll (#9425)
* feat: support ContextMenu for user bubble

* chore: rename TooltipActions

* chore: add back TooltipActions

* fix: lint error

* fix: Highligher scroll fails in Mardown with Tooltip wraps

* chore: update Input TextArea style

* fix: Header import

* chore: add back lockfile
2025-09-25 20:54:01 +08:00
canisminor1990 3853e36717 🐛 fix: Fix cva 2025-09-25 20:54:01 +08:00
canisminor1990 4aa767f576 🐛 fix: Fix createStyles 2025-09-25 20:54:01 +08:00
canisminor1990 56104c9315 💄 style: Fix demo 2025-09-25 20:54:01 +08:00
canisminor1990 688ce5fadf 🐛 fix: Fix new components in playgroun 2025-09-25 20:54:01 +08:00
canisminor1990 fbaeac2d1e fix: fix export 2025-09-25 20:54:01 +08:00
canisminor1990 7ed1b00496 fix: fix mono fonts 2025-09-25 20:54:01 +08:00
canisminor1990 08773dbc03 style: update fonts 2025-09-25 20:54:01 +08:00
canisminor1990 0361c05a8f fix: fix createStyles 2025-09-25 20:54:01 +08:00
canisminor1990 c90e92c5f1 chore: update codemod 2025-09-25 20:54:01 +08:00
Rdmclin2 065c38273a fix: style issues and refact components (#9404)
* fix: refresh token lost

* chore: refactor TextInput to Input

* feat: add InputText and CapsuleTabs support icon prop

* feat: CapsuleTabs support different sizes

* chore: unfied component import in demos

* chore: adjust Playground style

* style: adjust drawer style

* style: adjust Header style

* feat: add PageContainer components and refactor pages

* chore: migrate right to extra

* chore: adjust WelcomeBubble to ChatBubble

* chore: adjust Button loading effect

* chore: adjust StopLoadingButton style

* chore: adjust StopLoading Button style

* chore: update Agents.md

* fix: ChatBubble update with fontSize

* feat: Highlighter support scroll horizontally

* chore: remove ToolTipActions for AI Bubble
2025-09-25 20:54:01 +08:00
Rdmclin2 4753fe5d6d feat: development mode & chatInput style fix (#9375)
* chore: migrate discover to features

* chore: add Skeleton Button component and optimize Detail loading

* chore: adjust chat skeleton list

* chore: adjust keyboard offset

* chore: adjust keybord vertical offset

* fix: icon color

* fix: primary color bright

* chore: update Button icon size and i18n files

* fix: highlight component not respond to theme change

* chore: unified icon size

* fix: ai chatinput loading

* chore: remove forced consent

* chore: adjust Textinput size

* fix: input placeholder

* chore: adjust ChatInput style

* chore: remove style

* fix: ChatInput style

* chore: adjust styles

* feat: support developer mode

* chore: update i18n
2025-09-25 20:54:00 +08:00
Rdmclin2 f14f09abe3 fix: compacity problems (#9335)
* chore: migrate discover to features

* chore: add Skeleton Button component and optimize Detail loading

* chore: adjust chat skeleton list

* chore: adjust keyboard offset

* chore: adjust keybord vertical offset

* fix: icon color

* fix: primary color bright

* chore: update Button icon size and i18n files

* fix: highlight component not respond to theme change

* chore: unified icon size

* fix: ai chatinput loading

* chore: remove forced consent

* chore: adjust Textinput size

* fix: input placeholder

* chore: adjust ChatInput style

* chore: remove style

* fix: ChatInput style
2025-09-25 20:54:00 +08:00
rdmclin2 c117d077d6 chore: adjust assistant skeleton style 2025-09-25 20:54:00 +08:00
rdmclin2 1bfbe451fa fix: Discover list and detail page loading 2025-09-25 20:54:00 +08:00
Rdmclin2 1c450b22f6 chore: optimize style and add ActionIcon and icon components (#9309)
* chore: add disabled demo

* fix: button style & remove hover tokens

* chore: Markdown 字体使用默认 16 起步

* chore: unified inbox avatar

* fix: setting foooter press to setting page

* chore: adjust token colorTextLightSolid

* fix: adjust message content padding

* chore: optimize chatlist style

* chore: adjust ChatInput icons

* feat: add ActionIcon component

* feat: add ActionIcon and Icon component

* fix: icon size and style

* fix: drawer lint error

* fix: style

* fix: app submit bug (#9289)

* chore: add disabled demo

* fix: button style & remove hover tokens

* chore: Markdown 字体使用默认 16 起步

* chore: unified inbox avatar

* fix: setting foooter press to setting page

* chore: adjust token colorTextLightSolid

* fix: adjust message content padding

* chore: optimize chatlist style

* chore: adjust ChatInput icons

* feat(mobile): add programming global loading (#9303)

feat(mobile): programming loading

* chore: fix merge error

---------

Co-authored-by: Tsuki <76603360+sudongyuer@users.noreply.github.com>
2025-09-25 20:54:00 +08:00
Tsuki e438d0dcd8 feat(mobile): add programming global loading (#9303)
feat(mobile): programming loading
2025-09-25 20:54:00 +08:00
Rdmclin2 3de309c37a fix: app submit bug (#9289)
* chore: add disabled demo

* fix: button style & remove hover tokens

* chore: Markdown 字体使用默认 16 起步

* chore: unified inbox avatar

* fix: setting foooter press to setting page

* chore: adjust token colorTextLightSolid

* fix: adjust message content padding

* chore: optimize chatlist style

* chore: adjust ChatInput icons
2025-09-25 20:54:00 +08:00
Rdmclin2 a13f19d87a feat: supoort button shape, color and varient (#9271)
* chore: update .env.example

* chore: add icon demos

* feat: support shape and dashed type

* feat: add varient and color props

* chore: migrate playgroud to features floder

* chore: migrate some components from features to components

* chore: migrage playground demos

* fix: theme-provider demo error

* fix: button demos

* fix: button variant  type error

* feat: add  button loading spin
2025-09-25 20:54:00 +08:00
Tsuki 0b7e30bd22 fix(mobile): app crash (#9259) 2025-09-25 20:54:00 +08:00
Tsuki a1bd36a367 fix(mobile): add haptics effect (#9256) 2025-09-25 20:54:00 +08:00
Tsuki d0382ab4a1 fix(mobile): input multiple line issue (#9258)
fix(mobile): text input multiline issue
2025-09-25 20:54:00 +08:00
Tsuki 3fb71d28b9 fix(mobile): ui stuck issue 2025-09-25 20:54:00 +08:00
Tsuki 7d9b963790 fix(mobile): remove suspense fetch option 2025-09-25 20:54:00 +08:00
Tsuki 40d1bdc98c fix(mobile): ts error 2025-09-25 20:54:00 +08:00
Rdmclin2 0b0220ea51 chore: prepare for build (#9204)
* chore: update .env.example

* fix: mobile app auth

* chore: adjust fontSize setting page style

* chore: update fontSize page

* fix: webworker not supported

* fix: dependency

* chore: remove update for now

* chore: dynamic loading locales

* feat: dynamic locale load

* chore: remove updates enable false
2025-09-25 20:54:00 +08:00
Tsuki 6fee0f1a6e chore(mobile): optimized chatlist & auto scroll (#9192)
* chore(mobile): improve chat list

* fix(mobile): send message auto scroll
2025-09-25 20:54:00 +08:00
Tsuki 88f1a91761 fix(mobile): open ai provider proxy url setting 2025-09-25 20:54:00 +08:00
Rdmclin2 709fd293a6 Fix/mobile app oauth (#9174)
* chore: update .env.example

* fix: mobile app auth
2025-09-25 20:54:00 +08:00
Tsuki 50b7189061 fix(mobile): chat index 2025-09-25 20:54:00 +08:00
Tsuki 7b32c1eee3 chore(mobile): support jump to provider detail 2025-09-25 20:54:00 +08:00
Tsuki cce81be013 chore(mobile): update i18n 2025-09-25 20:54:00 +08:00
Tsuki 6c1174427b Feat/update keyboard input model switch icon (#9169)
* chore(mobile): update deps

* chore(mobile): update chat header style

* chore(mobile): update chat keyboard avoiding view

* chore(mobile): update provider item provider icon

* chore(mobile): support redirect to provider route

* chore(mobile): optimized keyboard & scroll view ux
2025-09-25 20:54:00 +08:00
rdmclin2 c9eb5af95c fix: module resolution 2025-09-25 20:54:00 +08:00
rdmclin2 301ecbd457 chore: update pnpm lockfile 2025-09-25 20:54:00 +08:00
rdmclin2 d7d6aa827a chore: update package.json 2025-09-25 20:54:00 +08:00
rdmclin2 77fae1cf97 fix: AutoScroll logic 2025-09-25 20:53:59 +08:00
rdmclin2 497033e17d chore: adjust ChatList styles 2025-09-25 20:53:59 +08:00
rdmclin2 854f8903cf chore: add AutoScroll Component 2025-09-25 20:53:59 +08:00
rdmclin2 9c295b79f1 chore: 调整 ChatInput 样式 2025-09-25 20:53:59 +08:00
rdmclin2 c40a1a6329 fix: ScrollToBottom problem 2025-09-25 20:53:59 +08:00
rdmclin2 7dfbccfa55 chore: add back ScrollToBottom 2025-09-25 20:53:59 +08:00
rdmclin2 bf434c73b5 fix: chat bubble style 2025-09-25 20:53:59 +08:00
rdmclin2 1a63bfd258 chore: remove chat page animation and optimize slide gesture 2025-09-25 20:53:59 +08:00
Tsuki a02960d928 fix(mobile): keyboard avoiding 2025-09-25 20:53:59 +08:00
Rdmclin2 9637ce846e chore: compatibility issues (#9142)
* chore: adjust Android TextInput Style

* feat: 封装 TextInput 组件

* feat: add TextInput Component

* chore: replace Playground and Sidebar TextInput

* feat: add TextInput.Search and Password, suffix and replace inplace

* chore: 重构项目 TextInput 使用

* feat: TextInput support varient props

* chore: adjust Android TextInput Style

* feat: 封装 TextInput 组件

* feat: add TextInput Component

* chore: replace Playground and Sidebar TextInput

* feat: add TextInput.Search and Password, suffix and replace inplace

* chore: 重构项目 TextInput 使用

* feat: TextInput support varient props

* chore: 迁移代码到  features 目录中

* chore: 调整 provider 页面 fontSize

* fix: Slider drag problem

* fix: duplicate login redirect

* chore: migrate chat code to features

* chore: update error i18n key

* chore: update i18n files

* feat: add ui_locales parameters to oidc provider

* chore: support cookie write in middleware

* chore: 一次性写入 cookies

* chore: remove oauth

* fix: Android notfound page problem

* fix: ts error

* fix: chat setting page scroll

* fix: chat and sidebar inset problem

* fix: chat bubble style

* chore: adjust chat list
2025-09-25 20:53:59 +08:00
Tsuki dfb2ab5f8c feat(mobile): connection check & error message & performance optimization & style change (#9111)
* chore: update

* chore(mobile): update deps

* chore(mobile): update tsconfig.json

* fix(mobile): fetchSSE.ts

* chore(mobile): update chat slice

* chore(mobile): update AgentRoleEditSection.tsx

* chore(mobile): update const

* chore(mobile: update layout

* chore(mobile): update providers flash list

* chore(mobile): update const

* chore(mobile): update tsconfig.json

* fix(mobile): chat services

* chore(mobile): update providers/[id]

* chore(mobile): update chat input sticky view

* chore(mobile): update setting

* chore(mobile): chat list

* fix(mobile): chat input keyboard view

* feat(mobile): add error content

* feat(mobile): add avatar

* chore(mobile): update i18n

* style(mobile): configuration
2025-09-25 20:53:59 +08:00
Rdmclin2 3e0a0bd184 chore: optimize performance and style (#9057)
* chore: update developer setting

* chore:  optimize Skeleton sub component

* feat: add list marker style

* chore: update setting page

* chore: remove unnecessary env variable

* fix: Theme Preview borderRadius

* fix: remove opaccity acitve effect from SettingItem

* feat: seperate themeMode setting page

* feat: seperate fontSize setting page

* faet: support adjust chatbubble fontSize

* feat: add fontSize Preview page

* fix: unified switch track and thumb color and wrap  Switch component

* chore: add switch demo page

* fix: navigation and status bar color problem

* chore: add unified header and safeview

* chore: add settings page  SafeAreaView

* fix: provider page style

* chore: add safeareaview to all pages
2025-09-25 20:53:59 +08:00
Tsuki 81c36dedb7 fix(mobile): i18n 2025-09-25 20:53:59 +08:00
Tsuki affa02eb26 fix(mobile): lock 2025-09-25 20:53:59 +08:00
Rdmclin2 ae365ef6be chore: optimize mobile experience and i18n (#9026)
* chore: modify Markdown styles and  Tag style

* feat: Tag support color prop

* feat: Tag Component support preset color

* chore: 优化 playground Card style

* feat: migrate to remark

* feat: add Remark components

* feat: Toast 支持静态方法

* feat: animate loading message and simplify Toast props

* chore: optimize Toast implementation

* chore: remove unnecessary custtom renderers

* chore: optimize Markdown Render and add Slider Component

* chore: unified api with web version

* chore: remove unnecessary props for slider

* fix: accurately control step slider

* feat: support marks props

* fix: 修复 mark dot 位置

* chore: optimize Slider token usage

* feat: setting support fontSize

* chore: Playground support fontSize setting

* fix: Slider 样式

* chore: prevent slider change

* chore: update i18n keys

* chore: optimize theme provider tokens

* chore: remove unnecessary code

* fix: slider demo

* fix: theme-token type error

* fix: auth expired handle

* feat: handle auth expired redirect

* feat: add developer setting

* fix: login expired handle

* feat: add SettingGroup to automatically add isLast prop and add prevew page

* chore: adjust Preview component

* feat: move theme settings to a seperate page

* feat: 优化调整 Markdown 样式

* fix: list style

* chore: adjust markdown table style

* fix: blockquoto style

* chore: remove Markdown type

* chore: 迁移 TokenDisplay

* chore: optimize Highlight display

* feat: support katex render

* feat(Skeleton): add Skeleton.Image compound component (animated, width/height/shape); align styles with existing shimmer. Update defaults to 200x200; no visual regressions

* feat(Skeleton.Image): center antd-like image placeholder icon (react-native-svg); add iconSize/iconColor/showIcon props

* feat: Image support skeleton

* chore: optimize mathjax

* chore: handle tokens globally

* feat: Button support icon property

* chore: update provider i18n

* chore: header support ellipsis

* chore: update preview i18n

* chore: adjust button colors

* chore: update i18n
2025-09-25 20:53:59 +08:00
Tsuki 8eab665505 feat: role edit & session context menu & expo 53 upgrade (#9024)
* chore(mobile): update welcome chat bubble

* chore(mobile): update generateAIChat toast

* chore(mobile): update

* chore(mobile): update config auth.ts

* fix(mobile): i18n

* style(mobile): list item style.ts

* fix(mobile): account logout not redirect

* feat(mobile): agent role edit

* chore(mobile): remove session & optimized

* chore(mobile): update configuration section

* chore(mobile): update

* chore(mobile): update agent role edit section

* fix(mobile): android build error

* chore(mobile): upgrade expo53 & update deps

* fix(mobile): add GestureHandlerRootView
2025-09-25 20:53:59 +08:00
Rdmclin2 3a45a383eb Feat/setting screens (#9014)
* chore: modify Markdown styles and  Tag style

* feat: Tag support color prop

* feat: Tag Component support preset color

* chore: 优化 playground Card style

* feat: migrate to remark

* feat: add Remark components

* feat: Toast 支持静态方法

* feat: animate loading message and simplify Toast props

* chore: optimize Toast implementation

* chore: remove unnecessary custtom renderers

* chore: optimize Markdown Render and add Slider Component

* chore: unified api with web version

* chore: remove unnecessary props for slider

* fix: accurately control step slider

* feat: support marks props

* fix: 修复 mark dot 位置

* chore: optimize Slider token usage

* feat: setting support fontSize

* chore: Playground support fontSize setting

* fix: Slider 样式

* chore: prevent slider change

* chore: update i18n keys

* chore: optimize theme provider tokens

* chore: remove unnecessary code

* fix: slider demo

* fix: theme-token type error

* fix: auth expired handle

* feat: handle auth expired redirect

* feat: add developer setting

* fix: login expired handle

* feat: add SettingGroup to automatically add isLast prop and add prevew page

* chore: adjust Preview component

* feat: move theme settings to a seperate page

* feat: 优化调整 Markdown 样式

* fix: list style

* chore: adjust markdown table style

* fix: blockquoto style

* chore: remove Markdown type

* chore: 迁移 TokenDisplay

* chore: optimize Highlight display

* feat: support katex render
2025-09-25 20:53:59 +08:00
Tsuki b4126686ac feat(mobile): mobile app new updates (bugs & discover & chat) (#8942)
* chore(mobile): add category tabs

* chore(mobile): update  discover

* chore(mobile): update discover hooks

* chore(mobile): update agent store

* chore(mobile): update useInitAgentConfig.ts hooks

* chore(mobile): update aiModel actions

* fix(mobile):chat service

* chore(mobile): update services

* feat(mobile): discover store

* chore(mobile): update model list skeleton

* chore(mobile): update provider list

* chore(mobile): prevent provider key

* chore(mobile): session list skeleton

* fix(mobile): session  list item

* chore(mobile): update new chat btn

* chore(mobile): message action

* chore(mobile): message skeleton

* chore(mobile): welcome message

* chore(mobile): optimized drawer

* chore(mobile): update i18n
2025-09-25 20:53:59 +08:00
Rdmclin2 6c1964bf83 Feat/mobile remark install error (#8891)
* chore: modify Markdown styles and  Tag style

* feat: Tag support color prop

* feat: Tag Component support preset color

* chore: 优化 playground Card style

* feat: migrate to remark

* feat: add Remark components

* feat: Toast 支持静态方法

* feat: animate loading message and simplify Toast props

* chore: optimize Toast implementation
2025-09-25 20:53:59 +08:00
Tsuki 97728ca1e8 fix(mobile): provider icon color when change theme 2025-09-25 20:53:59 +08:00
rdmclin2 9c05b13845 feat: ColorSwatch support Conic Gradient 2025-09-25 20:53:59 +08:00
rdmclin2 2cadc12047 fix: color token 2025-09-25 20:53:58 +08:00
Rdmclin2 c8c46a0ef6 Fix/token values (#8862)
* fix: detail style token and lineHeight Problem

* fix: optimize market tokens

* fix:  style token

* chore: tokenize setting style

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

* chore: add static token value

* chore: optimze theme-token style

* fix: token colorWhite lost

* chore: migrage components style

* fix: Tooltip style

* fix: Tooltip ref problem

* fix: neutral colors

* fix: ListItem token
2025-09-25 20:53:57 +08:00
rdmclin2 140c8ecccf fix: detail style token and lineHeight Problem 2025-09-25 20:53:35 +08:00
Tsuki 16cfa5dfed chore(mobile): update chat input 2025-09-25 20:53:35 +08:00
Rdmclin2 80569aebfd Feat/mobile workflow (#8762)
* chore: add ipa ignore

* chore: update expo realted package version

* fix: production build command

* feat: add ios submit workflow

* chore: add android submit process

* fix: vitest and jest test cases

* test: fix all components test cases

* fix: test cases and lint errors

* chore: update jest config json and fix testcases

* fix: login animation

* chore: add test cases for utils

* feat: refactor theme page

* feat: move theme page to settings page

* feat: add title prop for ListGroup

* feat: add theme token playground

* fix: login asset image and setting style

* chore: remove localhost:3020

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

* fix: theme token logic

* feat:support colorPrimary and fontSize

* feat: add ColorSwatches and local ThemeProvider

* fix: color token display

* chore: move colors to theme dir

* feat: add  ColorScale  components to  show color system

* chore: update colorscales

* feat: optimize design token generate

* fix: lint error

* feat: systemly orgnize lobe ui tokens

* fix: fix playground components style

* feat: 添加 primaryColor 配置

* fix: theme token  implementation

* fix: 支持中性色配置

* feat: primaryColor and netualColor config

* fix: text icon color

* feat: optimize theme-token components

* chore: extract components rom theme-token

* chore: optimize token display

* chore: optimize theme token style

* fix: _auth redirect logic

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

* fix: auth redirect on hotload

* chore: migrate @/theme import
2025-09-25 20:53:32 +08:00
Tsuki 3104122bf3 feat: connect session & chat & message & topic feature (#8755)
* chore: update mobile server routers

* chore: move original theme types

* chore(mobile): remove types files

* fix(mobile): type errors

* chore(mobile): remove useless model providers

* chore(mobile): session migrate

* chore(mobile): migrate const

* chore(mobile): add useFetchMessage hook

* chore(mobile): support package types

* chore(mobile): add utils fn

* chore(mobile): migrate user

* feat(mobile): add chat message slice

* fix(mobile): agent mobile server

* chore(mobile): update const

* chore(mobile): update fetch utils

* chore(mobile): update aiChat slice

* chore(mobile): update agent store

* chore(mobile): update tokenizer utils

* chore(mobile): update utils client

* chore(mobile): update parse.ts

* chore(mobile): update utils

* chore(mobile): update chains

* chore(mobile): update prompts chatMessages

* chore(mobile): update ChatList index loading issue

* chore(mobile): update chat header.ts

* chore(mobile): update chat relative components

* chore(mobile): update aiInfra store

* chore(mobile): update chat store

* chore(mobile): update utils

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

* chore(mobile): update global store

* chore(mobile): update services

* chore(mobile): update libs model-runtime

* chore(mobile): update i18n default

* chore(mobile): update hooks

* chore(mobile): update features

* chore(mobile): update settings const

* chore(mobile): update auth config

* chore(mobile): update ListItem component

* chore(mobile): update i18n zh-CN chat.json
2025-09-25 20:52:32 +08:00
Tsuki d424093366 feat: add provider list & model switch (#8662) 2025-09-25 20:52:32 +08:00
Rdmclin2 992b46695d feat: sidebar implementation (#8658)
* chore: migrate sidebar to features

* chore: migrate SIdeBar

* fix: add pnpm lockfile support

* chore: update .npmrc

* chore: optimize Session List

* fix:  stoploading animation

* chore: use mobile trpc

* fix: optimize consent page for mobile

* chore: optimize Consent page for mobile
2025-09-25 20:52:31 +08:00
rdmclin2 8f1bdbc3f1 feat: migrate mobile trpc api 2025-09-25 20:52:31 +08:00
rdmclin2 6ec7d5feca fix: trpc client problem 2025-09-25 20:52:31 +08:00
rdmclin2 e4051fa8dc feat: add trpc services 2025-09-25 20:52:31 +08:00
rdmclin2 af110931b4 fix: eas preview build env 2025-09-25 20:52:31 +08:00
rdmclin2 1e0d0a5337 chore: update locale config 2025-09-25 20:52:31 +08:00
rdmclin2 4d7d26fe12 chore: refactor src dir 2025-09-25 20:52:31 +08:00
rdmclin2 bf3b57b4ef fix: build error 2025-09-25 20:52:31 +08:00
rdmclin2 1ffeb7e92b feat: add mobile app 2025-09-25 20:52:29 +08:00
rdmclin2 a1e822b543 fix: mobile oidc process 2025-09-25 20:51:31 +08:00
rdmclin2 0785119aa6 feat: add oidc claims 2025-09-25 20:51:30 +08:00
rdmclin2 75aca0f2b8 feat: add oidc config for mobile 2025-09-25 20:51:30 +08:00
1198 changed files with 138318 additions and 15 deletions
+7
View File
@@ -0,0 +1,7 @@
# copy this file to .env when you want to develop the desktop app or you will fail
APP_URL=http://localhost:3020
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
NEXT_PUBLIC_SERVICE_MODE='server'
NEXT_PUBLIC_IS_DESKTOP_APP=0
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
+9
View File
@@ -117,3 +117,12 @@ 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,6 +3,7 @@ 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"
@@ -0,0 +1,21 @@
name: Create development builds
jobs:
android_development_build:
name: Build Android
type: build
params:
platform: android
profile: development
ios_device_development_build:
name: Build iOS device
type: build
params:
platform: ios
profile: development
ios_simulator_development_build:
name: Build iOS simulator
type: build
params:
platform: ios
profile: development-simulator
@@ -0,0 +1,15 @@
name: Create Production Builds
# on:
# push:
# branches: ['main']
jobs:
build_android:
type: build # This job type creates a production build for Android
params:
platform: android
build_ios:
type: build # This job type creates a production build for iOS
params:
platform: ios
@@ -0,0 +1,68 @@
name: Deploy to production
# on:
# push:
# branches: ['main']
jobs:
fingerprint:
name: Fingerprint
type: fingerprint
get_android_build:
name: Check for existing android build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
profile: production
get_ios_build:
name: Check for existing ios build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: production
build_android:
name: Build Android
needs: [get_android_build]
if: ${{ !needs.get_android_build.outputs.build_id }}
type: build
params:
platform: android
profile: production
build_ios:
name: Build iOS
needs: [get_ios_build]
if: ${{ !needs.get_ios_build.outputs.build_id }}
type: build
params:
platform: ios
profile: production
submit_android_build:
name: Submit Android Build
needs: [build_android]
type: submit
params:
build_id: ${{ needs.build_android.outputs.build_id }}
submit_ios_build:
name: Submit iOS Build
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
publish_android_update:
name: Publish Android update
needs: [get_android_build]
if: ${{ needs.get_android_build.outputs.build_id }}
type: update
params:
branch: production
platform: android
publish_ios_update:
name: Publish iOS update
needs: [get_ios_build]
if: ${{ needs.get_ios_build.outputs.build_id }}
type: update
params:
branch: production
platform: ios
@@ -0,0 +1,12 @@
name: Publish preview update
on:
push:
branches: ['*']
jobs:
publish_preview_update:
name: Publish preview update
type: update
params:
branch: ${{ github.ref_name || 'test' }}
@@ -0,0 +1,19 @@
# on:
# push:
# branches: ['main']
jobs:
build_android:
name: Build Android app
type: build
params:
platform: android
profile: production
submit_android:
name: Submit to Google Play Store
needs: [build_android]
type: submit
params:
platform: android
build_id: ${{ needs.build_android.outputs.build_id }}
+19
View File
@@ -0,0 +1,19 @@
# on:
# push:
# branches: ['main']
jobs:
build_ios:
name: Build iOS app
type: build
params:
platform: ios
profile: production
submit_ios:
name: Submit to Apple App Store
needs: [build_ios]
type: submit
params:
platform: ios
build_id: ${{ needs.build_ios.outputs.build_id }}
+9
View File
@@ -0,0 +1,9 @@
# 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
@@ -0,0 +1,36 @@
# 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
@@ -0,0 +1,55 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
package-lock.json
# pnpm-lock.yaml
# typescript
*.tsbuildinfo
app-example
android
ios
yarn.lock
repomix-output.xml
lobe-ui-repomix-output.xml
lobe-chat-repomix-output.xml
repomix-output-ant-design-theme.xml
credentials.json
build-*.aab
build-*.apk
build-*.ipa
coverage
+50
View File
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,67 @@
# 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
@@ -0,0 +1 @@
module.exports = require('@lobehub/lint').prettier;
+1
View File
@@ -0,0 +1 @@
module.exports = require('@lobehub/lint').remarklint;
+10
View File
@@ -0,0 +1,10 @@
const config = require('@lobehub/lint').stylelint;
module.exports = {
...config,
rules: {
'custom-property-pattern': null,
'no-descending-specificity': null,
...config.rules,
},
};
+54
View File
@@ -0,0 +1,54 @@
# 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
@@ -0,0 +1,15 @@
Apache License Version 2.0
Copyright (c) 2025/06/17 - current LobeHub LLC. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+202
View File
@@ -0,0 +1,202 @@
# LobeChat React Native
A modern open-source AI chat application built with React Native\
Get your own cross-platform ChatGPT/Claude/Gemini app for **free** with one click
**简体中文** · [English](./README.md)
[![GitHub release](https://img.shields.io/github/v/release/lobehub/lobe-chat-react-native?color=369eff&labelColor=black&logo=github&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/releases) [![Expo SDK](https://img.shields.io/badge/Expo-52.0.5-000020?labelColor=black&logo=expo&style=flat-square)](https://expo.dev) [![React Native](https://img.shields.io/badge/React%20Native-0.76.6-61dafb?labelColor=black&logo=react&style=flat-square)](https://reactnative.dev) [![TypeScript](https://img.shields.io/badge/TypeScript-5.8.2-3178c6?labelColor=black&logo=typescript&style=flat-square)](https://www.typescriptlang.org)
[![GitHub stars](https://img.shields.io/github/stars/lobehub/lobe-chat-react-native?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/stargazers) [![GitHub forks](https://img.shields.io/github/forks/lobehub/lobe-chat-react-native?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/network/members) [![GitHub issues](https://img.shields.io/github/issues/lobehub/lobe-chat-react-native?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/issues) [![GitHub license](https://img.shields.io/badge/license-apache%202.0-white?labelColor=black&style=flat-square)](https://github.com/lobehub/lobe-chat-react-native/blob/main/LICENSE)
Explore the limitless possibilities of AI conversations on mobile, crafted for you in an era of individual empowerment.
![](https://img.shields.io/badge/-POWERED%20BY%20LOBEHUB-151515?labelColor=black&logo=github&style=flat-square)
## Table of Contents
- [✨ Features Overview](#-features-overview)
- [📱 Supported Platforms](#-supported-platforms)
- [🚀 Quick Start](#-quick-start)
- [🛠️ Technology Stack](#-technology-stack)
- [📦 Project Structure](#-project-structure)
- [⌨️ Local Development](#-local-development)
- [🤝 Contributing](#-contributing)
- [🔗 More Tools](#-more-tools)
## ✨ Features Overview
### `1` Cross-Platform Support
Built with React Native and Expo, fully supporting both iOS and Android platforms with a single codebase running on multiple devices.
### `2` Modern UI Design
- 💎 **Refined UI**: Carefully crafted interface with elegant visuals and smooth interactions
- 🌗 **Dark/Light Themes**: Supports theme switching and adapts to system preferences
- 📱 **Mobile Optimization**: Deeply optimized for mobile devices, delivering a native app-like experience
### `3` Multi-Model Provider Support
Supports a variety of mainstream AI service providers:
- **OpenAI**: GPT-4, GPT-3.5, and more
- **Anthropic**: Claude series models
- **Google**: Gemini series models
- **Local Models**: Supports local LLMs like Ollama
### `4` Powerful Conversation Features
- 🗣️ **Smooth Chat Experience**: Supports streaming responses for real-time AI replies
- 📝 **Markdown Rendering**: Full support for Markdown formatting, including code highlighting
- 🎨 **Code Syntax Highlighting**: Professional code rendering powered by Shiki
- 🔊 **Voice Interaction**: Supports text-to-speech and speech-to-text functionality
### `5` Security and Privacy
- 🔒 **Data Security**: Supports local data storage to protect user privacy
- 💾 **Offline Support**: Important data cached locally so you can view chat history offline
### `6` Developer Friendly
- 🛠️ **TypeScript**: Full type support for a better development experience
- 📦 **Modular Architecture**: Clear project structure for easy maintenance and extensibility
- 🧪 **Testing Support**: Built-in Jest testing framework
- 📱 **Hot Reloading**: Real-time preview during development
## 📱 Supported Platforms
| Platform | Status | Version Requirement |
| -------- | ------------ | --------------------- |
| iOS | ✅ Supported | iOS 13.4+ |
| Android | ✅ Supported | Android 6.0+ (API 23) |
## 🚀 Quick Start
### Environment Requirements
Before getting started, please ensure your development environment meets the following requirements:
- **Node.js**: >= 18.0.0
- **pnpm**: >= 8.0.0 (recommended)
- **Expo CLI**: Latest version
- **iOS**: Xcode 14+ (macOS only)
- **Android**: Android Studio
### Install Dependencies
```bash
# Clone the repository
git clone https://github.com/lobehub/lobe-chat-react-native.git
cd lobe-chat-react-native
# Install dependencies
pnpm install
```
### Configure Environment Variables
```bash
# Copy the environment variable template
cp .env.example .env.local
# Edit the environment variables and add your API keys
# OPENAI_API_KEY=your_openai_api_key
```
### Start the Development Server
```bash
# Start the Expo development server
pnpm start
# Or run on a specific platform directly
pnpm run ios # iOS simulator
pnpm run android # Android emulator
```
### Run on a Physical Device
1. Install the **Expo Go** app:
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
- [Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
2. Scan the QR code displayed in the terminal to preview on your device
## 🛠️ Technology Stack
| Technology | Version | Description |
| --------------------------- | -------- | ----------------------------------------------- |
| **React Native** | 0.76.6 | Cross-platform mobile app framework |
| **Expo** | \~52.0.5 | React Native development platform and toolchain |
| **TypeScript** | ^5.8.2 | Type-safe JavaScript superset |
| **Expo Router** | \~4.0.17 | File system-based routing solution |
| **Zustand** | ^5.0.3 | Lightweight state management library |
| **React Native Reanimated** | \~3.16.7 | High-performance animation library |
| **Shiki** | ^3.1.0 | Code syntax highlighting engine |
## ⌨️ Local Development
### Development Scripts
```bash
# Start the development server
pnpm start
# Run on iOS simulator
pnpm run ios
# Run on Android emulator
pnpm run android
# Run tests
pnpm run test
# Code linting
pnpm run lint
# Build the app
pnpm build
```
### Code Standards
This project follows strict code standards:
- **ESLint** + **Prettier** for code formatting
- **TypeScript** strict type checking
- **Git Hooks** for pre-commit checks
- **Conventional Commits** for commit message conventions
## 🤝 Contributing
We warmly welcome all forms of contributions!
### Contribution Guide
1. **Fork** this repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a **Pull Request**
### Development Guidelines
- Please follow the [Conventional Commits](https://conventionalcommits.org/) specification for commit messages
- Use Prettier for code formatting and ESLint for code linting
- All new features should include corresponding test cases
## 🔗 More Tools
| Project | Description |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **[🤯 Lobe Chat](https://github.com/lobehub/lobe-chat)** | Modern open-source ChatGPT/LLM chat app (Web version) |
| **[🅰️ Lobe UI](https://github.com/lobehub/lobe-ui)** | Open-source UI component library for building AIGC web apps |
| **[🌏 Lobe i18n](https://github.com/lobehub/lobe-commit/tree/master/packages/lobe-i18n)** | ChatGPT-powered i18n translation automation tool |
| **[💌 Lobe Commit](https://github.com/lobehub/lobe-commit)** | AI-based Git commit message generator |
---
#### 📝 License
Copyright © 2025 [LobeHub](https://github.com/lobehub). This project is open source under the [Apache 2.0](./LICENSE) license.
+202
View File
@@ -0,0 +1,202 @@
# 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
@@ -0,0 +1,154 @@
{
"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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,69 @@
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>
);
}
@@ -0,0 +1,38 @@
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>
);
}
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,15 @@
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',
},
}));
@@ -0,0 +1,28 @@
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>
);
};
@@ -0,0 +1,17 @@
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,
},
}));
@@ -0,0 +1,103 @@
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>
);
};
@@ -0,0 +1,59 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
badge: {
backgroundColor: token.colorError,
borderRadius: token.borderRadiusXS,
height: 8,
marginRight: token.marginXS,
width: 8,
},
checkmark: {},
customContent: {
backgroundColor: token.colorBgContainer,
paddingBottom: token.paddingMD,
paddingHorizontal: token.paddingMD,
},
separator: {
backgroundColor: token.colorBorderSecondary,
height: 1,
marginHorizontal: token.marginMD,
},
settingItem: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
flexDirection: 'row',
justifyContent: 'space-between',
minHeight: 56,
paddingHorizontal: token.paddingMD,
paddingVertical: token.paddingSM,
},
settingItemArrow: {
color: token.colorBorder,
fontSize: token.sizeLG,
marginLeft: token.marginSM,
},
settingItemDescription: {
color: token.colorTextDescription,
fontSize: token.fontSizeSM,
marginTop: token.marginXS,
},
settingItemExtra: {
color: token.colorTextSecondary,
fontSize: token.fontSizeLG,
marginRight: token.marginXXS,
},
settingItemLeft: {
flexDirection: 'column',
flexShrink: 1,
justifyContent: 'center',
},
settingItemRight: {
alignItems: 'center',
flexDirection: 'row',
},
settingItemTitle: {
color: token.colorText,
fontSize: token.fontSizeLG,
},
}));
@@ -0,0 +1,2 @@
export { SettingGroup } from './SettingGroup';
export { SettingItem } from './SettingItem';
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,90 @@
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>
);
}
@@ -0,0 +1,19 @@
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,
},
}));
@@ -0,0 +1,80 @@
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;
@@ -0,0 +1,175 @@
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,
},
};
});
@@ -0,0 +1,89 @@
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>
);
}
@@ -0,0 +1,23 @@
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;
@@ -0,0 +1,7 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
}));
@@ -0,0 +1,134 @@
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>
);
}
@@ -0,0 +1,11 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -0,0 +1,33 @@
import { Token } from '@/_types/user';
import { TokenStorage } from '@/services/_auth/tokenStorage';
import { useUserStore } from '@/store/user';
const updateToken = async (mutate: (token: Token) => Token): Promise<void> => {
const current = await TokenStorage.getToken();
if (!current) throw new Error('当前无可用 Token');
const next = mutate(current);
await TokenStorage.storeToken(next);
};
export const expireAccessTokenNow = async (): Promise<void> => {
const now = Math.floor(Date.now() / 1000) - 10;
await updateToken((t) => ({ ...t, expiresAt: now }));
};
export const expireRefreshTokenNow = async (): Promise<void> => {
const now = Math.floor(Date.now() / 1000) - 10;
await updateToken((t) => ({ ...t, refreshExpiresAt: now }));
};
export const invalidateAccessToken = async (): Promise<void> => {
await updateToken((t) => ({ ...t, accessToken: 'invalid-access-token' }));
};
export const invalidateRefreshToken = async (): Promise<void> => {
await updateToken((t) => ({ ...t, refreshToken: 'invalid-refresh-token' }));
};
export const clearAuthData = async (): Promise<void> => {
await TokenStorage.clearAll();
useUserStore.getState().clearAuthState();
};
@@ -0,0 +1,68 @@
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;
@@ -0,0 +1,55 @@
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>
);
}
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,139 @@
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>
);
}
@@ -0,0 +1,59 @@
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>
);
}
@@ -0,0 +1,11 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
contentContainer: {
padding: token.paddingContentHorizontal,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -0,0 +1,521 @@
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;
@@ -0,0 +1,193 @@
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,
},
}));
@@ -0,0 +1,171 @@
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;
@@ -0,0 +1,115 @@
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
@@ -0,0 +1,7 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
container: {
padding: token.paddingContentHorizontal,
},
}));
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,197 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,196 @@
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;
@@ -0,0 +1,150 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
// 操作按钮相关样式
actionButtonsContainer: {
flexDirection: 'row',
marginBottom: token.marginLG,
width: '100%',
},
addButton: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
marginRight: token.marginXS,
paddingHorizontal: token.margin,
paddingVertical: token.paddingSM,
},
addButtonText: {
color: token.colorText,
fontSize: token.fontSize,
fontWeight: token.fontWeightStrong,
},
authorAvatar: {
alignItems: 'center',
borderRadius: token.borderRadiusSM,
height: token.controlHeightSM,
justifyContent: 'center',
marginRight: token.marginXS / 2,
width: token.controlHeightSM,
},
avatarTitleContainer: {
alignItems: 'center',
marginBottom: token.margin,
},
// 头部相关样式
backButton: {
padding: token.marginXS,
},
// 容器相关样式
container: {
flex: 1,
padding: token.padding,
},
description: {
color: token.colorText,
fontSize: token.fontSize,
lineHeight: token.lineHeight,
marginBottom: token.margin,
},
errorContainer: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
flex: 1,
justifyContent: 'center',
},
errorText: {
color: token.colorError,
},
iconText: {
fontSize: token.fontSize,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
flex: 1,
justifyContent: 'center',
},
// 文字相关样式
loadingText: {
color: token.colorTextSecondary,
marginTop: token.margin,
},
safeAreaContainer: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
scrollContainer: {
flex: 1,
},
settingsIcon: {
fontSize: token.fontSizeLG,
},
settingsTitleContainer: {
alignItems: 'center',
backgroundColor: token.colorBgElevated,
borderTopLeftRadius: token.borderRadiusLG,
borderTopRightRadius: token.borderRadiusLG,
flexDirection: 'row',
padding: token.margin,
},
shareButton: {
alignItems: 'center',
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
height: token.controlHeight,
justifyContent: 'center',
width: token.controlHeight,
},
// 系统角色相关样式
systemRoleContainer: {
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
marginBottom: token.marginLG,
width: '100%',
},
systemRoleContentContainer: {
// paddingVertical: 12,
color: token.colorText,
fontSize: token.fontSizeSM,
lineHeight: token.lineHeight,
paddingHorizontal: token.margin,
},
systemRoleTitle: {
color: token.colorText,
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
marginLeft: token.marginXS,
},
// 标签相关样式
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: token.marginXXS,
marginBottom: token.marginLG,
},
}));
@@ -0,0 +1,199 @@
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;
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
import { useThemedScreenOptions } from '@/_const/navigation';
export default function RoutesLayout() {
const themedScreenOptions = useThemedScreenOptions();
return (
<Stack
screenOptions={{
...themedScreenOptions,
headerShown: false,
}}
/>
);
}
@@ -0,0 +1,33 @@
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>
);
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,19 @@
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>
);
}
@@ -0,0 +1,38 @@
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>
);
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,15 @@
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>
);
}
@@ -0,0 +1,22 @@
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>
);
}
@@ -0,0 +1,21 @@
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>
);
}
@@ -0,0 +1,15 @@
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>
);
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,25 @@
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>
);
}
@@ -0,0 +1,28 @@
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>
);
}
@@ -0,0 +1,24 @@
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>
);
}
@@ -0,0 +1,39 @@
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>
);
}
@@ -0,0 +1,22 @@
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>
);
}
@@ -0,0 +1,28 @@
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>
);
}
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,32 @@
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>
);
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,30 @@
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>
);
}
@@ -0,0 +1,8 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
}));
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,30 @@
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>
);
}
@@ -0,0 +1,51 @@
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>
);
}
@@ -0,0 +1,5 @@
import ThemeTokensPlayground from '@lobehub/ui-rn/theme/theme-token';
export default function ThemeTokensPlaygroundPage() {
return <ThemeTokensPlayground />;
}
@@ -0,0 +1,30 @@
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>
);
}
@@ -0,0 +1,23 @@
import { PageContainer } from '@lobehub/ui-rn';
import ComponentPlayground, { DemoItem } from '@lobehub/ui-rn/Playground';
import { AdvancedDemo, BasicDemo, PositionDemo, TriggerDemo } from '@lobehub/ui-rn/Tooltip/demos';
import README from '@lobehub/ui-rn/Tooltip/readme';
import React from 'react';
import { useStyles } from './style';
const demos: DemoItem[] = [
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
{ component: <TriggerDemo />, key: 'trigger', title: '触发方式' },
{ component: <PositionDemo />, key: 'position', title: '不同位置' },
{ component: <AdvancedDemo />, key: 'advanced', title: '高级功能' },
];
export default function TooltipPlaygroundPage() {
const { styles } = useStyles();
return (
<PageContainer showBack style={styles.safeAreaView} title="Tooltip 组件">
<ComponentPlayground demos={demos} readmeContent={README} />
</PageContainer>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { CapsuleTabItem, CapsuleTabs, Input, PageContainer, Tag } from '@lobehub/ui-rn';
import { useRouter } from 'expo-router';
import { ChevronRight } from 'lucide-react-native';
import React, { useState } from 'react';
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
import { useStyles } from './styles';
import { ComponentItem } from './type';
import { COMPONENT_CONFIGS, getAllCategories, searchComponentsByName } from './utils';
export default function ComponentPlaygroundIndex() {
const router = useRouter();
const { styles, theme } = useStyles();
const [searchText, setSearchText] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const categories = getAllCategories();
// 构建 CapsuleTabs 的数据
const tabItems: CapsuleTabItem[] = [
{ key: '', label: 'All' },
...categories.map((category) => ({ key: category, label: category })),
];
// 过滤组件
const getFilteredComponents = (): ComponentItem[] => {
let components = COMPONENT_CONFIGS;
if (searchText) {
components = searchComponentsByName(searchText);
}
if (selectedCategory) {
components = components.filter((c) => c.category === selectedCategory);
}
return components;
};
const filteredComponents = getFilteredComponents();
const handleComponentPress = (component: ComponentItem) => {
switch (component.path) {
case 'tooltip': {
router.push('/playground/components/tooltip');
break;
}
case 'toast': {
router.push('/playground/components/toast');
break;
}
case 'highlighter': {
router.push('/playground/components/highlighter');
break;
}
case 'markdown': {
router.push('/playground/components/markdown');
break;
}
case 'listitem': {
router.push('/playground/components/listitem');
break;
}
case 'avatar': {
router.push('/playground/components/avatar');
break;
}
case 'space': {
router.push('/playground/components/space');
break;
}
case 'fluentemoji': {
router.push('/playground/components/fluentemoji');
break;
}
case 'tag': {
router.push('/playground/components/tag');
break;
}
case 'capsuletabs': {
router.push('/playground/components/capsuletabs');
break;
}
case 'button': {
router.push('/playground/components/button');
break;
}
case 'icon': {
router.push('/playground/components/icon');
break;
}
case 'action-icon': {
router.push('/playground/components/action-icon');
break;
}
case 'skeleton': {
router.push('/playground/components/skeleton');
break;
}
case 'instant-switch': {
router.push('/playground/components/instant-switch');
break;
}
case 'colorswatches': {
router.push('/playground/components/colorswatches');
break;
}
case 'colorscales': {
router.push('/playground/components/colorscales');
break;
}
case 'slider': {
router.push('/playground/components/slider');
break;
}
case 'theme-provider': {
router.push('/playground/components/theme-provider');
break;
}
case 'theme-theme': {
router.push('/playground/components/theme-token');
break;
}
case 'switch': {
router.push('/playground/components/switch');
break;
}
case 'input': {
router.push('/playground/components/input');
break;
}
case 'form': {
router.push('/playground/components/form');
break;
}
case 'flexbox': {
router.push('/playground/components/flexbox');
break;
}
case 'center': {
router.push('/playground/components/center');
break;
}
case 'block': {
router.push('/playground/components/block');
break;
}
case 'card': {
router.push('/playground/components/card');
break;
}
default: {
alert(`${component.name} 组件页面正在建设中`);
}
}
};
const renderComponentCard = (component: ComponentItem) => (
<TouchableOpacity
activeOpacity={0.7}
key={component.name}
onPress={() => handleComponentPress(component)}
style={styles.componentCard}
>
<View style={styles.cardHeader}>
<Text style={styles.componentName}>{component.name}</Text>
<View style={styles.badges}>
{component.hasReadme && (
<View style={styles.badge}>
<Text style={styles.badgeText}>README</Text>
</View>
)}
{component.hasDemos && (
<View style={styles.badge}>
<Text style={styles.badgeText}>DEMO</Text>
</View>
)}
</View>
</View>
<Text style={styles.componentDescription}>{component.description}</Text>
<View style={styles.tagsContainer}>
{component.tags.slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</View>
<View style={styles.cardFooter}>
<Text style={styles.categoryText}>{component.category}</Text>
<ChevronRight color={theme.colorTextTertiary} size={20} />
</View>
</TouchableOpacity>
);
return (
<PageContainer showBack style={styles.safeAreaView} title="Playground">
<View style={styles.filterContainer}>
<Input.Search
onChangeText={setSearchText}
placeholder="搜索组件..."
size="large"
style={styles.searchContainer}
value={searchText}
/>
<View style={styles.filterTabs}>
<CapsuleTabs
items={tabItems}
onSelect={setSelectedCategory}
selectedKey={selectedCategory}
showsHorizontalScrollIndicator={false}
size="large"
/>
</View>
</View>
<ScrollView showsVerticalScrollIndicator={false} style={styles.container}>
<View style={styles.componentList}>{filteredComponents.map(renderComponentCard)}</View>
</ScrollView>
</PageContainer>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { createStyles } from '@/theme';
export const useStyles = createStyles(({ token }) => ({
backButton: {
alignItems: 'center',
borderRadius: token.borderRadius,
height: token.controlHeight,
justifyContent: 'center',
marginRight: token.marginSM,
width: token.controlHeight,
},
badge: {
backgroundColor: token.colorBgContainerSecondary,
borderRadius: token.borderRadiusSM,
paddingHorizontal: token.paddingXS,
paddingVertical: token.paddingXXS,
},
badgeText: {
color: token.colorText,
fontSize: token.fontSizeIcon,
fontWeight: token.fontWeightStrong,
},
badges: {
flexDirection: 'row',
gap: token.marginXS,
},
cardFooter: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
},
cardHeader: {
alignItems: 'flex-start',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: token.marginXS,
},
categoryText: {
color: token.colorTextTertiary,
fontSize: token.fontSizeSM,
textTransform: 'capitalize',
},
componentCard: {
backgroundColor: token.colorBgElevated,
borderColor: token.colorBorderSecondary,
borderRadius: token.borderRadiusLG,
borderWidth: token.lineWidth,
elevation: 2,
marginBottom: token.marginSM,
padding: token.padding,
},
componentDescription: {
color: token.colorTextSecondary,
fontSize: token.fontSize,
lineHeight: token.lineHeight,
marginBottom: token.marginSM,
},
componentList: {
gap: token.marginSM,
paddingHorizontal: token.padding,
},
componentName: {
color: token.colorText,
flex: 1,
fontSize: token.fontSizeHeading4,
fontWeight: token.fontWeightStrong,
},
container: {
flex: 1,
},
filterContainer: {
paddingHorizontal: token.padding,
paddingVertical: token.paddingSM,
},
filterTab: {
borderRadius: token.borderRadius,
marginRight: token.marginXS,
paddingHorizontal: token.padding,
paddingVertical: token.paddingXS,
},
filterTabText: {
fontSize: token.fontSize,
textTransform: 'capitalize',
},
filterTabs: {
flexDirection: 'row',
},
header: {
alignItems: 'center',
borderBottomWidth: token.lineWidth,
flexDirection: 'row',
padding: token.paddingLG,
},
headerContent: {
flex: 1,
},
safeAreaView: {
backgroundColor: token.colorBgLayout,
flex: 1,
},
searchContainer: {
marginBottom: token.marginSM,
paddingHorizontal: token.paddingSM,
},
searchIcon: {
marginRight: token.marginXS,
},
searchInput: {
color: token.colorText,
flex: 1,
fontSize: token.fontSize,
paddingVertical: token.paddingXS,
},
subtitle: {
fontSize: token.fontSize,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: token.marginXXS,
marginBottom: token.marginXS,
},
title: {
fontSize: token.fontSizeHeading2,
fontWeight: token.fontWeightStrong,
lineHeight: token.lineHeightHeading2,
},
}));
+38
View File
@@ -0,0 +1,38 @@
/**
* 组件相关的类型定义
*/
import React from 'react';
export interface ComponentItem {
category: 'basic' | 'layout' | 'feedback' | 'display' | 'animation' | 'form' | 'navigation';
description: string;
hasDemos: boolean;
hasReadme: boolean;
name: string;
path: string;
tags: string[];
}
export interface ComponentStats {
beta: number;
categories: number;
demo: number;
stable: number;
tags: number;
total: number;
withDemos: number;
withReadme: number;
}
export interface DemoConfig {
code?: string;
component: React.ComponentType<any>;
description: string;
name: string;
}
export interface ComponentConfig {
component: ComponentItem;
demos?: DemoConfig[];
readme?: string;
}

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