Files
openhuman/gitbooks/developing/architecture/frontend.zh-CN.md
T
JAYcodrGitHubagent:skill-master <skill-master@openclaw>
838309e998 Docs/i18n batch fix link localization (#2508)
Co-authored-by: agent:skill-master <skill-master@openclaw>
2026-05-23 00:51:25 -07:00

2296 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
description: >-
React + Vite 前端 (`app/src/`) —— 架构、状态、服务、
提供商、路由、组件、hook。
icon: browsers
---
# 前端 (app/src/)
OpenHuman 桌面 UI`app/src/` 下的 Vite + React 19 树(Yarn workspace `openhuman-app`)。它使用 Redux Toolkit 配合持久化来管理会话状态,通过 REST + Socket.io 与后端通信,并通过 JSON-RPC 调用 Rust core sidecar`coreRpcClient` / Tauri `core_rpc_relay`)。重逻辑在核心中,不在此处。
这是一份整合的参考。使用上方目录(或你的阅读器大纲)在章节间跳转。
## 快速参考
| 章节 | 涵盖内容 |
| ------------------------------------------------- | --------------------------------------------- |
| [架构](frontend.zh-CN.md#architecture-overview) | Provider 链、构建、布局、规范 |
| [状态管理](frontend.zh-CN.md#state-management) | Redux Toolkit slice、selector、持久化 |
| [服务层](frontend.zh-CN.md#services-layer) | `apiClient``socketService``coreRpcClient` |
| [Providers](frontend.zh-CN.md#providers) | `User``Socket``AI``Skill` providers |
| [页面与路由](frontend.zh-CN.md#pages-routing) | `HashRouter`、路由守卫、主路由 |
| [组件](frontend.zh-CN.md#components) | UI / 设置组件模式 |
| [Hook 与工具](frontend.zh-CN.md#hooks-utilities) | 共享 hook、辅助函数、配置 |
## 规模
| 指标 | 值 |
| --------------------------------------- | ------------------------------------------------------------------------ |
| `app/src/` 下的 TypeScript / TSX 文件 | \~285 (`find app/src -name '*.ts' -o -name '*.tsx' \| wc -l` 刷新) |
| 测试 runner | Vitest (`app/test/vitest.config.ts`) |
## 目录布局
```text
app/src/
├── App.tsx # Provider 链 + HashRouter shell
├── AppRoutes.tsx # 路由表 + 守卫
├── main.tsx # 入口 (Sentry、store、样式)
├── store/ # Redux slice 和 selector
├── providers/ # UserProvider、SocketProvider、AIProvider、SkillProvider
├── services/ # apiClient、socketService、coreRpcClient、api/*
├── lib/ # AI loader、MCP 辅助函数、技能同步等
├── pages/ # 路由级页面
├── components/ # 共享 UI
├── hooks/ # 应用 hook
├── utils/ # 配置、Tauri 辅助函数、路由工具
└── assets/ # 图标和静态资源
```
## 架构概览
### 系统架构
OpenHuman 的桌面 UI 是一个 **React 19** 应用 (`app/src/`),它:
* 使用 **Redux Toolkit** 配合持久化来管理与会话相关的状态
* 通过 **REST** (`apiClient`) 和 **Socket.io** (`socketService`) 连接后端
* 通过 **`coreRpcClient`** / Tauri **`core_rpc_relay`** 调用 **Rust 核心**进程(JSON-RPC 方法实现在仓库根目录 `src/openhuman/` 中,通过 `core_server` 暴露)
* 从捆绑的 `src/openhuman/agent/prompts`(仓库根目录)和打包时的 Tauri **`ai_get_config`** 加载 **AI 提示**
*`lib/mcp/` 下使用 **最小 MCP 风格**辅助层(传输、验证),而非大型的仓库内 Telegram MCP 工具包
### 入口点
| 文件 | 用途 |
| ----------------------- | ------------------------------------------------------------------------------------ |
| `app/src/main.tsx` | React 根节点、Sentry 边界、store、全局样式 |
| `app/src/App.tsx` | Provider 链:Redux → PersistGate → User → Socket → AI → Skill → Router |
| `app/src/AppRoutes.tsx` | `HashRouter` 路由、`ProtectedRoute` / `PublicRoute`、onboarding 和 mnemonic 门禁 |
### Provider 链
```text
Redux Provider
└─ PersistGate
└─ UserProvider
└─ SocketProvider
└─ AIProvider
└─ SkillProvider
└─ HashRouter
└─ AppRoutes (pages + settings)
```
**为什么是这个顺序**
1. Redux 在最外层,以便到处使用 `useAppSelector` / dispatch。
2. `PersistGate` 在子组件假设稳定认证前重新水合持久化的 slice。
3. `SocketProvider` 使用 auth token 进行 Socket.io。
4. `AIProvider` / `SkillProvider` 包装依赖 socket 和 store 状态的功能。
5. `HashRouter` 为所有路由提供导航。
### 模块关系(简化)
```text
App.tsx
├─ Redux store + persistor
├─ UserProvider - 用户 profile / workspace 上下文
├─ SocketProvider - token 存在时连接 socketService
├─ AIProvider - AI 会话 / 记忆客户端协调
├─ SkillProvider - 技能目录和同步
└─ AppRoutes
├─ PublicRoute - 例如 `/` 上的 Welcome
├─ ProtectedRoute - onboarding、home、skills、settings、…
└─ DefaultRedirect - 未认证用户
```
### 服务层(概念性)
```text
services/
├─ apiClient → 通过运行时解析的 URL 的 REST,使用 `services/backendUrl#getBackendUrl`
├─ backendUrl → 调用 `openhuman.config_resolve_api_url`;仅在 Tauri 外 fallback 到 VITE_BACKEND_URL
├─ socketService → Socket.io;实时 + MCP 风格信封
└─ coreRpcClient → 本地 openhuman 核心的 HTTP (JSON-RPC),配合 Tauri relay 使用
```
#### 运行时配置优先级
桌面应用不会将核心 RPC URL 或 API 主机作为硬性要求烘焙到 bundle 中。运行时应用按此顺序解析它们(最高优先):
1. **登录屏幕 RPC URL 字段**,通过 `utils/configPersistence` 保存并在下次启动时恢复。终端用户在此配置 sidecar 地址,而非手动编辑 `config.toml``.env` 文件。
2. **Tauri `core_rpc_url` 命令**bundled sidecar 为本进程监听的端口。
3. **`VITE_OPENHUMAN_CORE_RPC_URL`**,开发时的构建时 fallback。
4. 硬编码的 `http://127.0.0.1:7788/rpc` 默认值。
RPC 握手成功后,`services/backendUrl` 调用 `openhuman.config_resolve_api_url` 从加载的核心 `Config` 中拉取 `api_url`(和其他安全客户端字段)。`VITE_BACKEND_URL` 仅在应用运行在 Tauri 外时作为 Web fallback 使用。
需要后端 URL 的组件应调用 `useBackendUrl()`(或非 React 代码调用 `getBackendUrl()`),它们绝不能从 `utils/config` 导入静态的 `BACKEND_URL` 常量,那只代表构建时值。
### 相关文档
* Rust 架构:[架构](../architecture.zh-CN.md)
* Tauri 壳层:[Tauri Shell](tauri-shell.zh-CN.md)
## 状态管理
应用使用 Redux Toolkit 配合 Redux-Persist 进行健壮的状态管理。
### Store 配置
**文件:** `store/index.ts`
```typescript
// 合并所有 slice 并持久化
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth', 'telegram'], // 持久化的 slice
};
```
### Redux 状态结构
```typescript
RootState = {
auth: {
token: string | null, // JWT (持久化)
isOnboardedByUser: Record<string, boolean>, // 每用户 flag (持久化)
},
socket: {
byUser: Record<
string,
{
// 每用户 ID
status: 'connecting' | 'connected' | 'disconnected';
socketId: string | null;
}
>,
},
user: { profile: User | null, loading: boolean, error: string | null },
telegram: {
byUser: Record<string, TelegramState>, // 每 Telegram 用户 (持久化)
},
};
```
### Slice
#### Auth Slice (`store/authSlice.ts`)
管理 JWT token 和每用户 onboarding 状态。
**状态:**
```typescript
interface AuthState {
token: string | null;
isOnboardedByUser: Record<string, boolean>;
}
```
**Actions**
* `setToken(token: string)` - 登录后存储 JWT
* `clearToken()` - 登出时移除 token
* `setOnboarded({ userId, isOnboarded })` - 将用户标记为已 onboard
**Selectors (`store/authSelectors.ts`)**
* `selectToken` - 获取当前 JWT
* `selectIsOnboarded(userId)` - 检查用户是否完成 onboarding
#### Socket Slice (`store/socketSlice.ts`)
跟踪每用户的 Socket.io 连接状态。
**状态:**
```typescript
interface SocketState {
byUser: Record<
string,
{ status: 'connecting' | 'connected' | 'disconnected'; socketId: string | null }
>;
}
```
**Actions**
* `setSocketStatus({ userId, status })` - 更新连接状态
* `setSocketId({ userId, socketId })` - 存储 socket ID
* `clearSocketState(userId)` - 清除用户 socket 状态
**Selectors (`store/socketSelectors.ts`)**
* `selectSocketStatus(userId)` - 获取连接状态
* `selectIsSocketConnected(userId)` - 布尔连接检查
#### User Slice (`store/userSlice.ts`)
存储用户 profile 数据。
**状态:**
```typescript
interface UserState {
profile: User | null;
loading: boolean;
error: string | null;
}
```
**Actions**
* `setUser(user)` - 存储用户 profile
* `setUserLoading(loading)` - 设置加载状态
* `setUserError(error)` - 设置错误状态
* `clearUser()` - 登出时清除 profile
#### Telegram Slice (`store/telegram/`)
Telegram 集成的复杂嵌套状态管理。
**文件:**
* `index.ts` - Slice 导出(actions、thunks
* `types.ts` - 实体和状态接口
* `reducers.ts` - 同步 reducers
* `extraReducers.ts` - 异步 thunk handlers
* `thunks.ts` - 异步操作
**状态结构:**
```typescript
telegram.byUser[telegramUserId] = {
connectionStatus: "disconnected" | "connecting" | "connected" | "error",
authStatus: "not_authenticated" | "authenticating" | "authenticated" | "error",
currentUser: TelegramUser | null,
sessionString: string | null, // 存储在这里,而非 localStorage
chats: Record<string, TelegramChat>,
chatsOrder: string[],
messages: Record<chatId, Record<msgId, TelegramMessage>>,
threads: Record<chatId, TelegramThread[]>
}
```
**Reducers**
* `setCurrentUser` - 存储已认证的 Telegram 用户
* `setSessionString` - 存储 MTProto 会话(用于持久化)
* `setConnectionStatus` - 更新连接状态
* `setAuthStatus` - 更新认证状态
* `addChat` / `updateChat` - 管理聊天列表
* `addMessage` / `updateMessage` - 管理消息历史
* `setThreads` - 存储 thread 数据
**Thunks (`store/telegram/thunks.ts`)**
* `initializeTelegram(userId)` - 初始化 MTProto 客户端
* `connectTelegram(userId)` - 建立 Telegram 连接
* `fetchChats(userId)` - 加载聊天列表
* `fetchMessages({ userId, chatId })` - 加载消息历史
* `disconnectTelegram(userId)` - 干净断开
**Selectors (`store/telegramSelectors.ts`)**
* `selectTelegramState(userId)` - 获取完整 Telegram 状态
* `selectTelegramConnectionStatus(userId)` - 获取连接状态
* `selectTelegramAuthStatus(userId)` - 获取 auth 状态
* `selectTelegramChats(userId)` - 获取聊天列表
* `selectTelegramMessages(userId, chatId)` - 获取聊天的消息
### Typed Hooks
**文件:** `store/hooks.ts`
```typescript
// 使用这些代替普通的 useDispatch/useSelector
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
```
### 持久化配置
#### 什么被持久化
* `auth.token` - 用于认证的 JWT
* `auth.isOnboardedByUser` - 每用户 onboarding 状态
* `telegram.byUser` - Telegram 状态(会话、聊天等)
#### 什么**不**被持久化
* `socket` - 连接状态(应用启动时重连)
* `user.loading` / `user.error` - 瞬态 UI 状态
* Telegram 加载/错误状态
#### 存储后端
Redux-Persist 默认使用 localStorage adapter。这是应用中唯一可接受的 localStorage 使用。
### 使用示例
#### 读取状态
```typescript
import { useAppSelector } from '../store/hooks';
function MyComponent() {
const token = useAppSelector(state => state.auth.token);
const isConnected = useAppSelector(state => state.socket.byUser[userId]?.status === 'connected');
const chats = useAppSelector(state => state.telegram.byUser[userId]?.chats);
}
```
#### Dispatch Actions
```typescript
import { clearToken, setToken } from '../store/authSlice';
import { useAppDispatch } from '../store/hooks';
import { initializeTelegram } from '../store/telegram/thunks';
function MyComponent() {
const dispatch = useAppDispatch();
// 同步 action
const handleLogin = (token: string) => {
dispatch(setToken(token));
};
// 异步 thunk
const handleConnect = async () => {
await dispatch(initializeTelegram(userId)).unwrap();
};
}
```
#### 使用 Selectors
```typescript
import { selectIsOnboarded } from '../store/authSelectors';
import { useAppSelector } from '../store/hooks';
import { selectTelegramConnectionStatus } from '../store/telegramSelectors';
function MyComponent({ userId }) {
const isOnboarded = useAppSelector(state => selectIsOnboarded(state, userId));
const connectionStatus = useAppSelector(state => selectTelegramConnectionStatus(state, userId));
}
```
### 最佳实践
1. **始终使用 typed hooks** - `useAppDispatch``useAppSelector`
2. **使用 selector 处理派生状态** - 可记忆且可测试
3. **将 thunks 放在单独文件中** - 更好的组织
4. **每用户状态作用域** - 按用户 ID 键控状态
5. **避免 localStorage** - 改用 Redux-Persist
***
## 服务层
应用使用单例服务进行外部通信。这防止连接泄漏并提供一致的 API 访问。
### 服务架构
```text
app/src/services/
├─ apiClient (HTTP REST)
│ ├─ 从 Redux 读取 auth.token
└─ 调用 VITE_BACKEND_URL(见 utils/config.ts
├─ socketService (Socket.io)
│ ├─ web: JS 客户端
│ └─ Tauri: 通过 utils/tauriSocket.ts 与 Rust 端 socket 协调
├─ coreRpcClient.ts
│ └─ invoke('core_rpc_relay', …) → 本地 openhuman 核心 (JSON-RPC)
└─ services/api/* - 领域 REST 模块 (auth、user、teams、…)
```
### API Client (`services/apiClient.ts`)
用于后端通信的 HTTP REST 客户端。
#### 特性
* 基于 Fetch 的实现
* 自动从 Redux store 注入 JWT
* 类型化的请求/响应处理
* 带类型错误的错误处理
#### 用法
```typescript
import apiClient from "../services/apiClient";
// GET 请求
const user = await apiClient.get<User>("/users/me");
// POST 请求
const result = await apiClient.post<LoginResponse>("/auth/login", {
email,
password,
});
// 带自定义头
const data = await apiClient.get<Data>("/endpoint", {
headers: { "X-Custom": "value" },
});
```
#### 配置
从环境读取 `VITE_BACKEND_URL` 或使用默认值:
```typescript
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || "https://api.example.com";
```
### API Endpoints (`services/api/`)
#### Auth API (`services/api/authApi.ts`)
认证相关端点。
```typescript
import { authApi } from "../services/api/authApi";
// 登录
const { token, user } = await authApi.login(credentials);
// Token 交换(用于深度链接流程)
const { sessionToken, user } = await authApi.exchangeToken(loginToken);
// 登出
await authApi.logout();
```
#### User API (`services/api/userApi.ts`)
用户 profile 端点。
```typescript
import { userApi } from "../services/api/userApi";
// 获取当前用户
const user = await userApi.getCurrentUser();
// 更新 profile
const updated = await userApi.updateProfile({ firstName, lastName });
// 获取设置
const settings = await userApi.getSettings();
```
### Socket Service (`services/socketService.ts`)
用于实时通信的 Socket.io 客户端单例。
#### 特性
* 单例模式 - 每应用一个连接
* Auth token 通过 socket `auth` 对象传递
* 传输:先 polling,然后 WebSocket 升级
* 自动重连处理
#### API
```typescript
import socketService from "../services/socketService";
// 用 auth token 连接
socketService.connect(token);
// 断开
socketService.disconnect();
// 发射事件
socketService.emit("event-name", data);
// 监听事件
socketService.on("event-name", (data) => {
// 处理事件
});
// 移除监听器
socketService.off("event-name", handler);
// 一次性监听器
socketService.once("event-name", (data) => {
// 处理一次
});
// 获取 socket 实例
const socket = socketService.getSocket();
// 检查连接状态
const isConnected = socketService.isConnected();
```
#### 连接流程
```typescript
// 在 SocketProvider.tsx 中
useEffect(() => {
if (token) {
socketService.connect(token);
socketService.on("connect", () => {
dispatch(setSocketStatus({ userId, status: "connected" }));
dispatch(setSocketId({ userId, socketId: socket.id }));
// 初始化 MCP 服务器
initMCPServer(socketService.getSocket());
});
socketService.on("disconnect", () => {
dispatch(setSocketStatus({ userId, status: "disconnected" }));
});
}
return () => {
socketService.disconnect();
};
}, [token]);
```
#### 配置
```typescript
const socket = io(BACKEND_URL, {
auth: { token },
transports: ["polling", "websocket"],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
```
#### Socket 事件契约 (Tauri)
在 Tauri 模式下,连接和事件通过 **`utils/tauriSocket.ts`** (`setupTauriSocketListeners``connectRustSocket` 等) 桥接。见 `providers/SocketProvider.tsx` 获取完整流程(包括 daemon 生命周期 hook)。
### Core RPC (`services/coreRpcClient.ts`)
桌面应用运行一个单独的 **`openhuman`** Rust 二进制文件(staging 在 `app/src-tauri/binaries/` 下)。UI 通过 Tauri 调用该进程上的 JSON-RPC 方法:
```typescript
import { callCoreRpc } from "../services/coreRpcClient";
const result = await callCoreRpc<MyType>({
method: "some.openhuman.method",
params: {
/* … */
},
serviceManaged: false, // true 如果 relay 应确保 systemd/launchd 风格服务
});
```
实现:`invoke('core_rpc_relay', { request: { method, params, serviceManaged } })``app/src-tauri/src/commands/core_relay.rs``app/src-tauri/src/core_rpc.rs` 中的 HTTP 客户端。
### 服务与 provider 集成
#### SocketProvider
`app/src/providers/SocketProvider.tsx``auth.token` 存在时连接。在 **Tauri** 中,它优先使用 Rust-backed socket 路径;在 **web** 中,它使用 JS Socket.io 客户端。见源码获取日志和 `useDaemonLifecycle` 集成。
#### UserProvider、AIProvider、SkillProvider
这些包装用户 profile 加载、AI/记忆客户端协调和技能目录/同步。它们位于 `PersistGate` **内部** 和路由器旁边或外部,如 `App.tsx` 所示。
### 最佳实践
1. **使用单例** - 永远不要创建多个服务实例
2. **在 Redux 中存储会话** - 不用 localStorage
3. **卸载时清理** - 在 useEffect cleanup 中断开连接
4. **优雅处理错误** - 瞬态失败时重试
5. **通过正确通道传递 auth** - Socket auth 对象,而非 query string
***
## Providers
React context providers 管理服务生命周期并提供共享状态。
### Provider 链
providers 按特定顺序包装应用 (`app/src/App.tsx`)
```tsx
<Sentry.ErrorBoundary>
<Provider store={store}>
<PersistGate persistor={persistor} onBeforeLift={...}>
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<AppRoutes />
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
```
(`Router``react-router-dom``HashRouter`。)
**顺序重要,因为:**
1. Redux 在最外层用于 store 访问。
2. `PersistGate` 在子组件依赖 auth 前重新水合持久化的 slice。
3. `SocketProvider` 使用 store 中的 JWT。
4. `AIProvider` / `SkillProvider` 依赖 socket 和 store-backed 功能。
5. 路由器为所有路由提供导航。
### SocketProvider (`app/src/providers/SocketProvider.tsx`)
管理实时连接:**web** 使用 JS Socket.io 客户端;**Tauri** 通过 `utils/tauriSocket.ts` 桥接到 Rust socket 并向 Redux 报告状态。
#### 职责
* `auth.token` 可用时连接;清除时断开
* Tauri 中:安装监听器一次,连接 Rust socket,协调 daemon 生命周期 (`useDaemonLifecycle`)
* 更新 Redux socket slice / 连接状态
#### 实现
**`app/src/providers/SocketProvider.tsx`**。文件在 **`isTauri()`** 上分叉:web 模式直接使用 `socketService`Tauri 设置 `tauriSocket` 监听器和 `connectRustSocket` / `disconnectRustSocket`。不要将下方的伪代码视为实时实现。
#### 用法
```typescript
import { useSocket } from '../providers/SocketProvider';
function MyComponent() {
const { socket, isConnected, emit, on, off } = useSocket();
useEffect(() => {
const handler = (data) => console.log('Received:', data);
on('event-name', handler);
return () => off('event-name', handler);
}, [on, off]);
const sendMessage = () => {
emit('send-message', { text: 'Hello!' });
};
return (
<div>
<span>Status: {isConnected ? 'Connected' : 'Disconnected'}</span>
<button onClick={sendMessage}>Send</button>
</div>
);
}
```
### AIProvider (`app/src/providers/AIProvider.tsx`)
初始化 **memory**、**sessions**、**tool registry**(包括 memory + web-search 工具)、**entity manager**、**LLM / embedding providers** 和 **constitution** 加载。为子组件暴露 `useAI()`。重逻辑位于 `app/src/lib/ai/` 下。
### SkillProvider (`app/src/providers/SkillProvider.tsx`)
挂载时(认证后),通过 Tauri 辅助函数 (`runtimeDiscoverSkills`) 从 **QuickJS** 技能引擎发现技能,将 manifest 同步到 Redux,监听技能相关的 Tauri 事件,并可以在开发中自动启动配置的技能。
### UserProvider (`providers/UserProvider.tsx`)
最小用户 context provider(大多数用户状态在 Redux 中)。
#### 职责
* 兼容性用的遗留用户 context
* 可能弃用,改为 Redux
#### 实现
```typescript
interface UserContextValue {
user: User | null;
loading: boolean;
}
export function UserProvider({ children }) {
const user = useAppSelector((state) => state.user.profile);
const loading = useAppSelector((state) => state.user.loading);
return (
<UserContext.Provider value={{ user, loading }}>
{children}
</UserContext.Provider>
);
}
```
#### 用法
```typescript
import { useUserContext } from '../providers/UserProvider';
function Header() {
const { user, loading } = useUserContext();
if (loading) return <Skeleton />;
if (!user) return null;
return <span>Welcome, {user.firstName}</span>;
}
```
### Provider 模式
#### 基于 Effect 的生命周期
Providers 使用 `useEffect` 管理服务生命周期:
```typescript
useEffect(() => {
// 挂载或依赖变更时设置
service.connect();
// 卸载或依赖变更时清理
return () => {
service.disconnect();
};
}, [dependencies]);
```
#### Redux 集成
Providers 从 Redux 读取并 dispatch
```typescript
// 读取状态
const token = useAppSelector((state) => state.auth.token);
// Dispatch actions
const dispatch = useAppDispatch();
dispatch(setStatus({ userId, status: "connected" }));
```
#### 并行初始化
`SkillProvider``AIProvider` 可能在挂载时启动多个异步任务(技能发现、记忆初始化、constitution 加载)。优先阅读源码获取排序保证,而非假设到处都是并行 `Promise.all`
#### 会话恢复
Providers 在挂载时恢复持久化状态:
```typescript
useEffect(() => {
if (persistedSession) {
service.restoreSession(persistedSession);
}
}, [persistedSession]);
```
### Context vs Redux
| 使用 Context 用于 | 使用 Redux 用于 |
| ---------------------------------- | ---------------------------------- |
| 服务实例 (socket、client) | 可序列化状态 (status、data) |
| 方法 (emit、on、off) | 持久化状态 (sessions、tokens) |
| 派生值 | 复杂状态逻辑 |
示例:
* `SocketContext` 提供 `socket` 实例和 `emit` 方法
* Redux 存储 `socketStatus``socketId`
### 测试 Providers
#### 测试用的 Mock Provider
```typescript
// test-utils.tsx
const mockSocketContext: SocketContextValue = {
socket: null,
isConnected: true,
emit: jest.fn(),
on: jest.fn(),
off: jest.fn()
};
export function TestProviders({ children }) {
return (
<Provider store={testStore}>
<SocketContext.Provider value={mockSocketContext}>
{children}
</SocketContext.Provider>
</Provider>
);
}
```
#### 测试 Provider Effects
```typescript
test('SocketProvider 在 token 可用时连接', () => {
const store = createTestStore({ auth: { token: 'test-token' } });
render(
<Provider store={store}>
<SocketProvider>
<TestComponent />
</SocketProvider>
</Provider>
);
expect(socketService.connect).toHaveBeenCalledWith('test-token');
});
```
***
## Human Mascot 表面
Human 页面 (`app/src/features/human/HumanPage.tsx`) 在对话侧边栏旁渲染主
`YellowMascot`。mascot face 仍然来自 `useHumanMascot`,它订阅聊天生命周期事件以获取 thinking、
speaking、acknowledgement 和 error 状态。
子智能体委托由 `SubMascotLayer` 可视化。它不引入新的 socket 协议。相反,它读取已选或活跃 thread 的
`chatRuntime.toolTimelineByThread` 条目,`ChatRuntimeProvider` 已经从
`subagent_spawned``subagent_completed``subagent_failed`
`subagent_iteration_start``subagent_tool_call``subagent_tool_result` 构建了这些条目。
生命周期映射:
| Runtime timeline 状态 | Sub-mascot 状态 |
| ---------------------- | ---------------- |
| `running` | 带 thinking face 和短活动气泡的小型彩色 mascot |
| `success` | 相同 mascot 解析为 happy face 和完成气泡 |
| `error` | 相同 mascot 解析为 concerned face 和失败气泡 |
活动气泡文本有意保持紧凑:当前子工具调用、子迭代、委托提示摘录或最终状态。Thread timeline 仍然是权威的详细视图;sub-mascot 只是主 mascot 周围可一瞥的编排层。
***
## 页面与路由
应用使用 HashRouter 配合受保护和公共路由守卫。
### 路由结构
**`app/src/AppRoutes.tsx`** (HashRouter) 中定义。近似映射:
```text
/ → Welcome (公共包装器)
/onboarding → Onboarding (authonboarding 未完成)
/mnemonic → Mnemonic / 加密设置 (auth)
/home → Home (auth + onboarding + 加密密钥)
/intelligence → Intelligence (auth)
/skills → Skills (auth)
/conversations → Conversations (auth)
/invites → Invites (auth)
/agents → Agents (auth)
/settings/* → Settings (auth)
* → DefaultRedirect
```
`AppRoutes` 中**没有**顶级 `/login` 路由;认证流程通过 welcome/onboarding 和后端重定向处理。
### 路由配置 (`AppRoutes.tsx`)
```typescript
export function AppRoutes() {
return (
<>
<Routes>
{/* 公共路由 - 已认证时重定向 */}
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>
{/* 受保护路由 - 需要认证 */}
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
{/* 受保护 + 已 onboard 路由 */}
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>
{/* Fallback 重定向 */}
<Route path="*" element={<DefaultRedirect />} />
</Routes>
{/* 设置模态覆盖层 - 在路由之上渲染 */}
<SettingsModal />
</>
);
}
```
### 路由守卫
#### PublicRoute (`components/PublicRoute.tsx`)
将已认证用户从公共页面重定向走。
```typescript
export function PublicRoute() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (token) {
// 已认证 - 重定向到适当页面
return <Navigate to={isOnboarded ? "/home" : "/onboarding"} replace />;
}
return <Outlet />;
}
```
#### ProtectedRoute (`components/ProtectedRoute.tsx`)
强制执行认证和可选的 onboarding 状态。
```typescript
interface ProtectedRouteProps {
requireOnboarded?: boolean;
}
export function ProtectedRoute({ requireOnboarded = false }) {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (!token) {
return <Navigate to="/login" replace />;
}
if (requireOnboarded && !isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Outlet />;
}
```
#### DefaultRedirect (`components/DefaultRedirect.tsx`)
基于 auth 状态的 fallback 路由。
```typescript
export function DefaultRedirect() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (!token) {
return <Navigate to="/" replace />;
}
if (!isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Navigate to="/home" replace />;
}
```
### 页面
#### Welcome 页面 (`pages/Welcome.tsx`)
未认证用户的落地页。
**特性:**
* 应用介绍和品牌
* 登录/注册 CTA
* 公共路由(已认证时重定向)
#### Login 页面 (`pages/Login.tsx`)
认证页面。
**特性:**
* Telegram OAuth 按钮
* 在浏览器中打开 `/auth/telegram?platform=desktop`
* 处理深度链接回调
```typescript
export function Login() {
const handleTelegramLogin = () => {
// 在系统浏览器中打开 Telegram OAuth
openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`);
};
return (
<div className="login-page">
<TelegramLoginButton onClick={handleTelegramLogin} />
</div>
);
}
```
#### Home 页面 (`pages/Home.tsx`)
认证后的主仪表板。
**特性:**
* 受保护路由(需要 auth + onboarded
* 连接状态指示器
* 导航到设置模态
* 未来:聊天列表、消息等
```typescript
export function Home() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.user.profile);
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, user?.id),
);
return (
<div className="home-page">
<header>
<h1>Welcome, {user?.firstName}</h1>
<button onClick={() => navigate("/settings")}>Settings</button>
</header>
<TelegramConnectionIndicator status={telegramStatus} />
<ConnectionIndicator />
{/* 主内容 */}
</div>
);
}
```
### Onboarding 流程 (`pages/onboarding/`)
多步 onboarding 流程。
#### 结构
```text
pages/onboarding/
├── Onboarding.tsx # 流程控制器
└── steps/
├── GetStartedStep.tsx # Welcome
├── PrivacyStep.tsx # 隐私政策
├── AnalyticsStep.tsx # Analytics 选择加入
├── ConnectStep.tsx # Telegram 连接
└── FeaturesStep.tsx # 特性概览
```
#### Onboarding 控制器 (`Onboarding.tsx`)
```typescript
const STEPS = [
{ id: "get-started", component: GetStartedStep },
{ id: "privacy", component: PrivacyStep },
{ id: "analytics", component: AnalyticsStep },
{ id: "connect", component: ConnectStep },
{ id: "features", component: FeaturesStep },
];
export function Onboarding() {
const [currentStep, setCurrentStep] = useState(0);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
// 完成 onboarding
dispatch(setOnboarded({ userId, isOnboarded: true }));
navigate("/home");
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const StepComponent = STEPS[currentStep].component;
return (
<div className="onboarding">
<ProgressIndicator current={currentStep} total={STEPS.length} />
<StepComponent onNext={handleNext} onBack={handleBack} />
</div>
);
}
```
#### Step 组件
每个 step 接收 `onNext``onBack` 回调:
```typescript
interface StepProps {
onNext: () => void;
onBack: () => void;
}
export function ConnectStep({ onNext, onBack }: StepProps) {
const [showModal, setShowModal] = useState(false);
const telegramStatus = useAppSelector(/* ... */);
return (
<div className="step">
<h2>Connect Your Accounts</h2>
{connectOptions.map((option) => (
<ConnectionOption
key={option.id}
{...option}
onClick={() => option.id === "telegram" && setShowModal(true)}
/>
))}
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
<div className="actions">
<button onClick={onBack}>Back</button>
<button onClick={onNext}>Continue</button>
</div>
</div>
);
}
```
### 设置模态路由
设置模态使用基于 URL 的路由覆盖现有内容。
#### 模态检测
```typescript
// 在 SettingsModal.tsx 中
const location = useLocation();
const isOpen = location.pathname.startsWith("/settings");
```
#### 子路由
```text
/settings → SettingsHome (主菜单)
/settings/connections → ConnectionsPanel
/settings/messaging → MessagingPanel (未来)
/settings/privacy → PrivacyPanel (未来)
/settings/profile → ProfilePanel (未来)
/settings/advanced → AdvancedPanel (未来)
/settings/billing → BillingPanel (未来)
```
#### 导航
```typescript
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsHome() {
const { navigateTo, closeModal } = useSettingsNavigation();
return (
<div>
<SettingsMenuItem
label="Connections"
onClick={() => navigateTo("connections")}
/>
<button onClick={closeModal}>Close</button>
</div>
);
}
```
### HashRouter vs BrowserRouter
应用使用 HashRouter 以兼容桌面:
```typescript
// App.tsx
import { HashRouter } from "react-router-dom";
// URL 看起来像这样:app://localhost/#/home
// 而不是:app://localhost/home
```
**为什么用 HashRouter**
1. Tauri 深度链接与基于 hash 的 URL 配合工作
2. 不需要服务器配置
3. 与 file:// 协议配合工作
4. 防止直接 URL 访问时的 404
### 深度链接处理
深度链接在路由前处理:
```typescript
// main.tsx
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
监听器拦截 `openhuman://auth?token=...` 并:
1. 通过 Rust 命令交换 token
2. 在 Redux 中存储会话
3. 导航到 `/onboarding``/home`
### 导航模式
#### 程序化导航
```typescript
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
// 导航到路由
navigate("/home");
// 替换历史条目
navigate("/login", { replace: true });
// 返回
navigate(-1);
```
#### Link 组件
```typescript
import { Link } from "react-router-dom";
<Link to="/settings">Settings</Link>;
```
#### 状态传递
```typescript
// 向路由传递状态
navigate("/details", { state: { itemId: 123 } });
// 接收状态
const location = useLocation();
const { itemId } = location.state;
```
***
## 组件
按功能组织的可复用 React 组件。
### 组件结构
```text
components/
├── Route Guards
│ ├── ProtectedRoute.tsx
│ ├── PublicRoute.tsx
│ └── DefaultRedirect.tsx
├── Authentication
│ └── TelegramLoginButton.tsx
├── Connection Status
│ ├── ConnectionIndicator.tsx
│ ├── TelegramConnectionIndicator.tsx
│ ├── TelegramConnectionModal.tsx
│ └── GmailConnectionIndicator.tsx
├── Onboarding
│ ├── ProgressIndicator.tsx
│ └── LottieAnimation.tsx
├── Settings Modal (16 files)
│ ├── SettingsModal.tsx
│ ├── SettingsLayout.tsx
│ ├── SettingsHome.tsx
│ ├── panels/
│ ├── components/
│ └── hooks/
└── Development
└── DesignSystemShowcase.tsx
```
### 路由守卫组件
#### ProtectedRoute
需要认证和可选的 onboarding。
```typescript
interface ProtectedRouteProps {
requireOnboarded?: boolean;
}
// 在 AppRoutes.tsx 中的用法
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>
```
#### PublicRoute
将已认证用户重定向走。
```typescript
// 在 AppRoutes.tsx 中的用法
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>
```
#### DefaultRedirect
基于 auth 状态的 fallback。
```typescript
// 重定向到:
// - "/" 如果未认证
// - "/onboarding" 如果已认证但未 onboard
// - "/home" 如果已认证且已 onboard
```
### 认证组件
#### TelegramLoginButton
Telegram 的 OAuth 登录按钮。
```typescript
interface TelegramLoginButtonProps {
onClick: () => void;
disabled?: boolean;
}
// 用法
<TelegramLoginButton
onClick={() => openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`)}
/>
```
### 连接状态组件
#### ConnectionIndicator
通用连接状态徽章。
```typescript
interface ConnectionIndicatorProps {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
label?: string;
}
<ConnectionIndicator status="connected" label="Socket" />
```
#### TelegramConnectionIndicator
Telegram 特定的状态显示。
```typescript
interface TelegramConnectionIndicatorProps {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
}
// 配合 Redux 状态使用
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, userId)
);
<TelegramConnectionIndicator status={telegramStatus} />
```
#### TelegramConnectionModal
设置 Telegram 连接的模态。
```typescript
interface TelegramConnectionModalProps {
isOpen: boolean;
onClose: () => void;
}
// 在 onboarding/settings 中的用法
const [showModal, setShowModal] = useState(false);
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
```
**特性:**
* QR 码登录流程
* 手机号登录流程
* 连接状态显示
* 错误处理
#### GmailConnectionIndicator
Gmail 状态徽章(未来集成)。
```typescript
<GmailConnectionIndicator status="coming-soon" />
```
### Onboarding 组件
#### ProgressIndicator
通过 onboarding step 的视觉进度。
```typescript
interface ProgressIndicatorProps {
current: number;
total: number;
}
<ProgressIndicator current={2} total={5} />
```
#### LottieAnimation
Onboarding 的 Lottie 动画播放器。
```typescript
interface LottieAnimationProps {
animationData: object;
loop?: boolean;
autoplay?: boolean;
className?: string;
}
import welcomeAnimation from '../assets/animations/welcome.json';
<LottieAnimation
animationData={welcomeAnimation}
loop={true}
autoplay={true}
/>
```
### 设置模态系统
带基于 URL 路由的完整模态系统。
#### 文件结构
```text
components/settings/
├── SettingsModal.tsx # 基于路由的容器
├── SettingsLayout.tsx # Portal + 背景包装器
├── SettingsHome.tsx # 带 profile 的主菜单
├── panels/
│ ├── ConnectionsPanel.tsx # 连接管理
│ ├── MessagingPanel.tsx # (未来)
│ ├── PrivacyPanel.tsx # (未来)
│ ├── ProfilePanel.tsx # (未来)
│ ├── AdvancedPanel.tsx # (未来)
│ └── BillingPanel.tsx # (未来)
├── components/
│ ├── SettingsHeader.tsx # 用户 profile 部分
│ ├── SettingsMenuItem.tsx # 菜单项组件
│ ├── SettingsBackButton.tsx # 返回导航
│ └── SettingsPanelLayout.tsx# Panel 包装器
└── hooks/
├── useSettingsNavigation.ts # URL 路由
└── useSettingsAnimation.ts # 动画状态
```
#### SettingsModal
基于 URL 渲染的主容器。
```typescript
export function SettingsModal() {
const location = useLocation();
const isOpen = location.pathname.startsWith('/settings');
if (!isOpen) return null;
return (
<SettingsLayout>
{/* 路由到适当的 panel */}
{location.pathname === '/settings' && <SettingsHome />}
{location.pathname === '/settings/connections' && <ConnectionsPanel />}
{/* ... 更多 panels */}
</SettingsLayout>
);
}
```
#### SettingsLayout
基于 Portal 的模态包装器。
```typescript
export function SettingsLayout({ children }) {
const { closeModal } = useSettingsNavigation();
return createPortal(
<div className="fixed inset-0 z-50">
{/* 背景 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={closeModal}
/>
{/* 模态 */}
<div className="absolute inset-4 flex items-center justify-center">
<div className="bg-white rounded-2xl w-full max-w-[520px] shadow-xl">
{children}
</div>
</div>
</div>,
document.body
);
}
```
#### SettingsHome
带用户 profile 的主菜单。
```typescript
export function SettingsHome() {
const { navigateTo, closeModal } = useSettingsNavigation();
const user = useAppSelector((state) => state.user.profile);
const menuItems = [
{ id: 'connections', label: 'Connections', icon: LinkIcon },
{ id: 'messaging', label: 'Messaging', icon: MessageIcon },
{ id: 'privacy', label: 'Privacy', icon: ShieldIcon },
// ... 更多项
];
return (
<div>
<SettingsHeader user={user} onClose={closeModal} />
{menuItems.map((item) => (
<SettingsMenuItem
key={item.id}
{...item}
onClick={() => navigateTo(item.id)}
/>
))}
</div>
);
}
```
#### ConnectionsPanel
连接管理界面。
```typescript
export function ConnectionsPanel() {
const { navigateBack } = useSettingsNavigation();
const [telegramModalOpen, setTelegramModalOpen] = useState(false);
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, userId)
);
// 复用 onboarding 中的 connectOptions
const connections = connectOptions.map((opt) => ({
...opt,
status: opt.id === 'telegram' ? telegramStatus : 'coming-soon'
}));
return (
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{connections.map((conn) => (
<ConnectionItem
key={conn.id}
{...conn}
onConnect={() => conn.id === 'telegram' && setTelegramModalOpen(true)}
/>
))}
<TelegramConnectionModal
isOpen={telegramModalOpen}
onClose={() => setTelegramModalOpen(false)}
/>
</SettingsPanelLayout>
);
}
```
#### 设置 Hooks
**useSettingsNavigation**
设置模态的基于 URL 导航。
```typescript
interface UseSettingsNavigationReturn {
currentRoute: string;
navigateTo: (panel: string) => void;
navigateBack: () => void;
closeModal: () => void;
}
const { navigateTo, navigateBack, closeModal } = useSettingsNavigation();
// 导航到 panel
navigateTo('connections'); // → /settings/connections
// 返回
navigateBack(); // → /settings
// 关闭模态
closeModal(); // → 之前的非设置路由
```
**useSettingsAnimation**
设置模态的动画状态管理。
```typescript
interface UseSettingsAnimationReturn {
isEntering: boolean;
isExiting: boolean;
animationClass: string;
}
const { animationClass } = useSettingsAnimation();
<div className={`modal ${animationClass}`}>{/* Content */}</div>
```
#### 设置组件
**SettingsHeader**
设置顶部的用户 profile 部分。
```typescript
interface SettingsHeaderProps {
user: User | null;
onClose: () => void;
}
<SettingsHeader user={user} onClose={handleClose} />
```
**SettingsMenuItem**
带图标和 chevron 的单个菜单项。
```typescript
interface SettingsMenuItemProps {
label: string;
icon: React.ComponentType;
onClick: () => void;
badge?: string;
disabled?: boolean;
}
<SettingsMenuItem
label="Connections"
icon={LinkIcon}
onClick={() => navigateTo('connections')}
badge="2"
/>
```
**SettingsBackButton**
返回导航按钮。
```typescript
interface SettingsBackButtonProps {
onClick: () => void;
}
<SettingsBackButton onClick={navigateBack} />
```
**SettingsPanelLayout**
设置 panel 的包装器。
```typescript
interface SettingsPanelLayoutProps {
title: string;
onBack: () => void;
children: React.ReactNode;
}
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{/* Panel content */}
</SettingsPanelLayout>
```
### 组件模式
#### 复用连接选项
`connectOptions` 数组在 onboarding 和 settings 之间共享:
```typescript
// 在 ConnectStep.tsx 中定义,在其他地方导入
export const connectOptions = [
{
id: 'telegram',
label: 'Telegram',
icon: TelegramIcon,
description: 'Connect your Telegram account',
},
{
id: 'gmail',
label: 'Gmail',
icon: GmailIcon,
description: 'Connect your Gmail account',
comingSoon: true,
},
];
```
#### 通过 Portal 的模态
设置模态使用 `createPortal` 在组件树外部渲染:
```typescript
return createPortal(
<div className="modal-container">
{/* 模态内容 */}
</div>,
document.body
);
```
#### 受控 vs 非受控
连接模态是受控组件:
```typescript
// 父级控制 open 状态
const [isOpen, setIsOpen] = useState(false);
<TelegramConnectionModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
/>
```
***
## Hook 与工具
自定义 React hook 和工具函数。
### 自定义 Hooks
#### useSocket (`hooks/useSocket.ts`)
从任何组件访问 Socket.io 功能。
```typescript
interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
emit: (event: string, data: unknown) => void;
on: (event: string, handler: Function) => void;
off: (event: string, handler: Function) => void;
once: (event: string, handler: Function) => void;
}
function useSocket(): UseSocketReturn;
```
**用法:**
```typescript
import { useSocket } from "../hooks/useSocket";
function ChatInput() {
const { emit, isConnected } = useSocket();
const sendMessage = (text: string) => {
if (isConnected) {
emit("chat:message", { text });
}
};
return (
<input
disabled={!isConnected}
onKeyDown={(e) => e.key === "Enter" && sendMessage(e.target.value)}
/>
);
}
```
**配合事件监听器:**
```typescript
function Notifications() {
const { on, off } = useSocket();
const [notifications, setNotifications] = useState([]);
useEffect(() => {
const handler = (notification) => {
setNotifications((prev) => [...prev, notification]);
};
on("notification", handler);
return () => off("notification", handler);
}, [on, off]);
return <NotificationList items={notifications} />;
}
```
#### useUser (`hooks/useUser.ts`)
访问用户 profile 数据和加载状态。
```typescript
interface UseUserReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
function useUser(): UseUserReturn;
```
**用法:**
```typescript
import { useUser } from "../hooks/useUser";
function ProfileHeader() {
const { user, loading, error, refetch } = useUser();
if (loading) return <Skeleton />;
if (error) return <Error message={error} onRetry={refetch} />;
if (!user) return null;
return (
<div className="profile">
<Avatar src={user.avatar} />
<span>
{user.firstName} {user.lastName}
</span>
</div>
);
}
```
#### 设置模态 Hooks
**useSettingsNavigation (`components/settings/hooks/useSettingsNavigation.ts`)**
设置模态的基于 URL 导航。
```typescript
interface UseSettingsNavigationReturn {
currentRoute: string; // 当前设置路径
navigateTo: (panel: string) => void; // 导航到 panel
navigateBack: () => void; // 返回一级
closeModal: () => void; // 完全关闭设置
}
function useSettingsNavigation(): UseSettingsNavigationReturn;
```
**用法:**
```typescript
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsMenu() {
const { navigateTo, closeModal } = useSettingsNavigation();
return (
<nav>
<button onClick={() => navigateTo("connections")}>Connections</button>
<button onClick={() => navigateTo("privacy")}>Privacy</button>
<button onClick={closeModal}>Close</button>
</nav>
);
}
```
**useSettingsAnimation (`components/settings/hooks/useSettingsAnimation.ts`)**
设置模态的动画状态管理。
```typescript
interface UseSettingsAnimationReturn {
isEntering: boolean; // 模态正在动画进入
isExiting: boolean; // 模态正在动画退出
animationClass: string; // 当前状态的 CSS 类
}
function useSettingsAnimation(): UseSettingsAnimationReturn;
```
**用法:**
```typescript
import { useSettingsAnimation } from "./hooks/useSettingsAnimation";
function SettingsModal() {
const { animationClass, isExiting } = useSettingsAnimation();
return <div className={`modal ${animationClass}`}>{/* Content */}</div>;
}
```
### 工具
#### 配置 (`utils/config.ts`)
构建时环境变量访问。这些常量只携带烘焙到 bundle 中的值,对于应用实际通信的**运行时** URL,见 `services/backendUrl` 和下方的 `hooks/useBackendUrl`
```typescript
// 仅构建时 fallback(在 Tauri 外使用)。
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.example.com';
// 调试模式
export const DEBUG = import.meta.env.VITE_DEBUG === 'true';
```
**用法(仅构建时、feature flag、调试开关、…):**
```typescript
import { DEBUG } from '../utils/config';
if (DEBUG) {
console.log('debug enabled');
}
```
> **不要**直接导入 `BACKEND_URL` 来发起 API 调用。在运行时解析 URL,以便核心 sidecar 的 `api_url`(通过登录屏幕上的 `openhuman.config_resolve_api_url` 设置)生效:
>
> ```typescript
> // React 组件
> import { useBackendUrl } from '../hooks/useBackendUrl';
> const backendUrl = useBackendUrl();
>
> // 非 React 代码
> import { getBackendUrl } from '../services/backendUrl';
> const backendUrl = await getBackendUrl();
> ```
#### 深度链接 (`utils/deeplink.ts`)
为认证交接构建深度链接 URL。
```typescript
// 构建 auth 深度链接
function buildAuthDeepLink(token: string): string;
// 解析深度链接 URL
function parseDeepLink(url: string): { path: string; params: URLSearchParams };
```
**用法:**
```typescript
import { buildAuthDeepLink } from '../utils/deeplink';
// 为浏览器重定向构建 URL
const deepLink = buildAuthDeepLink(loginToken);
// → "openhuman://auth?token=abc123"
// 在 Web 前端 auth 后:
window.location.href = deepLink;
```
#### 桌面深度链接监听器 (`utils/desktopDeepLinkListener.ts`)
在桌面应用中处理传入的深度链接。
```typescript
// 设置深度链接事件监听器
async function setupDesktopDeepLinkListener(): Promise<void>;
```
**在 main.tsx 中调用:**
```typescript
// 懒加载以确保 Tauri IPC 就绪
import('./utils/desktopDeepLinkListener').then(m => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
**它做什么:**
1. 监听来自 Tauri 深度链接插件的 `onOpenUrl` 事件
2. 解析 `openhuman://auth?token=...` URL
3. 调用 Rust `exchange_token` 命令(绕过 CORS
4. 在 Redux 中存储会话
5. 导航到 `/onboarding``/home`
**循环预防:**
```typescript
// 导航前设置 flag 以防止重新处理
localStorage.setItem('deepLinkHandled', 'true');
window.location.replace('/');
// 下次加载时,清除 flag
if (localStorage.getItem('deepLinkHandled') === 'true') {
localStorage.removeItem('deepLinkHandled');
return; // 不再处理
}
```
#### URL 打开器 (`utils/openUrl.ts`)
跨平台 URL 打开。
```typescript
// 在系统浏览器中打开 URL
async function openUrl(url: string): Promise<void>;
```
**用法:**
```typescript
import { openUrl } from '../utils/openUrl';
// 在系统浏览器中打开(非应用内 WebView)
await openUrl('https://telegram.org/auth');
```
**实现:**
```typescript
export async function openUrl(url: string): Promise<void> {
try {
// 先尝试 Tauri opener 插件
const { open } = await import('@tauri-apps/plugin-opener');
await open(url);
} catch {
// Fallback 到浏览器 API
window.open(url, '_blank');
}
}
```
### Polyfills (`polyfills.ts`)
浏览器环境的 Node.js polyfills。
`telegram` npm 包需要 Node.js API。这些被 polyfill
```typescript
// polyfills.ts
import { Buffer } from 'buffer';
import process from 'process';
import util from 'util';
window.Buffer = Buffer;
window.process = process;
window.util = util;
```
**在应用入口导入:**
```typescript
// main.tsx
import './polyfills';
// ... 应用的其余部分
```
**Vite 配置:**
```typescript
// vite.config.ts
export default defineConfig({
resolve: { alias: { buffer: 'buffer', process: 'process/browser', util: 'util' } },
define: { 'process.env': {}, global: 'globalThis' },
});
```
### 类型
#### API 类型 (`types/api.ts`)
```typescript
// API 响应包装器
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// API 错误
interface ApiError {
code: string;
message: string;
details?: unknown;
}
// User 接口
interface User {
id: string;
firstName: string;
lastName?: string;
username?: string;
email?: string;
avatar?: string;
telegramId?: string;
subscription?: SubscriptionInfo;
usage?: UsageInfo;
createdAt: string;
updatedAt: string;
}
```
#### Onboarding 类型 (`types/onboarding.ts`)
```typescript
// Onboarding step 定义
interface OnboardingStep {
id: string;
title: string;
component: React.ComponentType<StepProps>;
}
// Step 组件 props
interface StepProps {
onNext: () => void;
onBack: () => void;
}
// 连接选项
interface ConnectionOption {
id: string;
label: string;
icon: React.ComponentType;
description: string;
comingSoon?: boolean;
}
```
### 静态数据
#### 国家 (`data/countries.ts`)
手机号输入的国家列表。
```typescript
interface Country {
code: string; // "US"
name: string; // "United States"
dialCode: string; // "+1"
flag: string; // "🇺🇸"
}
export const countries: Country[];
```
**用法:**
```typescript
import { countries } from "../data/countries";
function PhoneInput() {
const [country, setCountry] = useState(countries[0]);
return (
<div>
<select
value={country.code}
onChange={(e) =>
setCountry(countries.find((c) => c.code === e.target.value))
}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.flag} {c.name} ({c.dialCode})
</option>
))}
</select>
<input placeholder="Phone number" />
</div>
);
}
```
### 最佳实践
#### Hook 依赖
始终在 useEffect 中包含依赖:
```typescript
// 好
useEffect(() => {
on('event', handler);
return () => off('event', handler);
}, [on, off, handler]);
// 坏 - 缺失依赖
useEffect(() => {
on('event', handler);
return () => off('event', handler);
}, []);
```
#### 清理函数
始终清理订阅:
```typescript
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);
```
#### 错误边界
将工具调用包装在 try-catch 中:
```typescript
try {
await openUrl(url);
} catch (error) {
console.error('Failed to open URL:', error);
// Fallback 行为
}
```
#### 类型安全
对 API 调用使用 TypeScript 泛型:
```typescript
const user = await apiClient.get<User>('/users/me');
// user 被类型化为 User
```
***