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

55 KiB
Raw Blame History

description, icon
description icon
React + Vite 前端 (`app/src/`) —— 架构、状态、服务、 提供商、路由、组件、hook。 browsers

前端 (app/src/)

OpenHuman 桌面 UIapp/src/ 下的 Vite + React 19 树(Yarn workspace openhuman-app)。它使用 Redux Toolkit 配合持久化来管理会话状态,通过 REST + Socket.io 与后端通信,并通过 JSON-RPC 调用 Rust core sidecarcoreRpcClient / Tauri core_rpc_relay)。重逻辑在核心中,不在此处。

这是一份整合的参考。使用上方目录(或你的阅读器大纲)在章节间跳转。

快速参考

章节 涵盖内容
架构 Provider 链、构建、布局、规范
状态管理 Redux Toolkit slice、selector、持久化
服务层 apiClientsocketServicecoreRpcClient
Providers UserSocketAISkill providers
页面与路由 HashRouter、路由守卫、主路由
组件 UI / 设置组件模式
Hook 与工具 共享 hook、辅助函数、配置

规模

指标
app/src/ 下的 TypeScript / TSX 文件 ~285 (find app/src -name '*.ts' -o -name '*.tsx' | wc -l 刷新)
测试 runner Vitest (app/test/vitest.config.ts)

目录布局

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 链

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 为所有路由提供导航。

模块关系(简化)

App.tsx
  ├─ Redux store + persistor
  ├─ UserProvider - 用户 profile / workspace 上下文
  ├─ SocketProvider - token 存在时连接 socketService
  ├─ AIProvider - AI 会话 / 记忆客户端协调
  ├─ SkillProvider - 技能目录和同步
  └─ AppRoutes
       ├─ PublicRoute - 例如 `/` 上的 Welcome
       ├─ ProtectedRoute - onboarding、home、skills、settings、…
       └─ DefaultRedirect - 未认证用户

服务层(概念性)

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 常量,那只代表构建时值。

相关文档

状态管理

应用使用 Redux Toolkit 配合 Redux-Persist 进行健壮的状态管理。

Store 配置

文件: store/index.ts

// 合并所有 slice 并持久化
const persistConfig = {
  key: 'root',
  storage,
  whitelist: ['auth', 'telegram'], // 持久化的 slice
};

Redux 状态结构

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 状态。

状态:

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 连接状态。

状态:

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 数据。

状态:

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 - 异步操作

状态结构:

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

// 使用这些代替普通的 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 使用。

使用示例

读取状态

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

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

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 - useAppDispatchuseAppSelector
  2. 使用 selector 处理派生状态 - 可记忆且可测试
  3. 将 thunks 放在单独文件中 - 更好的组织
  4. 每用户状态作用域 - 按用户 ID 键控状态
  5. 避免 localStorage - 改用 Redux-Persist

服务层

应用使用单例服务进行外部通信。这防止连接泄漏并提供一致的 API 访问。

服务架构

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
  • 类型化的请求/响应处理
  • 带类型错误的错误处理

用法

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 或使用默认值:

const BACKEND_URL =
  import.meta.env.VITE_BACKEND_URL || "https://api.example.com";

API Endpoints (services/api/)

Auth API (services/api/authApi.ts)

认证相关端点。

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 端点。

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

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();

连接流程

// 在 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]);

配置

const socket = io(BACKEND_URL, {
  auth: { token },
  transports: ["polling", "websocket"],
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: 1000,
});

Socket 事件契约 (Tauri)

在 Tauri 模式下,连接和事件通过 utils/tauriSocket.ts (setupTauriSocketListenersconnectRustSocket 等) 桥接。见 providers/SocketProvider.tsx 获取完整流程(包括 daemon 生命周期 hook)。

Core RPC (services/coreRpcClient.ts)

桌面应用运行一个单独的 openhuman Rust 二进制文件(staging 在 app/src-tauri/binaries/ 下)。UI 通过 Tauri 调用该进程上的 JSON-RPC 方法:

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.rsapp/src-tauri/src/core_rpc.rs 中的 HTTP 客户端。

服务与 provider 集成

SocketProvider

app/src/providers/SocketProvider.tsxauth.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)

<Sentry.ErrorBoundary>
  <Provider store={store}>
    <PersistGate persistor={persistor} onBeforeLift={...}>
      <UserProvider>
        <SocketProvider>
          <AIProvider>
            <SkillProvider>
              <Router>
                <AppRoutes />
              </Router>
            </SkillProvider>
          </AIProvider>
        </SocketProvider>
      </UserProvider>
    </PersistGate>
  </Provider>
</Sentry.ErrorBoundary>

(Routerreact-router-domHashRouter。)

顺序重要,因为:

  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 模式直接使用 socketServiceTauri 设置 tauriSocket 监听器和 connectRustSocket / disconnectRustSocket。不要将下方的伪代码视为实时实现。

用法

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)

初始化 memorysessionstool registry(包括 memory + web-search 工具)、entity managerLLM / embedding providersconstitution 加载。为子组件暴露 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

实现

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>
  );
}

用法

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 管理服务生命周期:

useEffect(() => {
  // 挂载或依赖变更时设置
  service.connect();

  // 卸载或依赖变更时清理
  return () => {
    service.disconnect();
  };
}, [dependencies]);

Redux 集成

Providers 从 Redux 读取并 dispatch

// 读取状态
const token = useAppSelector((state) => state.auth.token);

// Dispatch actions
const dispatch = useAppDispatch();
dispatch(setStatus({ userId, status: "connected" }));

并行初始化

SkillProviderAIProvider 可能在挂载时启动多个异步任务(技能发现、记忆初始化、constitution 加载)。优先阅读源码获取排序保证,而非假设到处都是并行 Promise.all

会话恢复

Providers 在挂载时恢复持久化状态:

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 存储 socketStatussocketId

测试 Providers

测试用的 Mock Provider

// 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

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_spawnedsubagent_completedsubagent_failedsubagent_iteration_startsubagent_tool_callsubagent_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) 中定义。近似映射:

/                  → 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)

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)

将已认证用户从公共页面重定向走。

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 状态。

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 路由。

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
  • 处理深度链接回调
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
  • 连接状态指示器
  • 导航到设置模态
  • 未来:聊天列表、消息等
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 流程。

结构

pages/onboarding/
├── Onboarding.tsx           # 流程控制器
└── steps/
    ├── GetStartedStep.tsx   # Welcome
    ├── PrivacyStep.tsx      # 隐私政策
    ├── AnalyticsStep.tsx    # Analytics 选择加入
    ├── ConnectStep.tsx      # Telegram 连接
    └── FeaturesStep.tsx     # 特性概览

Onboarding 控制器 (Onboarding.tsx)

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 接收 onNextonBack 回调:

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 的路由覆盖现有内容。

模态检测

// 在 SettingsModal.tsx 中
const location = useLocation();
const isOpen = location.pathname.startsWith("/settings");

子路由

/settings              → SettingsHome (主菜单)
/settings/connections  → ConnectionsPanel
/settings/messaging    → MessagingPanel (未来)
/settings/privacy      → PrivacyPanel (未来)
/settings/profile      → ProfilePanel (未来)
/settings/advanced     → AdvancedPanel (未来)
/settings/billing      → BillingPanel (未来)

导航

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 以兼容桌面:

// 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

深度链接处理

深度链接在路由前处理:

// main.tsx
import("./utils/desktopDeepLinkListener").then((m) => {
  m.setupDesktopDeepLinkListener().catch(console.error);
});

监听器拦截 openhuman://auth?token=... 并:

  1. 通过 Rust 命令交换 token
  2. 在 Redux 中存储会话
  3. 导航到 /onboarding/home

导航模式

程序化导航

import { useNavigate } from "react-router-dom";

const navigate = useNavigate();

// 导航到路由
navigate("/home");

// 替换历史条目
navigate("/login", { replace: true });

// 返回
navigate(-1);
import { Link } from "react-router-dom";

<Link to="/settings">Settings</Link>;

状态传递

// 向路由传递状态
navigate("/details", { state: { itemId: 123 } });

// 接收状态
const location = useLocation();
const { itemId } = location.state;

组件

按功能组织的可复用 React 组件。

组件结构

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。

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

将已认证用户重定向走。

// 在 AppRoutes.tsx 中的用法
<Route element={<PublicRoute />}>
  <Route path="/" element={<Welcome />} />
  <Route path="/login" element={<Login />} />
</Route>

DefaultRedirect

基于 auth 状态的 fallback。

// 重定向到:
// - "/" 如果未认证
// - "/onboarding" 如果已认证但未 onboard
// - "/home" 如果已认证且已 onboard

认证组件

TelegramLoginButton

Telegram 的 OAuth 登录按钮。

interface TelegramLoginButtonProps {
  onClick: () => void;
  disabled?: boolean;
}

// 用法
<TelegramLoginButton
  onClick={() => openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`)}
/>

连接状态组件

ConnectionIndicator

通用连接状态徽章。

interface ConnectionIndicatorProps {
  status: 'connected' | 'connecting' | 'disconnected' | 'error';
  label?: string;
}

<ConnectionIndicator status="connected" label="Socket" />

TelegramConnectionIndicator

Telegram 特定的状态显示。

interface TelegramConnectionIndicatorProps {
  status: 'connected' | 'connecting' | 'disconnected' | 'error';
}

// 配合 Redux 状态使用
const telegramStatus = useAppSelector((state) =>
  selectTelegramConnectionStatus(state, userId)
);

<TelegramConnectionIndicator status={telegramStatus} />

TelegramConnectionModal

设置 Telegram 连接的模态。

interface TelegramConnectionModalProps {
  isOpen: boolean;
  onClose: () => void;
}

// 在 onboarding/settings 中的用法
const [showModal, setShowModal] = useState(false);

<TelegramConnectionModal
  isOpen={showModal}
  onClose={() => setShowModal(false)}
/>

特性:

  • QR 码登录流程
  • 手机号登录流程
  • 连接状态显示
  • 错误处理

GmailConnectionIndicator

Gmail 状态徽章(未来集成)。

<GmailConnectionIndicator status="coming-soon" />

Onboarding 组件

ProgressIndicator

通过 onboarding step 的视觉进度。

interface ProgressIndicatorProps {
  current: number;
  total: number;
}

<ProgressIndicator current={2} total={5} />

LottieAnimation

Onboarding 的 Lottie 动画播放器。

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 路由的完整模态系统。

文件结构

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 渲染的主容器。

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 的模态包装器。

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 的主菜单。

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

连接管理界面。

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 导航。

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

设置模态的动画状态管理。

interface UseSettingsAnimationReturn {
  isEntering: boolean;
  isExiting: boolean;
  animationClass: string;
}

const { animationClass } = useSettingsAnimation();

<div className={`modal ${animationClass}`}>{/* Content */}</div>

设置组件

SettingsHeader

设置顶部的用户 profile 部分。

interface SettingsHeaderProps {
  user: User | null;
  onClose: () => void;
}

<SettingsHeader user={user} onClose={handleClose} />

SettingsMenuItem

带图标和 chevron 的单个菜单项。

interface SettingsMenuItemProps {
  label: string;
  icon: React.ComponentType;
  onClick: () => void;
  badge?: string;
  disabled?: boolean;
}

<SettingsMenuItem
  label="Connections"
  icon={LinkIcon}
  onClick={() => navigateTo('connections')}
  badge="2"
/>

SettingsBackButton

返回导航按钮。

interface SettingsBackButtonProps {
  onClick: () => void;
}

<SettingsBackButton onClick={navigateBack} />

SettingsPanelLayout

设置 panel 的包装器。

interface SettingsPanelLayoutProps {
  title: string;
  onBack: () => void;
  children: React.ReactNode;
}

<SettingsPanelLayout title="Connections" onBack={navigateBack}>
  {/* Panel content */}
</SettingsPanelLayout>

组件模式

复用连接选项

connectOptions 数组在 onboarding 和 settings 之间共享:

// 在 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 在组件树外部渲染:

return createPortal(
  <div className="modal-container">
    {/* 模态内容 */}
  </div>,
  document.body
);

受控 vs 非受控

连接模态是受控组件:

// 父级控制 open 状态
const [isOpen, setIsOpen] = useState(false);

<TelegramConnectionModal
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
/>

Hook 与工具

自定义 React hook 和工具函数。

自定义 Hooks

useSocket (hooks/useSocket.ts)

从任何组件访问 Socket.io 功能。

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;

用法:

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)}
    />
  );
}

配合事件监听器:

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 数据和加载状态。

interface UseUserReturn {
  user: User | null;
  loading: boolean;
  error: string | null;
  refetch: () => Promise<void>;
}

function useUser(): UseUserReturn;

用法:

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 导航。

interface UseSettingsNavigationReturn {
  currentRoute: string; // 当前设置路径
  navigateTo: (panel: string) => void; // 导航到 panel
  navigateBack: () => void; // 返回一级
  closeModal: () => void; // 完全关闭设置
}

function useSettingsNavigation(): UseSettingsNavigationReturn;

用法:

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)

设置模态的动画状态管理。

interface UseSettingsAnimationReturn {
  isEntering: boolean; // 模态正在动画进入
  isExiting: boolean; // 模态正在动画退出
  animationClass: string; // 当前状态的 CSS 类
}

function useSettingsAnimation(): UseSettingsAnimationReturn;

用法:

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

// 仅构建时 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、调试开关、…):

import { DEBUG } from '../utils/config';

if (DEBUG) {
  console.log('debug enabled');
}

不要直接导入 BACKEND_URL 来发起 API 调用。在运行时解析 URL,以便核心 sidecar 的 api_url(通过登录屏幕上的 openhuman.config_resolve_api_url 设置)生效:

// React 组件
import { useBackendUrl } from '../hooks/useBackendUrl';
const backendUrl = useBackendUrl();

// 非 React 代码
import { getBackendUrl } from '../services/backendUrl';
const backendUrl = await getBackendUrl();

深度链接 (utils/deeplink.ts)

为认证交接构建深度链接 URL。

// 构建 auth 深度链接
function buildAuthDeepLink(token: string): string;

// 解析深度链接 URL
function parseDeepLink(url: string): { path: string; params: URLSearchParams };

用法:

import { buildAuthDeepLink } from '../utils/deeplink';

// 为浏览器重定向构建 URL
const deepLink = buildAuthDeepLink(loginToken);
// → "openhuman://auth?token=abc123"

// 在 Web 前端 auth 后:
window.location.href = deepLink;

桌面深度链接监听器 (utils/desktopDeepLinkListener.ts)

在桌面应用中处理传入的深度链接。

// 设置深度链接事件监听器
async function setupDesktopDeepLinkListener(): Promise<void>;

在 main.tsx 中调用:

// 懒加载以确保 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

循环预防:

// 导航前设置 flag 以防止重新处理
localStorage.setItem('deepLinkHandled', 'true');
window.location.replace('/');

// 下次加载时,清除 flag
if (localStorage.getItem('deepLinkHandled') === 'true') {
  localStorage.removeItem('deepLinkHandled');
  return; // 不再处理
}

URL 打开器 (utils/openUrl.ts)

跨平台 URL 打开。

// 在系统浏览器中打开 URL
async function openUrl(url: string): Promise<void>;

用法:

import { openUrl } from '../utils/openUrl';

// 在系统浏览器中打开(非应用内 WebView)
await openUrl('https://telegram.org/auth');

实现:

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

// polyfills.ts
import { Buffer } from 'buffer';
import process from 'process';
import util from 'util';

window.Buffer = Buffer;
window.process = process;
window.util = util;

在应用入口导入:

// main.tsx
import './polyfills';

// ... 应用的其余部分

Vite 配置:

// vite.config.ts
export default defineConfig({
  resolve: { alias: { buffer: 'buffer', process: 'process/browser', util: 'util' } },
  define: { 'process.env': {}, global: 'globalThis' },
});

类型

API 类型 (types/api.ts)

// 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)

// 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)

手机号输入的国家列表。

interface Country {
  code: string; // "US"
  name: string; // "United States"
  dialCode: string; // "+1"
  flag: string; // "🇺🇸"
}

export const countries: Country[];

用法:

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 中包含依赖:

// 好
useEffect(() => {
  on('event', handler);
  return () => off('event', handler);
}, [on, off, handler]);

// 坏 - 缺失依赖
useEffect(() => {
  on('event', handler);
  return () => off('event', handler);
}, []);

清理函数

始终清理订阅:

useEffect(() => {
  const subscription = subscribe();
  return () => subscription.unsubscribe();
}, []);

错误边界

将工具调用包装在 try-catch 中:

try {
  await openUrl(url);
} catch (error) {
  console.error('Failed to open URL:', error);
  // Fallback 行为
}

类型安全

对 API 调用使用 TypeScript 泛型:

const user = await apiClient.get<User>('/users/me');
// user 被类型化为 User