diff --git a/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md b/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md index 4ffa6fc..be9aa93 100644 --- a/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md +++ b/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md @@ -36,7 +36,9 @@ 5. **LLM 监控一律走网页 + CDP 拦截**,不调用任何 LLM 官方 API(成本导向;多账号 = 多配额,线性成本比 token 计费友好)。后端 `llm.api_key` 是后端自用管线,与客户端监控完全解耦。 6. **不打包 Playwright**。复用 Electron 自带 Chromium,通过 `webContents.debugger` 直接用 CDP。包体越小越好。 7. **支持 5 目标**:Windows x64/arm64、macOS Intel+Apple Silicon、Linux x64/arm64。 -8. **一个平台账号只绑定一个 workspace**(不支持跨租户共享账号)。该约束必须落到 DB 唯一约束与 API 级冲突校验,不依赖客户端自觉(见 §6.6 `platform_accounts` 的 `platform_uid` 字段 + 唯一索引)。 +8. **一个平台账号只绑定一个 workspace**(不支持跨租户共享账号)。该约束必须落到 DB 唯一约束与 API 级冲突校验,不依赖客户端自觉(见 §6.6 `platform_accounts` 的 `platform_uid` 字段 + 唯一索引)。**前置条件**:workspace 必须先升成后端一等安全边界(见 §12 Plan 0);仅靠新增 middleware 不够,因为现有代码里 `Actor/Claims/cache/monitoring` 多处仍按 tenant 粒度(v2.2 三轮审查发现的底座问题)。 +9. **演进缝预留**:短期 `一账号 = 一 workspace = 一台 client` 是产品边界,不是代码硬写;DB 列名采用 `credential_holder_workspace_id` / `account_home_client_id` 风格而非 `owner_*`,为将来"共享运营账号池 / 桌面端主备"留演进位置(见 §6.6 DDL 注释)。 +10. 要求最小 electron 体积包 --- @@ -95,9 +97,14 @@ apps/desktop-client/ ### 3.3 关键复用(更务实的口径) **可复用**: + - `packages/shared-types`:平台枚举、任务类型、DTO。需要**扩展**:加入租约、platform_uid、patch manifest 等新类型。 -- `packages/http-client`:HTTP 基座直接用。 -- **新建 `packages/ui-shared`**:从 `apps/admin-web` 抽出**通用组件、i18n、样式、utils**。admin-web 与 desktop-client 两边都只消费共享包。Renderer 是一个极简 Vite 项目,不是克隆+删页面。 +- `packages/http-client`:HTTP 基座直接用;**v2.3 新增** `SseClient` 类——内置指数退避重连、`Last-Event-ID` 处理、首帧 snapshot 消费、事件去重。Web 和 Desktop 两端共用。[apps/admin-web/src/lib/api.ts](apps/admin-web/src/lib/api.ts) 现有的简陋 `subscribeSSE` 迁移到这里并补能力。 +- **新建 `packages/ui-shared`**:从 `apps/admin-web` 抽出**通用组件、i18n、样式、utils**。admin-web 与 desktop-client 两边都只消费共享包。Renderer 是一个极简 Vite 项目,不是克隆+删页面。**必须抽出**的组件: + - `MarkdownPreview.vue` + `markdown-it` 渲染器(让 Web 发布前预览和 Desktop manual-review 用同一套渲染,避免"Web 看到的"和"桌面端审核看到的"不一致)。 + - `AccountCard.vue` / `TagMultiSelect.vue` / `HealthBadge.vue`(账号管理 + 发布 Modal 共用)。 + - `SseSubscription.vue` composable(封装 `SseClient` 给 Vue 用)。 + - i18n 文案、主题变量、toast/dialog 系统。 - 老适配器里的**选择器字符串**和**业务流程描述**("打开编辑器 → 填标题 → 粘正文 → 上传封面 → 点发布"这种语义)。 **不可复用、必须重写或新建**: @@ -185,8 +192,8 @@ type ProxyConfig = { | 豆包 | Doubao | 主动保活 | 8 min + 30 min 探活 | | 千问 | Qwen | 主动保活 | 8 min + 30 min 探活 | | DeepSeek | DeepSeek | 只探活 | 30 min | -| 混元 | Hunyuan(腾讯)· 登录方式:微信扫码 / QQ / 邮箱(微信涉及 `open.weixin.qq.com` iframe + 本地 loopback 检查)| 接入时按实际 transport 复测;默认主动保活 | 8 min + 30 min 探活 | -| Kimi | Moonshot · 登录方式:微信扫码 / 手机号快捷登录。实测请求是 `POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat`(gRPC-Web binary-framed,**非 EventSource**,parser 需先 framing 解包,见 §5.2) | 接入时按实际 transport 复测;默认主动保活 | 8 min + 30 min 探活 | +| **元宝**(腾讯)| Yuanbao · 入口 `yuanbao.tencent.com`(**不是** hunyuan.tencent.com,后者是开发者/模型页不是对话入口)· 登录方式:微信扫码 / QQ / 邮箱(微信涉及 `open.weixin.qq.com` iframe + 本地 loopback 检查)| 接入时按 §5.2 spike 矩阵复测;默认主动保活 | 8 min + 30 min 探活 | +| Kimi | Moonshot · 入口 `www.kimi.com` · 登录方式:微信扫码 / 手机号快捷登录。实测请求是 `POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat`(gRPC-Web binary-framed,**非 EventSource**,parser 需先 framing 解包,见 §5.2) | 接入时按 §5.2 spike 矩阵复测;默认主动保活 | 8 min + 30 min 探活 | 实施细节: @@ -241,20 +248,35 @@ interface MonitorAdapter { } ``` -### 5.2 监控适配器:流式 SSE 抓取技术路径(核心修正) +### 5.2 监控适配器:流式 SSE 抓取技术路径(**先 spike 后定案**) -**不要用** `Network.responseReceivedExtraInfo + getResponseBody`——前者是响应头/Cookie 元信息,后者只能在响应整段收完后取完整 body,**对长连接 SSE 拿不到流式增量**。 +**v2.2 review 发现 `Fetch.takeResponseBodyAsStream` 与 `Fetch.continueResponse` 在 CDP 语义上互斥**:前者独占响应 body,后者让页面继续消费。因此**不能**写成"连续调用链"。v2.3 改为"分流 + 实测矩阵 + spike gate"。 -**按传输类型分流**: +**总方针**:不同平台用不同 domain,**不劫持能不劫持**(劫持会阻塞页面继续跑),只在必须看响应 body 时才用 Fetch 劫持。 -| 页面使用的机制 | 正确的 CDP 抓法 | -|---|---| -| `new EventSource(url)` | `Network.enable` → `Network.eventSourceMessageReceived` 监听每条 message | -| `fetch(url)` + `text/event-stream` + streaming body | `Fetch.enable({patterns})` → `Fetch.requestPaused` → `Fetch.continueResponse` → `Fetch.takeResponseBodyAsStream` + `IO.read` 流式拼接 | -| `fetch(url)` + chunked transfer + 自定义协议 | 同上(或 Chromium ≥ 120 的 `Network.streamResourceContent + Network.dataReceived`,**需先确认 Electron 打包的 Chromium 版本支持**)| -| **gRPC-Web / binary-framed fetch-stream**(如 Kimi `/apiv2/kimi.gateway.chat.v1.ChatService/Chat`)| 同 `Fetch.enable + takeResponseBodyAsStream + IO.read`,但 **provider parser 必须先做 gRPC-Web framing 解包**(5-byte 前缀:1B compressed flag + 4B message length)再按 protobuf 解 payload。正规化成 MonitorResult 前建议引入 `framer.ts` 公共工具 | +**按传输类型分流**(每条路线的可用性需在 Plan A 先做 **spike 矩阵**确认): -**Chromium 版本兼容矩阵**由 `docs/superpowers/specs/electron-chromium-cdp-matrix.md`(本 spec 落地后新建)维护;每次升 Electron 大版本检查一次上面这些 domain 是否仍可用。 +| 页面使用的机制 | 推荐 CDP 路径 | 约束 | +|---|---|---| +| `new EventSource(url)` | `Network.enable` → `Network.eventSourceMessageReceived` 监听每条 message | 仅监听、不劫持。最干净。 | +| `fetch(url)` + `text/event-stream` + streaming body | `Network.enable` → `Network.streamResourceContent(requestId)` + `Network.dataReceived` 事件流式拼接 | **Chromium ≥ 120**(Electron 41+ 满足)。仅监听、不阻断页面。| +| `fetch(url)` + chunked transfer + 自定义协议 | 同上 `streamResourceContent + dataReceived` | 同上 | +| **gRPC-Web / binary-framed fetch-stream**(Kimi `/apiv2/kimi.gateway.chat.v1.ChatService/Chat`)| 同上 `streamResourceContent + dataReceived`,拿到 raw bytes 后 **provider parser 先做 gRPC-Web framing 解包**(5-byte 前缀:1B compressed flag + 4B message length)再解 payload | 引入 `adapters/_shared/framer.ts` 公共工具 | +| 必须**劫持 / 替换** 响应(一般不需要,只在改写响应或注入时)| `Fetch.enable({patterns})` → `Fetch.requestPaused` → `Fetch.fulfillRequest`(**不再调 continueResponse**);若需读 body 再处理,用 `Fetch.getResponseBody`(**等响应结束**)后统一返回 | `takeResponseBodyAsStream` 后页面**不能继续正常消费该响应**,故仅用于"必须替换响应"场景,监控场景不选 | + +**Spike 矩阵(Plan A 必须先跑)**: + +``` +provider × transport × Electron 版本 × domain 可用性 × 实际效果 + 豆包 千问 DeepSeek 元宝/混元 Kimi +EventSource ? ? ? ? ? +streamResource ? ? ? ? ? +dataReceived ? ? ? ? ? +``` + +每个 cell 在开工前用 `electron/chrome://devtools` 实跑一次登录后的真实聊天请求,记录实际的 request 种类与可抓到的 body 行为。**没有 spike 结果就不能定任何 adapter 的实现细节**。 + +**Chromium/CDP 兼容矩阵文件**:`docs/superpowers/specs/electron-chromium-cdp-matrix.md`(Plan A 阶段新建),记录每次升 Electron 大版本的 domain 可用性回归结果。截至 2026-04-18 Electron stable 41.2.0(Chromium 146),上述三类 domain 全部可用。 实施步骤: @@ -291,11 +313,17 @@ type PublishState = ``` **状态机与租约的绑定**(见 §6 协议): -- 进入 `filling / readyToReview / submitting` 时,适配器必须每 60 s 调 `lease-manager.extend(lease_token)` 续租。 -- 进入 `readyToReview` 时,lease-manager 同步把服务端任务切到 `waiting_user` 状态,服务端 lease 续期 = 30 min(长于默认 10 min),避免等用户审核过程中被抢任务。 + +- 进入 `filling / submitting` 时,适配器必须每 60 s 调 `lease-manager.extend(lease_token)` 续租(lease TTL 默认 10 min)。 +- 进入 `readyToReview` 时,**人工审核可能持续几分钟到几小时**,不能用 lease 续期表达。lease-manager **释放当前 lease**,把服务端任务切到 **`waiting_user` parked state**: + - 任务归属仍绑定原 `target_account_id + target_client_id`,**不会被其它 client 抢走**(因为 target_client_id 路由规则)。 + - 任务**不持有活动 lease**(`active_attempt_id = null`),server 不会因 lease 超时回滚状态。 + - 用户点"发布"后,客户端**重新获取 lease**(`POST /tasks/{id}/lease` 带 `from_parked=true`),进入 `submitting`。 + - 用户跨重启恢复:同样的路径,因为 parked state 持久在 DB。 + - 服务端可选设置 "parked 超过 N 小时则自动 abort" 的 fallback(默认 24h,可配置)。 - 用户取消或 signal abort → `aborted`。 - adapter 抛异常 → `failed`,带错误码。 -- 进程崩溃 → 启动时按 §7.5 分派为 `unknown`。 +- 进程崩溃 → 启动时按 §7.5 分派为 `unknown`(仅限 publish 任务的 `submitting` 阶段)。 文件上传:`protocol.handle('file')` + CDP `Input.dispatchDragEvent` 或直接喂 ``,不经服务端中转。 @@ -351,7 +379,10 @@ type PublishState = - 结构化日志(pino JSON)滚动写入 `userData/logs/`。 - 任务失败时自动截屏 3–5 张 + 存 Network 响应摘要。 -- 本地内置极简 viewer(Vue 单页,离线),能读 `.trace.json` 文件。 +- 本地内置极简 viewer(Vue 单页,离线),能读 `.trace.json` 文件。**加载与隔离要求**: + - Viewer Vite 构建用 **hash 路由**(`createWebHashHistory`),避免 `file://` 协议下 history 路由 404。 + - Viewer 跑在 **独立的 sandboxed `