# dsh-workbuddy-multi — 设计说明 > 面向开发者。使用说明见 [README](../README.md)。 `dsh-workbuddy-connect` 的**多账号伴侣插件**。让 WorkBuddy / WorkBuddy AI 两个模型组 同时挂多个账号:默认粘住当前账号、撞上游频率限制时自动冷却换号,并且每个账号都能 单独暴露成一个独立模型组手动选。 **硬保证:本插件一行都不改 `dsh-workbuddy-connect`。** 它只使用上游 `package.json` 声明的公开导出面(`WorkBuddyCredentialStore` / `WorkBuddyUpstreamClient` / `createWorkBuddyAdapter` / `WORKBUDDY_VARIANTS` 等)和上游文档化的 `WORKBUDDY_AUTH_FILE` / `WORKBUDDY_AI_AUTH_FILE` 环境变量。上游升级不影响本插件, 本插件的存在也不影响上游。 除了被动收割(从桌面 App 抓、从文件导入),还可以**主动扫码登录**新账号——走 WorkBuddy 官方登录页,本插件只做转发与脱敏;以及**自动续期**:定时给快过期的账号换 access token, 并把「需要重新登录」的账号单独标出来,而不是让它们静默失效。 --- ## ⚠️ 五个必须知道的限制 ### (a) 上游若设了显式 `authFile`,会盖过本插件 上游解析凭据路径的优先级是**「插件配置里填的路径 → 环境变量 → 平台默认路径」** (`host-heartbeat-sjotAEdb.js:3462-3469`,注释原文:_the plugin's configured path, then the environment variable, then the platform defaults_): ```js const fromEnv = process.env[this.variant?.env ?? "WORKBUDDY_AUTH_FILE"]; const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0); if (explicit !== void 0) return [explicit]; ``` 本插件是靠**接管环境变量**把上游指向账号池里的当前账号文件的。如果你在 `dsh-workbuddy-connect` 自己的设置里填过 `authFile` / `authFileAI`,那个显式值会 **优先于环境变量**,于是账号池失效——轮换看起来在工作,实际上上游一直读同一个文件。 **处置**:在设置卡片顶部的「需要注意」区会明确列出这条告警。看到它就去 `dsh-workbuddy-connect` 的设置里**清空 `authFile` / `authFileAI` 两个字段**, 让环境变量重新生效。 ### (b) CN 账号的 token 是本机加密信封,快照只有本机能用、不能跨机搬 CN(`workbuddy`)variant 的 `accessToken` / `refreshToken` / `nickname` / `phoneNumber` 在磁盘上是**加密信封**(`{"$wbEncrypted":1,"envelope":"…"}`), 解密密钥来自 WorkBuddy 桌面 App 的静态密钥(上游 `WorkBuddyAtRestKeyProvider` 会去 spawn App 取)。 因此: - 账号池里存的是**原始字节快照**(`cp` 级别),本插件**不解析、不解密、不重新序列化** ——重新序列化会破坏信封。 - 这份快照**只在这台机器上有意义**。把 `~/.dsh/workbuddy-pool/` 拷到另一台机器, CN 账号会直接失败,因为那台机器的 App 密钥不同。 - **不要**把 CN 的 `.info` 文件当凭据跨机传播,也不要用它做备份迁移。 AI(`workbuddy-ai`)variant 的 token 是明文,没有这个限制。 ### (c) 本插件的独立模型组在宿主重启后才出现 每个账号的独立模型组(`WorkBuddy · <显示名>` / `WorkBuddy AI · <显示名>`)是在宿主 启动时注册路由的。新启用一个账号、改显示名、或删除账号后: - **账号池的轮换立刻生效**(上游每次都重新 `readFile`,见下), - 但**新增 / 改名的模型组要等宿主重启**(退出并重新打开 DSH Desktop)。 插件本身**不会**替你重启 DSH——那条对话就在 DSH 里跑着。设置卡片会一直显示这条提醒。 --- ### (d) 主动登录拿到的凭据,卡片永远看不到 扫码登录成功后,账号直接进池子(`~/.dsh/workbuddy-pool/accounts//.info`,权限 0600)。 宿主 HTTP 接口**强制脱敏**:响应里不会出现 `accessToken`、`refreshToken`、 `$wbEncrypted` 信封或快照正文,`loginPoll` 成功时只回 `{uid, nickname, email?}`。 所以卡片不显示凭据、也不提供复制凭据的入口——这是有意的边界,不是没做完。 需要看凭据就自己看池子文件。 ### (e) 关掉面板就停止轮询,不留后台定时器 「+ 登录新账号」展开后,等待扫码期间**只有一个 1 秒定时器**:它同时驱动倒计时 和每两秒一次的 `loginPoll`。定时器的生命周期就是组件的生命周期——设置卡片收起、 Plugins 页切走、或组件卸载,定时器立即被清掉,不会留在后台继续打宿主。 所以:**关掉面板 = 不再轮询**。服务端那条登录链接本身仍然有效(默认 600 秒), 重新打开面板可以再生成一条;宿主的 `login.js` 也会自己回收超时的登录。面板关闭时 **刻意不发** `loginCancel`——收起一个面板不该产生一次网络写操作,只有显式点「取消登录」才取消。 ## 工作原理 ``` dsh-workbuddy-multi(伴侣插件) ├─ lib/pool.js 账号池:快照存储 / 抓取 / 导入 / 索引 / 冷却 ├─ lib/rotation.js 环境变量接管 + 轮换策略 + llm/retry 撞限换号 ├─ lib/pinned.js 每账号一套 store+shim+adapter → 独立模型组 ├─ lib/index.js 入口:Config / apply / 设置卡片的宿主动作线 └─ lib/client.js 设置卡片 UI(浏览器半) ``` ### 为什么改文件就生效(不需要重启) 上游 `readDesktop()` **没有任何缓存**,每次调用都重新 `readFile`;而 `resolve() → current() → readDesktop()` 在**每次请求**的发送路径上 (`lib/index.js:774: credential = await store.resolve();`)。 所以本插件切换「当前账号」= 原子地把账号池里那份快照覆盖到 `~/.dsh/workbuddy-pool/active/.info`,**下一次请求**就换号了,无需重启。 ### 磁盘布局 根目录 `~/.dsh/workbuddy-pool`(可用环境变量 `WORKBUDDY_POOL_ROOT` 覆盖): ``` / index.json # 唯一索引,原子写 active/.info # 当前生效账号的原始字节(上游实际读它) accounts//.info # 每个账号的原始字节快照(0600) accounts//.meta.json import//*.{info,json} # 手动导入落地区 ``` 账号快照一律 `0600`,写入一律「写临时文件 + rename」原子替换。 ### 轮换策略 | policy | 行为 | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `on-rate-limit`(默认) | 平时粘住当前账号;上游报 `RATE_LIMIT` / `QUOTA` / `ACCOUNT_QUOTA` 时,把当前账号冷却到上游文案里的重置时刻并自动换下一个 | | `round-robin` | 每次请求分发前换下一个可用账号,跳过停用与冷却中的 | | `manual` | 只认手动选择,不自动换号 | ### 独立模型组 每个启用中的账号会被建一套 `store + client + catalog + shim + adapter`, 用自己的 `providerId` 注册(`workbuddy-` / `workbuddy-ai-`), 显示名 `WorkBuddy ·