# dsh-plugin-workbuddy 把本机 **WorkBuddy 桌面端**已登录的模型,做成 **DeepSeek Harness 的一等公民**。 **分工是「插件当引擎,控制台当界面 —— 且界面就长在 dsh 里」**: | | 角色 | 负责什么 | |---|---|---| | **插件**(dsh 内) | 引擎 + 界面 | 原生模型路由、桥与控制台的启停、工具、命令,以及 **设置 → WorkBuddy** 里的 9 个标签页:概览 / 账号 / 用量 / 请求 / 签到 / 诊断 / 模型 / 对话测试 / 日志 —— 控制台的全部功能 | | **控制台**(本仓库原有网页) | 同一界面的网页版 | 功能与设置页**完全等价**(见下文的同步设计),想在大屏浏览器里用就开它 | ``` dsh 的模型选择器 ──► provider: workbuddy ──► 插件内置适配器 │ (dsh 词汇 ↔ OpenAI 词汇) ▼ bridge/workbuddy-bridge.mjs(独立进程,127.0.0.1:8790) │ 注入鉴权头 / 现取现解凭据 ▼ WorkBuddy 上游 dsh 设置 → WorkBuddy(9 个标签页)═══ 同一份状态 ═══ dashboard/server.mjs(控制台网页) ``` ## 能力一览 装进 dsh 后,这些能力都在同一个插件里(明细见下方「它做了什么」): - **零配置接入** —— 不需要 API Key,复用桌面端已登录态 - **模型直接进选择器** —— 原生 `provider: workbuddy` 路由,不写任何配置文件 - **模型管理** —— 启用 / 禁用、体检(可用性 · 消耗倍率 · 实测成本)、详情、CSV 导出 - **账号切换**、**积分与用量**(趋势图 + 排行 + CSV)、**请求明细**(失败标红 + 错误码一键复制) - **8 项环境诊断**、**多轮对话测试**、**每日自动签到** - **中英双语** —— 9 个标签页界面全部双语 --- ## 两边如何同步、互不冲突(设计核心) dsh 设置页与控制台网页是**两个前端、一个后端**,所以天然一致: | 数据 | 谁写 | 为什么 | |---|---|---| | 模型目录 / 用量 / 最近请求 / 签到状态 / 桥日志 | **桥**(两边都只读桥的接口) | 单一事实来源,读操作无冲突 | | 账号切换 / 自动签到开关 / 模型体检结论 | **只由控制台写**(设置页通过 `/workbuddy/console-api/*` 白名单透传) | 控制台的 `lib/state.mjs` 带进程内缓存,插件若另写这个文件,控制台的缓存就过期了 —— 让唯一持有缓存的进程写,两边立刻一致 | | 桥 / 控制台进程的启停 | **插件** | 生命周期归引擎管;插件不会去停用户自己双击 `启动.cmd` 起的进程 | 因此:在控制台网页里切账号,设置页 30 秒内(下一次轮询)同步;在设置页体检模型, 控制台的「可用模型」面板看到的是同一份结论。控制台进程没跑时,读功能照常 (读桥),写功能给出「启动控制台」的一键提示;宿主端旧构建(未重启 dsh)时, 明确提示「重启一次 dsh」而不是空白。 透传接口是**白名单**(`consoleApi.mjs`),不是任意路径代理;写操作同样要求 `x-workbuddy-panel: 1` 头。控制台的代码与文件一个字未改。 --- ## 它做了什么 | 能力 | 说明 | |---|---| | **原生模型路由** | `ctx.llm.registerAdapter(['workbuddy'], …)` —— 模型直接出现在 dsh 模型选择器里,**不写 settings.yaml**,也不依赖 `llm-pi-ai` | | **动态模型目录** | 模型列表每次向桥要(带 1 分钟缓存),上游加/减模型不必改配置、不必重启 | | **桥的生命周期** | 启动时复用已在跑的桥,没跑就按配置拉起;桥仍是**独立进程**,控制台、`start-bridge.cmd`、其它 OpenAI 客户端照旧可用 | | **控制台的生命周期** | 同样复用/拉起 `dashboard/server.mjs`,让它常驻;dsh 里一个链接就进完整控制台(不会自动弹浏览器窗口) | | **5 个工具** | `workbuddy_status` / `workbuddy_models` / `workbuddy_usage` / `workbuddy_checkin` / `workbuddy_bridge`(后者 `action=console` 可确保控制台在跑) | | **斜杠命令** | `/workbuddy status\|models\|usage\|checkin\|console\|start\|stop\|restart\|cleanup` | | **dsh 里的设置页** | 设置 → **WorkBuddy**,9 个标签页 = 控制台的全部功能:概览(含待办提醒与诊断报告)/ 账号(切换)/ 用量(趋势图 + 积分排行 + CSV)/ 请求(筛选 + CSV)/ 签到(领取 + 自动开关)/ 诊断 / 模型(体检 + 详情)/ 对话测试(多轮流式)/ 日志(过滤 + 清空) | | **旧路由迁移** | 首次加载自动清掉 `llm-pi-ai.providers.workbuddy` 手写路由(**先备份**),否则两条同名路由会撞名 | --- ## 安装 插件就在本仓库的 `dsh-plugin/` 里,安装到某个 dsh profile: ```sh # 方式一:用 dsh 自带的插件管理(推荐) dsh plugin --profile desktop add <本仓库路径>/dsh-plugin # 方式二:在 dsh 界面里装 —— 设置 → 插件 → 安装,填 dsh-plugin 目录的绝对路径 ``` 装完 **重启一次 DeepSeek Harness**。原因:插件模块会被 ESM 缓存,客户端半个 插件的引导行也只在应用启动时组装一次 —— 热更新看不到新面板。 > 本机(Windows + DSH Desktop)已装好并验证通过,见文末「验证记录」。 ### 给别人用 / 分发 插件是**自带一切**的独立包:`npm run vendor` 会把桥、控制台与共享库复制进 `dsh-plugin/vendor/`(16 个文件、约 366 KB),于是对方**不需要仓库**,只要有: - Windows + Node 18+ - WorkBuddy 桌面端**已登录** - dsh 桌面版 拷 `dsh-plugin/` 文件夹(或 `npm run pack:plugin` 出的 tgz)过去 → 对方跑一次 `node <插件>/scripts/preflight.mjs` 自检 → 用 dsh 的插件安装指向它 → 重启 dsh。 完整的路线(拷文件夹 / tarball / 发 npm)、验收清单、端口与目录说明、九条常见 故障排查,见 **[docs/INSTALL.md](../docs/INSTALL.md)**; ```sh npm run vendor # 生成/更新插件自带的 vendor/(分发前必做) npm run vendor:check # 校验 vendor/ 与仓库是否同步(CI / 发版前) npm run preflight # 环境自检:Node、脚本、登录文件、端口、模型、安装情况 npm run pack:plugin # vendor + npm pack → dsh-plugin-workbuddy-<版本>.tgz npm run verify:standalone # 把插件拷到没有仓库的临时目录,真起一遍桥与控制台 npm run release:check # = vendor:check + 单测 + 独立分发演练(发版前一键) ``` > 仓库在旁边时插件**优先用仓库**(本机改 bridge/dashboard 立刻生效), > `vendor/` 只在找不到仓库时兜底 —— 这条顺序有单测守着。 > `vendor/` 只含运行必需的 15 个文件(约 347 KB);日志、用量账本、`.state.json`、 > `*.bak`、`.env` 一律不打包(脚本还会主动清理遗留的这类文件)。 ### 依赖 - Node.js 18+ - 桥与控制台脚本:仓库里的 `bridge/workbuddy-bridge.mjs`、`dashboard/server.mjs`, 或插件自带的 `vendor/` 副本(插件通过 `projectRoot` 找它们) - WorkBuddy 桌面端已安装并登录 插件**只连本机回环地址**,不新增任何对外监听、不上报任何数据。 --- ## 配置 配置写在 profile 的 `cordis.patch.yml` 里,按 id 覆盖插件条目: ```yaml - id: workbuddy config: projectRoot: E:\workbuddy-to-dsh # 可选:桥脚本所在仓库;默认自动探测 bridgePort: 8790 localToken: wb-local-bridge autoStart: true consoleAutoStart: true # 控制台(数据界面)也由插件复用/拉起 consolePort: 8792 # 默认跟 .env 的 DASHBOARD_PORT 对齐 displayName: WorkBuddy modelAllow: [] # 只放行这些模型 id(空 = 全部) modelDeny: [] migrateLegacy: true # 首次加载清理 llm-pi-ai 里的旧 workbuddy 路由 tools: true commands: true routes: true ``` 完整默认值见 [`lib/index.js`](lib/index.js) 的 `DEFAULTS`。 `projectRoot` 的探测顺序:配置 → `WORKBUDDY_ROOT` 环境变量 → 插件目录的上一级 (插件放在仓库里时)→ `node_modules` 上溯三级 → `~/workbuddy-to-dsh` → `E:\workbuddy-to-dsh`。 **`.env` 也会被尊重**:桥进程只认环境变量,而端口 / 登录文件可能配置在仓库的 `.env` 里(`config.mjs` 的"统一配置真源")。插件会读它并补齐默认值,优先级为 ``` 插件 config(显式写的) > .env > 进程环境变量 > 内置默认值 ``` 认的键:`WORKBUDDY_HOST`、`WORKBUDDY_PORT`、`WORKBUDDY_LOCAL_TOKEN`、 `WORKBUDDY_AUTH_FILE`、`WORKBUDDY_AUTO_CHECKIN`、`DASHBOARD_PORT`。 --- ## 和旧流程的关系 在插件之前,模型靠**两处手写 YAML** 注册:`$DSH_HOME/settings.yaml` 与 profile 的 `cordis.patch.yml`。两条路都指向同一个桥,但用**同一个 provider 名**,因此不能共存。 插件加载时会: 1. 探测这两个文件里是否存在 `llm-pi-ai.providers.workbuddy`; 2. 存在就按行删掉那一段(**只动这一段**,其它行一个字符不改),并写 `<文件>.bak-<时间戳>` 备份; 3. 在入口页/工具里如实报告删了什么、备份在哪。 关掉这个行为:`migrateLegacy: false`,此时插件只提示、不改文件(但两条路由撞名, 插件会退到 `fallbackProvider`,默认 `workbuddy-native`,入口页里会显示"兜底")。 手工清理也可以在入口页点「清理旧路由」,或执行 `/workbuddy cleanup`。 --- ## dsh 里的设置页(控制台的全部功能) **设置 → WorkBuddy**(`settings.section` 槽,注册 id `workbuddy`),9 个标签页。 右上角有 **`EN` / `中` 语言开关**(选择记进 localStorage,重开保持):9 个标签页的 界面文案全部双语,与控制台同一套词条思路;**上游数据**(模型描述、诊断明细、 对话正文、日志行)保持原样不翻。 | 标签页 | 内容 | |---|---| | **概览** | 待办提醒(桥停了 / 令牌临期 / 路由未注册 / 有旧路由)、状态卡(账号 / 令牌 / 积分 / 原生路由)、操作条(启停桥与控制台、复制诊断报告与桥地址) | | **账号** | 登录目录下全部账号快照与可用性,一键切换(等价于控制台的切换:自动重启桥、清体检与余额缓存) | | **用量** | 1/7/30 天(24 小时粒度)、趋势图(次数 / tokens / 积分,内联 SVG)、指标卡、按模型积分排行、失败列表、导出 CSV、清空账本 | | **请求** | 逐条请求(模型筛选 / 仅看失败 / 条数 / 暂停自动刷新)、失败标红、点失败复制详情、导出当前筛选 CSV | | **签到** | 今日状态 / 连续天数 / 上次尝试 / 自动签到开关 / 一键领取 | | **诊断** | 8 项环境诊断(与控制台同一套 `lib/diagnostics.mjs`),按状态着色、带建议 | | **模型** | 动态目录全表(上下文 / 输出 / 倍率 / 多模态)、全部体检 / 重测不可用 / 单个重测(带进度)、清除结论、详情 ⓘ、导出 CSV | | **对话测试** | 多轮流式对话、按模型选择、停止、本轮耗时 / tokens / 扣分(消耗登录账号的额度,与控制台同一行为) | | **日志** | 关键字过滤 / 只看错误 / 只看本次启动 / 暂停刷新 / 行数选择 / 清空 | 页面数据来自插件挂的同源 HTTP 路由: | 路由 | 用途 | |---|---| | `GET /workbuddy/status[?quota=1]` | 桥 + 控制台 + 路由 + 模型目录 + 旧路由 + 积分(`quota=1` 强制重读上游) | | `GET/POST /workbuddy/quota` | 积分缓存 / **强制重读上游**(积分卡上的「刷新积分」按钮) | | `GET /workbuddy/models[?refresh=1]` | 归一化后的模型目录 | | `GET/DELETE /workbuddy/usage?days=N[&hours=1]` | 本地账本汇总 / 清空(删必须面板头) | | `GET /workbuddy/requests?limit=N` | 最近请求明细 | | `GET/DELETE /workbuddy/log?lines=N` | 桥日志尾部 / 清空 | | `GET/POST /workbuddy/checkin[?claim=1]` | 签到状态 / 领取 | | `GET/POST /workbuddy/console` | 控制台状态 / `{action: start\|stop}` | | `POST /workbuddy/bridge` | `{action: start\|stop\|restart\|status}` | | `GET/POST /workbuddy/migrate` | 探测 / 清理旧路由(POST 带 `{dryRun:true}` 可预演) | | `* /workbuddy/console-api/<白名单>` | 控制台域功能的受控透传(见「两边如何同步」) | **写操作必须带 `x-workbuddy-panel: 1` 头**:这会让跨站表单/简单请求直接打不进来 (带自定义头会触发 CORS 预检,而预检不会被放行),等于用最小代价挡掉 CSRF。 读接口只返回非敏感元数据,**绝不含任何令牌或对话内容**。 > 版本错配的兜底:控制台域的面板遇到「控制台没跑」给一键启动,遇到「宿主端旧构建 > (未重启 dsh,透传 404)」给明确的重启提示 —— 不会出现空白表格或对 404 空转。 --- ## 工具与命令 | 工具 | 用途 | |---|---| | `workbuddy_status` | 桥 / 账号 / 令牌 / 积分 / 路由 / 模型数 / 控制台地址,排查「模型没出现」的第一步 | | `workbuddy_models` | 列出当前真正可用的模型(可过滤、可强制刷新) | | `workbuddy_usage` | 最近 N 天调用次数、tokens、积分、失败数 | | `workbuddy_checkin` | 签到状态;`claim=true` 立即领取 | | `workbuddy_bridge` | `status` / `start` / `stop` / `restart` 桥;`console` 确保控制台在跑并返回地址 | ``` /workbuddy status 桥与路由总览 /workbuddy models 可用模型 /workbuddy usage 最近 7 天用量 /workbuddy checkin 签到状态 /workbuddy start|stop|restart /workbuddy cleanup 清理旧的 llm-pi-ai 路由 ``` --- ## 实现要点(给要改代码的人) - **宿主端不 import 任何 `@deepseek-ai/*` 包**。`llm.registerAdapter()` 只调用 `adapter.providerInfo()` 与 `adapter.providerRetryPolicy()`,**没有 instanceof 检查**,所以适配器是普通对象即可;profile 的 `node_modules` 里本来也解析不到 那些包。可选服务(`tools` / `commands` / `webServer`)统一走 `ctx.inject` 等就绪。 - **适配器必须走 `ctx.llm` 属性**而不是 `ctx.get('llm')`:cordis 的服务代理会把 `this.ctx` 绑到**调用方**的 fiber,注册才不会挂到别人的生命周期上。 - **派发失败必须以 `finish` 块收尾**(`kind: 'error'`),不能向调用方抛; dsh 的流协议是 block-start → *-delta → block-end,末尾 usage → finish。 - **客户端半边是 lazy-CJS 包**(`window.__ModuleLoader__.load({id, factory})`), 由 dsh 的引导图加载,`react` 来自平台模块表。`dsh.client.inject` 是**引导图上的 行依赖**(写包名),不是 cordis 服务名 —— 写错会让整行不激活;本插件因此不声明它, 改在运行时用 `ctx.inject(['slots'], …)` 等槽服务。 - **桥与控制台都是独立进程**:插件停用/卸载只会摘掉路由、工具与入口页, **不会杀掉桥**,也不会去停别人(双击 `启动.cmd`)起的控制台 —— 只停自己拉起来的那个。 - **不会和插件抢着拉桥**:插件拉起的控制台实例带 `DASHBOARD_AUTO_START_BRIDGE=0` (桥归插件管),避免两个进程同时探测→同时 spawn 打出 `EADDRINUSE`。 想让控制台照旧自己拉桥,就在环境里显式设成 `1`。 - **控制台靠首页的结构标记识别**(`id="navTabs"`),不是"端口能连上就算数": 端口被别的服务占着时会报 `foreign` 并拒绝动手,不会误连一个陌生 HTTP 服务。 探测结果有 20 秒缓存(面板每 10 秒问一次状态,没必要每次都拉首页)。 ### 目录 ``` lib/index.js 插件入口:配置、路由注册、工具/命令/设置页装配、旧路由迁移 lib/adapter.mjs dsh 词汇 ↔ OpenAI 词汇;SSE → StreamChunk lib/bridge.mjs 桥进程管理 + HTTP 客户端 lib/console.mjs 控制台进程管理(识别 / 复用 / 拉起) lib/consoleApi.mjs 控制台 API 的白名单透传(同步设计的关键) lib/models.mjs 模型目录归一化 lib/tools.mjs 5 个模型可见工具 lib/routes.mjs HTTP 数据面(同源) lib/legacy.mjs 旧路由探测与文本级清理(带备份) lib/client.js 客户端半边:dsh 设置里的 9 标签页 tests/ 协议/装配/旧路由/控制台/透传测试 + 设置页渲染验证 ``` ### 自测 ```sh # 协议与装配(打桩上游,不碰真实额度) node --test dsh-plugin/tests/ # 或:npm run test:plugin # 入口页渲染验证(无头浏览器 + **真实数据**,产出 docs/plugin-panel.png) node dsh-plugin/tests/panel-render.mjs # 或:npm run panel:shot node dsh-plugin/tests/panel-render.mjs --offline # 只用内置样例数据 # 面板 i18n 验收:切到英文后 9 个标签页可见中文必须为 0 node dsh-plugin/tests/panel-i18n.mjs # 或:npm run panel:i18n npm run panel:check # 上面两个一起跑 ``` `panel-render.mjs` 需要无头 Chrome/Edge(项目自带的 [`tools/dev/ui-harness.mjs`](../tools/dev/ui-harness.mjs) 会去找),首次运行会把 React UMD 下到 `.tmp-research/vendor/` 供离线渲染使用。 两个脚本的桩数据与渲染骨架在 `tests/_panel-fixtures.mjs`,**共用一份** —— 各写一份必然漂移,而桩一漂移,两边验的就不是同一个东西了。 `panel-i18n.mjs` 还会把桩数据里的中文换成 ASCII:这样扫到的任何中文都只可能来自 界面文案,而不是上游返回的数据。它同时要求每个标签页的内容够厚 (≥12 个文本节点),防止「没渲染 → 0 残留」的假绿。 --- ## 验证记录 在 **Windows + DSH Desktop 0.2.0-rc.2 / profile `desktop`** 上: | 项 | 结果 | |---|---| | 纯逻辑测试 | `node --test dsh-plugin/tests/` **30 项全绿**(流协议、工具调用、错误/取消、wire 转换与净化、日志尾部读取、**方法/权限矩阵**、旧路由手术、装配、路由鉴权、.env 优先级、控制台识别与复用、console-api 白名单透传) | | 安装 | `plugin_manager install_bundle` 成功,profile 依赖为 `link:E:/workbuddy-to-dsh/dsh-plugin`(源码改动直接可见) | | 桥复用 | 复用已在运行的桥(pid 31840,已运行 12h+),未重复拉起 | | 控制台复用 | 控制台已在 8792 运行(首页结构标记匹配)→ 插件判定 `running` 并复用,不再拉起第二个 | | 原生路由 | `route.registered=true provider=workbuddy`,目录 30 个模型 | | **真实模型调用** | 以 `provider: workbuddy` + `model: deepseek-v4.1-flash` 跑通一次真实 agent 轮次,返回「桥已接通。」;桥日志同时出现该请求 | | 工具 | 5 个工具注册进当前会话并返回真实数据(状态/模型/用量/签到/桥) | | 设置页渲染(场景 1:全部可用) | 无头浏览器加载真实客户端半边:注册契约(`name`/`slots.inject`/模块级 `inject`)逐项断言;**9 个标签页逐个点开**且都有预期内容;趋势图 SVG、模型表行数、「启动控制台」确实发出 POST;无页面错误(截图 `docs/plugin-panel*.png`) | | **溢出防护** | 每个标签页都跑横向溢出检查(`scrollWidth > clientWidth` 即失败);夹具故意塞进真实机器出现的长路径 / 多账号 / 长域名。靠 `box-sizing:border-box` + 内容驱动列宽 + 分层 `overflow-wrap` 策略钉死 | | **表格布局(窄列场景 3)** | 在 **620px**(比设置页常见宽度还窄)跑一遍:逐表检查**单元格盒子互不重叠**、内容不宽于格子、单元格不被压到 34px 以下、视口无横向溢出;账号页另外要求卡片内无任何元素溢出/被裁。这套断言正是"7 列挤成一团 + 胶囊压住邻居"那张截图的机器可判定形式 | | **启动窗口 404 自愈** | 请求页的桩**故意首帧回 404**(模拟刷新页面时宿主还没挂上路由),断言面板必须自己重试恢复并渲染出行 —— 不许把 `HTTP 404` 挂在那儿等下一个轮询周期 | | **下拉可读 + 模型可选** | 原生 `