--- title: 客户端路由 updated: 2026-04-21 status: active tags: [module, routing, connection] --- # 客户端路由 > 从 [[index]] 导航。关联模块: [[chat]] [[saas]] ## 设计思想 **核心决策: Tauri 桌面端通过 SaaS Token Pool 中转访问 LLM,不直连。** 为什么? 1. **集中密钥管理** — 用户不需要自己的 API Key,SaaS 维护共享 Key 池 2. **用量追踪 + 计费** — 每次调用经过 SaaS,`record_usage` worker 记录 token 消耗 3. **模型白名单** — Admin 配置哪些模型可用,`listModels()` 返回白名单 4. **降级保障** — SaaS 挂了自动切本地 Kernel,桌面端不变砖 ## 功能清单 | 功能 | 描述 | 入口文件 | 状态 | |------|------|----------|------| | 连接管理 | 5 分支路由决策 + 自动降级 | connectionStore.ts | ✅ | | SaaS Relay 中转 | Tauri 通过 SaaS Token Pool 中转 LLM | connectionStore.ts | ✅ | | 浏览器模式 | SSE 连接 SaaS relay | saas-relay-client.ts | ✅ | | 本地 Kernel | Tauri 内置 Kernel 直连 LLM | kernel-client.ts | ✅ | | 外部 Gateway | WebSocket 独立进程 | gateway-client.ts | ✅ | | Gateway 进程管理 | 启动/停止/重启/状态/诊断 | gateway/commands.rs | ✅ | | 健康检查 | 端口检测 + 完整诊断 | health_check.rs | ✅ | | 设备配对 | 设备审批 + 公钥交换 | gateway/commands.rs | ✅ | | 模型路由 | 白名单验证 + fallback + 别名解析 | connectionStore.ts | ✅ | ## 代码逻辑 ### 5 分支 + 降级决策树 入口: `connectionStore.ts` → `connect(url?, token?)` ``` connect() │ ├── [1] Admin 强制本地: adminRouting === 'local' && isTauri() │ → 直接走 Kernel 模式,跳过 SaaS │ ├── [2] SaaS Relay (Tauri 路径): savedMode === 'saas' && isTauri() │ → KernelClient + baseUrl = saasUrl/api/v1/relay │ → apiKey = SaaS JWT (不是 LLM Key!) │ → SaaS 不可达 → 降级到本地 Kernel │ ├── [3] SaaS Relay (Browser 路径): savedMode === 'saas' && !isTauri() │ → SaaSRelayGatewayClient (SSE) │ → SaaS 不可达 → 降级到本地 Kernel │ ├── [4] 本地 Kernel: isTauriRuntime() && 非 SaaS 模式 │ → KernelClient + 用户自定义模型配置 │ → 用户需要自己的 API Key │ └── [5] External Gateway (fallback): !isTauri() → GatewayClient via WebSocket/REST ``` ### SaaS Relay 主路径 (Tauri 桌面端) 关键代码: `connectionStore.ts:482-535` ```ts kernelClient.setConfig({ provider: 'custom', model: modelToUse, // 从 SaaS listModels() 获取 apiKey: session.token, // SaaS JWT,不是 LLM Key baseUrl: `${session.saasUrl}/api/v1/relay`, // 指向 SaaS relay apiProtocol: 'openai', }); ``` **注意**: Kernel 仍然执行 LLM 调用逻辑,但请求发往 SaaS relay 而非直连 LLM。 SaaS relay 接到请求后,从 Token Pool 中取一个可用 Key,转发给真实 LLM。 ### SaaS 降级流程 关键代码: `connectionStore.ts:446-468` ``` listModels() 失败 → 401 → session 过期 → logout → 要求重新登录 → 其他错误 → saasDegraded = true → saasStore.saasReachable = false → 降级到本地 Kernel 模式 ``` ### 客户端类型 | 客户端 | 传输 | 文件 | 用途 | |--------|------|------|------| | GatewayClient | WebSocket + REST | `lib/gateway-client.ts` | 外部 Gateway 进程 | | KernelClient | Tauri invoke() | `lib/kernel-chat.ts` | 内置 Kernel (桌面端) | | SaaSRelayGatewayClient | HTTP SSE | `lib/saas-relay-client.ts` | 浏览器端 SaaS 中继 | `getClient()` 定义: `connectionStore.ts:844` 所有 Store 通过 `initializeStores()` (store/index.ts:94) 获取共享 client。 ### Store 层 (16 根文件 + chat/4 + saas/5 = 25) ``` desktop/src/store/ ├── index.ts Store 协调器 + client 注入 ├── agentStore.ts Agent 分身管理 ├── browserHandStore.ts 浏览器 Hand 状态 ├── chatStore.ts 聊天通用状态 ├── classroomStore.ts 课堂模式 ├── configStore.ts 配置读写 ├── connectionStore.ts 路由决策核心 ├── handStore.ts Hand 状态管理 ├── industryStore.ts 行业配置 (已接通 ButlerPanel) ├── memoryGraphStore.ts 记忆图谱 ├── offlineStore.ts 离线队列 ├── saasStore.ts SaaS 认证 (re-export barrel) ├── securityStore.ts 安全状态 ├── sessionStore.ts 会话管理 ├── uiModeStore.ts 双模式 UI ├── workflowStore.ts 工作流状态 ├── chat/ │ ├── artifactStore.ts 聊天产物 │ ├── conversationStore.ts 会话管理 │ ├── messageStore.ts 消息持久化 │ └── streamStore.ts 流式编排 └── saas/ (拆分子模块, 04-17 refactor) ├── index.ts 子模块入口 ├── auth.ts 认证逻辑 ├── billing.ts 计费逻辑 ├── shared.ts 共享状态/工具 └── types.ts 类型定义 ``` ### lib/ 工具层 (75 个 .ts 文件) 关键分类: | 类别 | 文件 | 数量 | |------|------|------| | Kernel 通信 | kernel-client/kernel-chat/kernel-agent/kernel-skills/kernel-triggers/kernel-hands/... | 8 | | SaaS 通信 | saas-client/saas-auth/saas-billing/saas-relay/saas-industry/saas-knowledge/... | 12 | | Gateway | gateway-client/gateway-api/gateway-auth/gateway-config/... | 9 | | Intelligence | intelligence-backend/intelligence-client/embedding-client/memory-extractor | 4 | | Viking | viking-client | 1 | | Pipeline | pipeline-client/pipeline-recommender | 2 | | Security | crypto-utils/secure-storage/security-audit/security-index/api-key-storage | 5 | | 工具 | config-parser/logger/utils/error-types/error-utils/json-utils/... | 10+ | | Tauri 集成 | safe-tauri/tauri-gateway | 2 | | 工作流 | workflow-builder/ (index + types + yaml-converter) | 3 | ## 模型路由 ### 完整链路 (Tauri SaaS Relay 主路径) ``` 前端模型选择 │ ├─ conversationStore.currentModel (用户上次选择的模型) │ 持久化到 IndexedDB,跨会话保留 │ ├─ connectionStore 连接时获取 SaaS 可用模型 │ saasClient.listModels() → [{id: "deepseek-chat"}, {id: "GLM-4.7"}, ...] │ relayModels[0]?.id 作为 fallback │ └─ 最终: preferredModel || fallbackId │ ▼ kernelClient.setConfig({ model: modelToUse }) │ ▼ kernel_init (Tauri Command) │ KernelConfigRequest { model, api_key, base_url } │ base_url = "https://saas-host/api/v1/relay" │ api_key = SaaS JWT (不是 LLM Key!) │ ▼ Kernel::boot(config) │ config.llm.model = modelToUse │ config.llm.base_url = SaaS relay URL │ ▼ loop_runner → LLM Driver (OpenAI compatible) │ POST {base_url}/chat/completions │ body: { model: modelToUse, messages: [...] } │ header: Authorization: Bearer {SaaS JWT} │ ▼ SaaS Relay Handler (handlers.rs) │ cache.get_model(model_name) → 精确匹配 model_id │ ⚠️ 无别名解析! "glm-4-flash" ≠ "deepseek-chat" │ 找不到 → 400 "模型 xxx 不存在或未启用" │ ▼ Key Pool 轮换 │ priority ASC → last_used_at ASC → cooldown 检查 → RPM/TPM 滑动窗口 │ ▼ 真实 LLM API │ 429 → mark cooldown → 切换 key │ 5xx → exponential backoff │ model_group → 跨 Provider 故障转移 │ ▼ 响应 → SSE 流式返回 → 前端 ``` ### 辅助 LLM 调用 (非聊天主路径) 这些 Rust 端组件也通过同一个 relay 发起 LLM 请求: | 组件 | 文件 | 模型来源 | 触发时机 | |------|------|----------|----------| | 记忆摘要 | `summarizer_adapter.rs` | kernel_init 传入的 model | 定期 L0/L1 摘要生成 | | 记忆提取 | `extraction_adapter.rs` | kernel_init 传入的 model | 中间件触发提取 | | 管家路由 | ButlerRouter via loop_runner | 同聊天模型 | 聊天中间件链 | **关键**: `summarizer_adapter.rs` 和 `extraction_adapter.rs` 在 `kernel_init` 时配置, 使用与聊天相同的 `model` 和 `base_url`。未配置时会明确报错,不会静默 fallback 到错误模型。 ### SaaS Relay 模型匹配规则 ``` 前端发送 model: "deepseek-chat" → SaaS cache 按 model_id 精确匹配 → 匹配: cache.models["deepseek-chat"] → 命中 → 不匹配: cache.models["glm-4-flash"] → null → 400 错误 ⚠️ config.toml 中的 [llm.aliases] 仅用于本地 Kernel,SaaS relay 不解析别名! ``` ### Browser 模式模型路由 ``` createSaaSRelayGatewayClient(saasUrl, getModel) │ getModel() 回调 → conversationStore.currentModel || relayModels[0]?.id │ ▼ chatStream() → saasClient.chatCompletion({ model: getModel() }) │ 未获取到模型时 → onError 报错,不发请求 │ ▼ POST /api/v1/relay/chat/completions → SSE 流 ``` ## API 接口 ### Tauri 命令 **Gateway 管理** (`desktop/src-tauri/src/gateway/commands.rs`): | 命令 | 参数 | 返回值 | 说明 | |------|------|--------|------| | `zclaw_status` | — | `LocalGatewayStatus` | Kernel 运行状态 | | `zclaw_start` | — | `LocalGatewayStatus` | 启动 Kernel | | `zclaw_stop` | — | `LocalGatewayStatus` | 停止 Kernel | | `zclaw_restart` | — | `LocalGatewayStatus` | 重启 Kernel | | `zclaw_local_auth` | — | `LocalGatewayAuth` | 获取本地认证 token | | `zclaw_prepare_for_tauri` | — | `LocalGatewayPrepareResult` | 更新 Tauri allowed origins | | `zclaw_approve_device_pairing` | device_id, public_key_base64, url? | `PairingApprovalResult` | 设备配对审批 | | `zclaw_doctor` | — | `String` | 诊断报告 | | `zclaw_process_list` | — | `ProcessListResponse` | 进程列表 | | `zclaw_process_logs` | pid?, lines? | `ProcessLogsResponse` | 进程日志 | | `zclaw_version` | — | `VersionResponse` | 版本信息 | **健康检查** (`desktop/src-tauri/src/health_check.rs`): | 命令 | 参数 | 返回值 | 说明 | |------|------|--------|------| | `zclaw_health_check` | port?, timeout_ms? | `HealthCheckResponse` | 完整健康检查 | | `zclaw_ping` | — | `bool` | 快速端口检测 | ### SaaS Relay 路由 (`crates/zclaw-saas/src/relay/`) | 方法 | 路径 | 权限 | 说明 | |------|------|------|------| | POST | `/api/v1/relay/chat/completions` | 认证+配额 | 主聊天中转 | | GET | `/api/v1/relay/models` | 认证 | 可用模型列表 | | GET | `/api/v1/relay/tasks` | 认证 | 任务列表 | | GET | `/api/v1/relay/tasks/:id` | 认证 | 任务详情 | | POST | `/api/v1/relay/tasks/:id/retry` | admin | 重试失败任务 | ### Provider Key 管理 (`crates/zclaw-saas/src/relay/handlers.rs`) | 方法 | 路径 | 权限 | 说明 | |------|------|------|------| | GET | `/api/v1/providers/:id/keys` | admin | 列出 Provider Key | | POST | `/api/v1/providers/:id/keys` | admin | 添加加密 Key | | PUT | `/api/v1/providers/:id/keys/:kid/toggle` | admin | 启停 Key | | DELETE | `/api/v1/providers/:id/keys/:kid` | admin | 删除 Key | ## 测试链路 | 功能 | 测试文件 | 测试数 | 覆盖状态 | |------|---------|--------|---------| | Admin 路由解析 | `tests/desktop/connectionStore.adminRouting.test.ts` | — | ✅ parseAdminRouting() 纯函数 | | 连接流程 | `tests/desktop/gatewayStore.test.ts` | — | ✅ connect() + 数据加载 | | GatewayClient | `tests/gateway/ws-client.test.ts` | — | ✅ WS 连接/事件/断连 | | Mock Server | `tests/fixtures/zclaw-mock-server.ts` | — | ✅ HTTP+WS 模拟 | ## 关联模块 - [[chat]] — 路由决定使用哪种 ChatStream - [[saas]] — Token Pool、认证、模型管理 - [[middleware]] — 请求经过中间件链处理 - [[butler]] — 管家模式通过 ButlerRouter 中间件介入 ## 关键文件 | 文件 | 职责 | |------|------| | `desktop/src/store/connectionStore.ts` | 路由决策核心 | | `desktop/src/lib/gateway-client.ts` | WebSocket 客户端 | | `desktop/src/lib/kernel-chat.ts` | Tauri 内核聊天 | | `desktop/src/lib/kernel-client.ts` | Kernel 客户端配置 | | `desktop/src/lib/saas-relay-client.ts` | SaaS SSE 中继 | | `desktop/src/lib/saas-client.ts` | SaaS API 客户端 | | `desktop/src/store/index.ts` | Store 协调器 + client 注入 | ## 已知问题 - ✅ **Tauri invoke 参数名 snake_case vs camelCase** — P1 已修复 (commit f6c5dd2)。Tauri 2.x 默认 `rename_all = "camelCase"`,所有 invoke 调用必须用 camelCase - ✅ **Provider Key 解密失败导致 relay 500** — P1 已修复 (commit b69dc61)。`key_pool.rs` 现在 decrypt 失败时 warn+skip 到下一个 key,启动时 `heal_provider_keys()` 自动重新加密有效 key