commit a44227b69a1c254438d6df233df4f9be78a33934 Author: dsh-custom Date: Sat Oct 10 13:59:09 2026 +0800 feat: 个人定制版 —— API Key 模式显示剩余积分 + 悬浮卡片 基于 @axiaohungry/dsh-llm-workbuddy 1.3.21 (MIT)。 详见 README.CUSTOM.md。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c7e917 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.bak-* +*.bak +node_modules/ +.DS_Store +Thumbs.db +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a15da4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Axiaohungry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.CUSTOM.md b/README.CUSTOM.md new file mode 100644 index 0000000..4367bcd --- /dev/null +++ b/README.CUSTOM.md @@ -0,0 +1,83 @@ +# dsh-llm-workbuddy(个人定制版) + +> 本仓库是 [`@axiaohungry/dsh-llm-workbuddy`](https://github.com/Axiaohungry/dsh-llm-workbuddy) **v1.3.21 的个人自用修改版**。 +> 原项目采用 **MIT** 许可,版权归原作者 **Axiaohungry** 所有;本定制版的改动见下。 +> 与腾讯、WorkBuddy、CodeBuddy、DeepSeek、DSH 官方**均无关联,未获其背书**。 + +原始 `README.md` 为原作者文档,**未做改动**,保留作为参考。 + +--- + +## 安装(换新机器时) + +DSH 桌面版(本机安装于 `E:\DeepSeekHarness`,路径按实际调整): + +```powershell +& "\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add git+<本仓库地址> +``` + +装完**完全退出 DSH 再启动**(客户端与 host 两侧的模块在启动时组装)。 + +> 若本机此前装过 npm 上的同名包,建议先 +> `... plugin --profile desktop remove @axiaohungry/dsh-llm-workbuddy`, +> 否则同一个 provider(`workbuddy-cn`)会注册两次而冲突。 + +--- + +## 相对原版的改动 + +| # | 位置 | 改动 | +|---|---|---| +| 1 | `client.js` `WorkBuddyCreditsDock` | 移除凭证绑定控件、认证模式选择、多 Key 下拉与「当前会话 API Key」文案,只保留一行「剩余积分 xxx」 | +| 2 | `client.js` `:408` 一带 | 原版在 API Key 模式下**提前 return 不查积分**;本版改为继续查询 | +| 3 | `workbuddy-web.js` credits 路由 | 非令牌模式不再直接返回「积分查询仅支持令牌登录」,改为读当前 API Key 查账单接口 | +| 4 | `workbuddy-credits.js` `queryPersonalCredits` | 新增返回 `planName` / `planCycleEnd`(取账单中 `CapacityType=2` 且名字含「高级版/标准版/旗舰版/专业版」的条目) | +| 5 | `client.js` | 积分项改为可点击的「药丸」,hover 高亮 + 点击弹出卡片:`WorkBuddy` / 剩余积分 / 套餐 / 下次重置时间;点外部或按 Esc 关闭 | +| 6 | `client.js` CSS | dock 的 `overflow: hidden` → `visible`(否则弹出的卡片会被父容器裁掉) | +| 7 | `client.js` `mountPendingSelector` | 移除新建会话时的「新会话默认凭证」选择器 | +| 8 | `client.js` `apply()` | 注释掉 `plugins.detail.section` 注册(移除插件详情页面板)。**保留** `settings.models.provider-card`(改 Key / 拉模型列表的唯一 UI 入口) | +| 9 | `client.js` `applyMode` | 隐藏「当前 API Key」多 Key 来源下拉,且不再回显 Key 内容 | +| 10 | `index.js` | `DISPLAY_NAME` 由 `WorkBuddy 中国区` 改为 `WorkBuddy`(不显示区服) | + +代码内均以 `[自用定制 2026-10-10]` 注释标记,便于日后与上游 diff。 + +--- + +## 为什么积分能查到(原理) + +WorkBuddy 的账单接口 `POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource` +**接受 API Key 作为 `Authorization: Bearer` 令牌**(原版插件只是没这么做,它认为只有网页登录令牌可用)。 + +请求体: + +```json +{ + "PageNumber": 1, + "PageSize": 100, + "ProductCode": "p_tcaca", + "Status": [0, 3], + "PackageEndTimeRangeBegin": "当前时间", + "PackageEndTimeRangeEnd": "当前时间 + 101 年" +} +``` + +返回的 `data.Response.Data.Accounts[]` 中: + +- `CapacityType = 2` → **套餐 / 会员类**(本条用来取 `PackageName` 与 `CycleEndTime`) +- `CapacityType = 1` → 积分包(签到、活动、兑换码赠送) + +> 注:`GET /console/accounts` 对 API Key 返回 **401**,只认网页登录令牌,所以套餐类信息只能从上面这个接口筛。 + +--- + +## ⚠️ 注意事项 + +1. **本版移除了配置 UI**,只适合**已经配好 Key 的自用场景**。若要分享给别人,请让对方用上游原版。 +2. 依赖 WorkBuddy **未公开**的接口,上游随时可能改动或收紧风控,**可用性无任何保证**。 +3. **仅限本机、仅供自用**:请勿用于多账号池化、代他人调用或任何形式的转售 / 托管。 +4. 使用前请自行确认是否符合 WorkBuddy 的服务条款。 +5. 按 MIT「按现状」提供,作者不对账号风控、额度异常或任何损失负责。 + +## 许可 + +MIT —— 见 `LICENSE`(原作者的版权声明必须保留)。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..50f6539 --- /dev/null +++ b/README.md @@ -0,0 +1,286 @@ +# DSH WorkBuddy Provider + +为 DeepSeek Harness(DSH)增加 `WorkBuddy 中国区` Provider。插件通过 +WorkBuddy 提供的 API Key,或 WorkBuddy 中国站的网页登录令牌调用模型,并在 +DSH WebUI 中管理模型和认证方式。 + +> [!IMPORTANT] +> API Key 来自 **WorkBuddy**,用于调用供 WorkBuddy 使用的模型服务。本插件是第三方 +> 适配器,不属于 WorkBuddy 或 DSH 官方项目。 + +## 功能 + +- 在 DSH WebUI 中使用 `WorkBuddy 中国区`; +- 支持 WorkBuddy API Key 和 WorkBuddy 中国站账号令牌; +- 两种认证模式互相独立,可以随时切换; +- API Key 支持环境变量、多个 DSH 保存值,并可通过下拉列表切换; +- 令牌支持浏览器登录、多个账号持久化、账号名称展示和下拉切换; +- 令牌模式显示剩余积分、今日请求次数和今日积分用量; +- 自动兼容 `modlens-workbuddy-cn` 等带有 WorkBuddy/CodeBuddy 标识的包装 Provider; +- 用量信息与 DSH 的 Token/缓存统计显示在输入框底部同一行; +- 自动从 WorkBuddy 获取当前账号可用模型; +- 支持编辑模型 ID、名称、上下文窗口、最大输出 Token 和模型思考档位; +- 支持添加、删除模型以及恢复在线模型目录; +- 不依赖本机 WorkBuddy CLI。 + +## 环境要求 + +- 已安装 DSH; +- Node.js `>= 22.19.0`; +- Windows、Linux 或 macOS。 + +插件 `1.3.7` 及以上版本要求 DSH `>= 0.1.2-alpha.2`。`1.3.9` 起兼容新版 DSH +的 settings 注册接口。该版本不再携带旧版 DSH +运行时副本,而是复用宿主 DSH 的运行时,避免更新 DSH 后出现 Provider 目录接口 +(`llm/listProviders`)不兼容。`1.3.10` 起,WorkBuddy 认证助手同时兼容新旧 DSH +的 `signal` 调用约定。升级插件后请重新安装一次并重启 DSH。 + +插件兼容旧版 DSH 和 `0.1.7-rc.1`(`next`)的按插件条目管理配置方式。 +旧版由插件接管 `llm-pi-ai`;`next` 保留内置 `llm-pi-ai` 处理自定义提供方, +WorkBuddy 使用自己的配置条目。升级后请完整退出并重新启动 DSH,再检查模型卡片与凭证模式。 +桌面版 DSH `0.2.0-rc.2` 同样保留内置 `llm-pi-ai`;请从桌面端「插件 → 添加插件」 +安装本插件。下方的命令行安装器仅管理 `web` 和 `headless` Profile,不会安装到桌面端。 +在 `next` 上,命令行 `login` 只保存令牌;认证模式需在 WorkBuddy 模型卡片中选择 +“令牌登录”,不会再写入旧版 `settings.yaml`。`next` 的模型页通过插件扩展位显示 +WorkBuddy 专属认证与模型管理区域;旧版 DSH 继续使用原有编辑界面。 + +## 安装 + +在 PowerShell 或终端执行: + +```powershell +npx --yes @axiaohungry/dsh-llm-workbuddy@latest install +``` + +安装器会为 DSH 的 `web` 和 `headless` Profile 安装插件。安装完成后重启 DSH。 +只使用 WebUI 时,也可以单独安装 Web Profile: + +```powershell +dsh plugin --profile web add @axiaohungry/dsh-llm-workbuddy@latest +``` + +### 从旧版升级 + +插件 npm 包现在是 `@axiaohungry/dsh-llm-workbuddy`。重新执行上面的安装命令时,安装器会在 +`web` 和 `headless` Profile 中自动移除旧包(包括 `dsh-llm-workbuddy` 和 +`dsh-llm-codebuddy`),再安装新包;已有 Provider、模型、 +API Key 和令牌凭据会保留。 + +新版本内部 Provider ID 为 `workbuddy-cn`。旧配置中的 `codebuddy-cn` 会在运行时兼容,并在 +切换认证模式时迁移为新 ID。 + +## WebUI 配置 + +打开 **设置 → 模型**。在 `next` 中,点击“添加模型提供商”,从“第三方模型提供商” +选择 `WorkBuddy 中国区`;已配置后直接编辑它的模型卡片。旧版 DSH 继续从提供方列表 +添加或编辑。认证区域有两个模式按钮: + +- `API Key`:只显示 API Key 来源和新增 Key 功能; +- `令牌登录`:只显示令牌账号、登录、切换以及账号用量信息。 + +切换模式后,另一种模式的账号或 Key 控件会隐藏,不会同时占用页面空间。 + +### 可选:会话级账号/API Key + +认证区域提供“会话级账号/API Key”开关: + +- **关闭(默认)**:保持旧版行为。所有会话共用模型设置中当前选中的全局账号或 API Key; + 不会改变已有配置和调用方式。 +- **打开**:模型设置中选择的账号或 API Key 作为未绑定会话的默认预选。每个会话也可以在 + 输入框底部单独选择“令牌 / API Key”以及对应账号或 Key。切换当前会话的凭证不会修改 + 其他会话,也不会中断已有会话上下文。 + +会话级绑定只保存会话 ID 与账号 ID/API Key 引用,不会把令牌或 API Key 写入会话路由配置。 +关闭开关后,已保存的绑定会暂时停用;重新打开后会继续生效。新增或删除账号、Key 时, +对应的无效绑定会自动清理。 + +开启后,点击“新会话”时会在输入框上方显示“新会话默认凭证”,可以在发送第一条消息前 +选择令牌账号或 API Key。新会话或历史未绑定会话第一次调用 WorkBuddy 模型时,插件会先把 +当时的默认预选固化到该 `sessionId`,再发出模型请求。固化后,即使模型设置中的默认预选发生 +变化,该会话仍继续使用自己的绑定;也可以在会话底部再次切换或解除绑定。解除后,下一次发送 +会重新固化当时的默认预选。运行时请求如果缺少 `sessionId`,则使用原有全局认证配置。 + +模型目录仍由 DSH 统一管理,模型选择本身按会话保存。更换会话凭证后,如果该账号的模型 +权限不同,请在模型设置中重新获取一次在线模型目录。 + +### 方式一:WorkBuddy API Key + +#### 使用环境变量中的 Key + +插件会自动检测: + +```text +WORKBUDDY_API_KEY +``` + +如果这个环境变量存在,API Key 来源下拉列表会显示“环境变量 WORKBUDDY_API_KEY”。 +选择它即可使用;密钥值不会显示在页面中,也不会写入 `settings.yaml`。 + +旧版插件使用的 `CODEBUDDY_API_KEY` 仍会作为兼容环境变量自动识别,但新配置建议统一使用 +`WORKBUDDY_API_KEY`。 + +#### 在 DSH 中保存新的 Key + +1. 选择 `API Key` 模式; +2. 在“新增 API Key”输入框粘贴 WorkBuddy Key; +3. 可填写一个名称,例如“工作账号”或“测试账号”; +4. 点击“添加并使用”; +5. 新 Key 会出现在“当前 API Key”下拉列表中,并立即切换为当前 Key。 + +DSH 保存的 Key 存放在 DSH 凭据服务中。可以保存多个 Key 并随时切换;环境变量 Key +和 DSH 保存的 Key 互不覆盖。删除操作只允许删除 DSH 保存的 Key,不会删除环境变量。 + +### 方式二:WorkBuddy 账号令牌 + +1. 选择 `令牌登录` 模式; +2. 点击“令牌登录”(已有账号时为“添加账号”),插件会打开 WorkBuddy 中国站; +3. 在浏览器完成账号登录; +4. 返回 DSH 后,账号会保存到本地凭据并自动启用。 + +再次打开页面时,可以在“令牌账号”下拉列表中切换账号。点击“添加账号”可以登录 +另一个账号,点击“删除账号”会移除当前账号的本地令牌。令牌登录不需要安装 +本机 WorkBuddy 命令行工具。 + +令牌模式下,插件会尝试显示: + +- 剩余积分或企业账号的“不限量”; +- 今日请求次数; +- 今日请求消耗的积分。 + +积分接口属于 WorkBuddy 中国站的账号服务,仅令牌模式使用;API Key 模式不会调用该 +接口。如果服务暂时不可用,页面会保留账号登录状态并显示“暂不可用”。 + +## 获取和编辑模型 + +1. 在 `WorkBuddy 中国区` 的设置中点击“获取可用模型”; +2. 插件会使用当前选中的 API Key 或令牌请求 WorkBuddy 模型目录; +3. 选择要使用的模型,按需使用“添加模型”补充,然后点击“保存模型”; +4. 再次编辑时,可以直接修改模型 ID、显示名称、上下文窗口和最大输出 Token。 + +“恢复在线目录”会移除手工覆盖的模型列表,恢复按在线目录读取。获取在线模型后, +若要固定当前选择或编辑结果,仍需点击“保存模型”。 + +模型目录按当前账号权限返回。更换 API Key 或令牌账号后,建议重新点击“获取可用模型”。 +如果在线目录暂时失败,插件会使用内置目录作为兜底。 +在 headless 模式指定内置目录尚未收录的 WorkBuddy 模型时,插件会在调用前查询当前 +凭证可用的在线模型目录;模型仍不存在或查询失败时会明确报错。若在配置中手动指定了 +模型列表,请把要使用的模型 ID 也加入该列表。 + +旧版 DSH 中,插件接管 `llm-pi-ai` 后也负责自定义 OpenAI Completions、OpenAI Responses +和 Anthropic Messages 提供方的模型探测;`next` 则保留 DSH 内置的自定义提供方页面与 +探测器。两种方式都会请求已配置的模型端点;服务未提供标准目录时,可手工填写模型 ID。 + +## ModLens 兼容性 + +插件兼容 `modlens-workbuddy-cn`、`modlens-codebuddy-cn` 等包装 Provider。包装 Provider +会先由 ModLens 处理图片,再把请求转发给 WorkBuddy,因此它不是另一个 WorkBuddy 账号: + +- 会话历史中有图片时,首次响应可能比 WorkBuddy 直连慢;旧会话中的历史图片也可能再次参与处理; +- 超时、无响应或 `429`/“配额耗尽”通常来自 ModLens 的视觉引擎,不代表 WorkBuddy 令牌或 API Key 失效; +- 纯文本任务可切换到 `workbuddy-cn/`,绕过视觉桥接; +- WorkBuddy 的令牌积分和请求量与 ModLens 视觉引擎额度相互独立,插件不会混合统计。 +- ModLens 会保留原始 `sessionId`,因此与 WorkBuddy 直连使用同一套会话凭证固化和切换规则。 + +遇到包装模型异常时,可运行 ModLens 提供的诊断命令检查视觉引擎状态: + +```powershell +modlens doctor --json +``` + +如果纯文本直连正常、而带历史图片的请求变慢或失败,优先检查 ModLens 的视觉引擎登录状态、网络 +和额度;切换 WorkBuddy 账号不会恢复已经耗尽的视觉引擎配额。 + +## 思考程度 + +思考档位按模型分别决定,插件不会给所有模型强行使用同一套选项。在线模型目录会声明 +每个模型是否支持思考、支持哪些档位以及默认档位,WebUI 会据此显示可用选项。 + +常见档位包括: + +```text +off / minimal / low / medium / high / xhigh / max +``` + +实际选项可能因模型不同而不同。未手动指定时,使用 WorkBuddy 返回的默认档位;服务端 +没有声明思考能力时,插件不会额外发送思考参数。 + +## 认证和请求说明 + +- API Key 请求使用 WorkBuddy 的 OpenAI-compatible 接口; +- 令牌请求使用 WorkBuddy 中国站的登录令牌,并在过期前自动刷新; +- DSH 负责 Agent 循环、上下文、工具调用和权限; +- WorkBuddy 负责模型推理并返回结果; +- 访问令牌、刷新令牌和 DSH 保存的 API Key 不会写入模型目录或 `settings.yaml`。 + +## 更新 + +重新执行安装命令即可更新: + +```powershell +npx --yes @axiaohungry/dsh-llm-workbuddy@latest install +``` + +更新后重启 DSH。已有的模型配置、API Key 和登录令牌会保留。 + +## 卸载 + +```powershell +npx --yes @axiaohungry/dsh-llm-workbuddy@latest uninstall +``` + +卸载会移除 `WorkBuddy 中国区` 的 Provider 和插件包,并备份 DSH 设置文件。为方便以后 +重新安装,API Key 和登录令牌默认保留在 DSH 凭据服务中。 + +## 常见问题 + +### 看不到 WorkBuddy Provider + +重启 DSH 后重新打开 **设置 → 模型**。也可以检查: + +```powershell +dsh plugin --profile web list --depth 0 +``` + +### API Key 下拉列表只有环境变量 + +不要使用 DSH 原生的旧密钥输入框提交。选择 `API Key` 模式,在插件自己的“新增 API Key” +区域粘贴 Key 并点击“添加并使用”。成功后新 Key 会出现在“当前 API Key”下拉列表。 + +### 获取模型失败 + +确认当前选中的 Key 来自 WorkBuddy 且仍然有效,或确认令牌账号登录状态正常。更换认证 +凭据后重新获取模型目录。如果出现 `Unexpected end of JSON input`、空响应或模型页接口 +`404`,请先完整退出并重新启动 DSH,再刷新页面;只刷新浏览器不会更新正在运行的后端插件。 +若返回 `401` 或 `403`,则检查当前凭证及该账号的模型权限。 + +### 请求卡住或超时 + +插件会在 DSH 日志中记录不含密钥、令牌和消息内容的请求阶段:认证、HTTP 响应头、 +首个模型事件、完成或取消。可用同一请求编号判断停在认证、连接还是生成阶段; +若使用 ModLens 包装 Provider,还应单独检查其视觉引擎状态与额度。 + +### 令牌登录后没有积分 + +积分查询只支持令牌模式,并依赖 WorkBuddy 中国站 billing 接口。网络、账号类型或接口 +暂时不可用时,页面会显示“暂不可用”,不影响模型调用。 + +用量栏会识别 `workbuddy` 或 `codebuddy` 词段的 Provider ID,因此通过 ModLens 等插件 +包装的 Provider(例如 `modlens-workbuddy-cn`)也可以显示当前账号用量。 + +### 调用时报 `500 status code (no body)` + +先更新插件并重启 DSH: + +```powershell +npx --yes @axiaohungry/dsh-llm-workbuddy@latest install +``` + +最新版会恢复 WorkBuddy 官方请求标识,兼容 API Key 和令牌模式。 + +### 修改后页面仍显示旧布局 + +完整退出并重新启动 DSH,再在浏览器执行 `Ctrl+F5` 强制刷新。 + +## License + +[MIT](./LICENSE) diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..6f2a4be --- /dev/null +++ b/cli.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node + +import { copyFileSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { createRequire } from "node:module"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { parseDocument } from "yaml"; +import { + WORKBUDDY_SESSION_REF, + WORKBUDDY_SESSIONS_REF, + LEGACY_SESSION_REF, + LEGACY_SESSIONS_REF, + createWorkBuddySessionStore, + loginWorkBuddy, + parseWorkBuddySession, + parseWorkBuddySessions, + serializeWorkBuddySession, + serializeWorkBuddySessions, + upsertWorkBuddySession, +} from "./workbuddy-auth.js"; + +const PACKAGE = "@axiaohungry/dsh-llm-workbuddy"; +const LEGACY_PACKAGES = ["dsh-llm-workbuddy", "dsh-llm-codebuddy"]; +const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; +const PACKAGE_SPEC = `${PACKAGE}@${PACKAGE_VERSION}`; +const PROVIDER_PATH = ["llm-pi-ai", "providers", "workbuddy-cn"]; +const LEGACY_PROVIDER_PATH = ["llm-pi-ai", "providers", "codebuddy-cn"]; +const IGNORED_BUILDS = ["@google/genai", "protobufjs"]; +const require = createRequire(import.meta.url); + +function dshHome() { + return resolve(process.env.DSH_HOME || join(homedir(), ".dsh")); +} + +function profileHasPackage(home, profile, packageName) { + const file = join(home, "profiles", profile, "package.json"); + if (!existsSync(file)) return false; + const json = JSON.parse(readFileSync(file, "utf8")); + return Boolean(json.dependencies?.[packageName] || json.devDependencies?.[packageName]); +} + +function profileHasPlugin(home, profile) { + return profileHasPackage(home, profile, PACKAGE); +} + +function dshEnv() { + const pnpmPackageDir = dirname(require.resolve("pnpm")); + const pnpmBinDir = join(dirname(pnpmPackageDir), ".bin"); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH"; + return { + ...process.env, + [pathKey]: `${pnpmBinDir}${delimiter}${process.env[pathKey] || ""}`, + npm_config_ignore_workspace_root_check: "true", + }; +} + +function runDsh(args) { + const result = spawnSync(process.platform === "win32" ? "dsh.cmd" : "dsh", args, { + stdio: "inherit", + shell: process.platform === "win32", + env: dshEnv(), + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`dsh ${args.join(" ")} 执行失败(退出码 ${result.status})`); +} + +function entrySettingsVersion(output) { + const version = String(output).match(/\b(\d+)\.(\d+)\.(\d+)(?:[-.]|\b)/); + if (!version) return undefined; + const [, major, minor, patch] = version.map(Number); + return major > 0 || minor > 1 || (minor === 1 && patch >= 7); +} + +function usesEntrySettings() { + const result = spawnSync(process.platform === "win32" ? "dsh.cmd" : "dsh", ["--version"], { + encoding: "utf8", + shell: process.platform === "win32", + env: dshEnv(), + }); + if (result.error || result.status !== 0) throw new Error("无法检测 DSH 版本,请确认 dsh 命令可用"); + const modern = entrySettingsVersion(result.stdout); + if (modern === undefined) throw new Error("无法识别 DSH 版本,令牌已保存;请在模型设置中手动选择令牌登录"); + return modern; +} + +function writeYamlDocument(file, document) { + const temporary = join(dirname(file), `.workbuddy-${process.pid}.tmp`); + writeFileSync(temporary, String(document), "utf8"); + renameSync(temporary, file); +} + +function withPnpmBuildPolicy(file, action) { + const document = parseDocument(readFileSync(file, "utf8")); + if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`); + const changes = []; + for (const packageName of IGNORED_BUILDS) { + const path = ["allowBuilds", packageName]; + if (typeof document.getIn(path) !== "boolean") { + changes.push({ packageName, existed: document.hasIn(path), value: document.getIn(path) }); + document.setIn(path, false); + } + } + if (changes.length) writeYamlDocument(file, document); + try { + return action(); + } finally { + if (changes.length) { + const current = parseDocument(readFileSync(file, "utf8")); + for (const change of changes) { + const path = ["allowBuilds", change.packageName]; + if (change.existed) current.setIn(path, change.value); + else current.deleteIn(path); + } + if (current.getIn(["allowBuilds"])?.items?.length === 0) current.deleteIn(["allowBuilds"]); + writeYamlDocument(file, current); + } + } +} + +function cleanPnpmWorkspace(file) { + if (!existsSync(file)) return; + const document = parseDocument(readFileSync(file, "utf8")); + if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`); + const entries = document.getIn(["minimumReleaseAgeExclude"])?.items; + if (!entries) return; + const packageNames = [PACKAGE, ...LEGACY_PACKAGES]; + const remaining = entries.map((entry) => entry.value).filter((entry) => !packageNames.some((packageName) => String(entry).startsWith(`${packageName}@`))); + if (remaining.length === entries.length) return; + if (remaining.length) document.setIn(["minimumReleaseAgeExclude"], remaining); + else document.deleteIn(["minimumReleaseAgeExclude"]); + writeYamlDocument(file, document); +} + +function cleanSettings(file) { + if (!existsSync(file)) return undefined; + const source = readFileSync(file, "utf8"); + const document = parseDocument(source); + if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`); + const paths = [PROVIDER_PATH, LEGACY_PROVIDER_PATH].filter((path) => document.hasIn(path)); + if (paths.length === 0) return undefined; + + for (const path of paths) document.deleteIn(path); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = `${file}.workbuddy-backup-${stamp}`; + const temporary = join(dirname(file), `.settings-workbuddy-${process.pid}.tmp`); + copyFileSync(file, backup); + writeFileSync(temporary, String(document), "utf8"); + renameSync(temporary, file); + return backup; +} + +function enableTokenLogin(home = dshHome()) { + const file = join(home, "settings.yaml"); + const source = existsSync(file) ? readFileSync(file, "utf8") : "{}\n"; + const document = parseDocument(source); + if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`); + if (document.hasIn(PROVIDER_PATH)) document.deleteIn([...PROVIDER_PATH, "apiKeyEnv"]); + else if (document.hasIn(LEGACY_PROVIDER_PATH)) { + const legacy = document.getIn(LEGACY_PROVIDER_PATH); + const value = legacy && typeof legacy.toJSON === "function" ? legacy.toJSON() : legacy; + document.setIn(PROVIDER_PATH, document.createNode(value && typeof value === "object" ? value : {})); + document.deleteIn([...PROVIDER_PATH, "apiKeyEnv"]); + document.deleteIn(LEGACY_PROVIDER_PATH); + } + else document.setIn(PROVIDER_PATH, {}); + if (existsSync(file)) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + copyFileSync(file, `${file}.workbuddy-backup-${stamp}`); + } + writeYamlDocument(file, document); +} + +function storeLoginSession(session, home = dshHome()) { + const file = join(home, ".credentials.yaml"); + const document = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "{}\n"); + if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`); + const stored = document.get(WORKBUDDY_SESSIONS_REF) + ?? document.get(LEGACY_SESSIONS_REF) + ?? document.get(WORKBUDDY_SESSION_REF) + ?? document.get(LEGACY_SESSION_REF); + const current = stored ? parseWorkBuddySessions(stored) : createWorkBuddySessionStore(); + const next = upsertWorkBuddySession(current, session); + document.set(WORKBUDDY_SESSIONS_REF, serializeWorkBuddySessions(next)); + document.set(WORKBUDDY_SESSION_REF, serializeWorkBuddySession(next.sessions.find((entry) => entry.id === next.activeId))); + document.delete(LEGACY_SESSIONS_REF); + document.delete(LEGACY_SESSION_REF); + if (existsSync(file)) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + copyFileSync(file, `${file}.workbuddy-backup-${stamp}`); + } + writeYamlDocument(file, document); +} + +async function login() { + console.log("正在打开 WorkBuddy 中国站网页登录(无需安装 WorkBuddy CLI)……"); + const session = await loginWorkBuddy((url, opened) => { + if (opened) console.log("浏览器登录页已打开,请在浏览器中完成登录。"); + else console.log(`无法自动打开浏览器,请手动访问:${url}`); + }); + storeLoginSession(session); + if (usesEntrySettings()) { + console.log("WorkBuddy 令牌登录成功。新版 DSH 按插件条目保存配置,请在模型设置的 WorkBuddy 卡片中选择“令牌登录”;无需安装 WorkBuddy CLI。"); + } else { + enableTokenLogin(); + console.log("WorkBuddy 令牌登录成功,Provider 已切换为令牌模式。请重启 DSH。"); + } +} + +function install() { + for (const profile of ["web", "headless"]) { + runDsh(["plugin", "--profile", profile, "list", "--depth", "0"]); + const workspace = join(dshHome(), "profiles", profile, "pnpm-workspace.yaml"); + withPnpmBuildPolicy(workspace, () => { + for (const legacyPackage of LEGACY_PACKAGES) { + if (profileHasPackage(dshHome(), profile, legacyPackage)) { + runDsh(["plugin", "--profile", profile, "remove", legacyPackage]); + } + } + runDsh(["plugin", "--profile", profile, "add", PACKAGE_SPEC]); + }); + } + console.log("WorkBuddy Provider 已安装。请重启 DSH 后进行配置。"); +} + +function uninstall(home = dshHome()) { + const backup = cleanSettings(join(home, "settings.yaml")); + for (const profile of ["web", "headless"]) { + const workspace = join(home, "profiles", profile, "pnpm-workspace.yaml"); + const installedPackages = [PACKAGE, ...LEGACY_PACKAGES].filter((packageName) => profileHasPackage(home, profile, packageName)); + if (installedPackages.length) { + withPnpmBuildPolicy(workspace, () => { + for (const packageName of installedPackages) { + runDsh(["plugin", "--profile", profile, "remove", packageName]); + } + }); + } + cleanPnpmWorkspace(workspace); + } + console.log(backup ? `WorkBuddy 配置已清理,备份:${backup}` : "未发现 WorkBuddy Provider 配置。"); + console.log("插件已卸载,API Key 和登录令牌凭据保持不变。请重启 DSH。"); +} + +function selfTest() { + const root = mkdtempSync(join(tmpdir(), "dsh-workbuddy-cli-")); + try { + if (entrySettingsVersion("dsh 0.1.6") !== false || entrySettingsVersion("dsh 0.1.7-rc.1") !== true || entrySettingsVersion("dsh 0.2.0-rc.2") !== true) { + throw new Error("DSH version detection self-test failed"); + } + const file = join(root, "settings.yaml"); + writeFileSync(file, "llm-pi-ai:\n providers:\n opencode-go:\n apiKeyEnv: OPENCODE_GO_API_KEY\n codebuddy-cn:\n apiKeyEnv: WORKBUDDY_CN_API_KEY\n models:\n - id: legacy-model\n", "utf8"); + enableTokenLogin(root); + const tokenMode = parseDocument(readFileSync(file, "utf8")); + if (tokenMode.hasIn([...PROVIDER_PATH, "apiKeyEnv"]) || !tokenMode.hasIn(PROVIDER_PATH) || tokenMode.hasIn(LEGACY_PROVIDER_PATH) || !tokenMode.hasIn([...PROVIDER_PATH, "models", 0, "id"])) { + throw new Error("token login settings self-test failed"); + } + const backup = cleanSettings(file); + const result = parseDocument(readFileSync(file, "utf8")); + if (!backup || !existsSync(backup) || result.hasIn(PROVIDER_PATH) || !result.hasIn(["llm-pi-ai", "providers", "opencode-go"])) { + throw new Error("uninstall settings cleanup self-test failed"); + } + enableTokenLogin(root); + if (!parseDocument(readFileSync(file, "utf8")).hasIn(PROVIDER_PATH)) { + throw new Error("token login provider creation self-test failed"); + } + const sampleSession = { + auth: { accessToken: "test-access-token", refreshToken: "test-refresh-token", expiresAt: Date.now() + 60_000 }, + account: { userId: "test-user" }, + }; + storeLoginSession(sampleSession, root); + const storedSession = parseDocument(readFileSync(join(root, ".credentials.yaml"), "utf8")).get(WORKBUDDY_SESSION_REF); + if (parseWorkBuddySession(storedSession).account.userId !== "test-user") { + throw new Error("token credential storage self-test failed"); + } + const storedSessions = parseDocument(readFileSync(join(root, ".credentials.yaml"), "utf8")).get(WORKBUDDY_SESSIONS_REF); + const sessionStore = parseWorkBuddySessions(storedSessions); + if (sessionStore.sessions.length !== 1 || sessionStore.activeId !== sessionStore.sessions[0].id) { + throw new Error("token account list self-test failed"); + } + const workspace = join(root, "pnpm-workspace.yaml"); + writeFileSync(workspace, "packages:\n - .\nallowBuilds:\n '@google/genai': true\n protobufjs: pending\n", "utf8"); + withPnpmBuildPolicy(workspace, () => { + const active = parseDocument(readFileSync(workspace, "utf8")); + if (active.getIn(["allowBuilds", "@google/genai"]) !== true || active.getIn(["allowBuilds", "protobufjs"]) !== false) { + throw new Error("pnpm build policy activation self-test failed"); + } + }); + const restored = parseDocument(readFileSync(workspace, "utf8")); + if (restored.getIn(["allowBuilds", "@google/genai"]) !== true || restored.getIn(["allowBuilds", "protobufjs"]) !== "pending") { + throw new Error("pnpm build policy self-test failed"); + } + restored.setIn(["minimumReleaseAgeExclude"], ["other@1.0.0", `${PACKAGE}@1.3.1`]); + writeYamlDocument(workspace, restored); + cleanPnpmWorkspace(workspace); + const cleaned = parseDocument(readFileSync(workspace, "utf8")).getIn(["minimumReleaseAgeExclude"])?.items?.map((entry) => entry.value); + if (cleaned?.join(",") !== "other@1.0.0") throw new Error("pnpm workspace cleanup self-test failed"); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH"; + if (!dshEnv()[pathKey].split(delimiter)[0].endsWith(join("node_modules", ".bin"))) { + throw new Error("bundled pnpm PATH self-test failed"); + } + console.log("CLI-SELF-TEST-OK"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const command = process.argv[2]; +if (command === "install") install(); +else if (command === "uninstall") uninstall(); +else if (command === "login") await login(); +else if (command === "--self-test") selfTest(); +else { + console.log("用法:dsh-llm-workbuddy "); + process.exitCode = 1; +} diff --git a/client.js b/client.js new file mode 100644 index 0000000..9b152a5 --- /dev/null +++ b/client.js @@ -0,0 +1,1427 @@ +window.__ModuleLoader__.load({ + id: "@axiaohungry/dsh-llm-workbuddy", + factory: (require) => { + const ROUTE = "/dsh-llm-workbuddy/auth"; + const MARKER = "data-workbuddy-auth-switch"; + const AUTH_STATE_EVENT = "dsh-llm-workbuddy:auth-state"; + const PENDING_MARKER = "data-workbuddy-new-session-selector"; + const WORKBUDDY_PROVIDER_PATTERN = /(?:^|-)(?:work-?buddy|code-?buddy)(?:-|$)/; + const React = require("react"); + const { createElement, useEffect, useRef, useState } = React; + + function isWorkBuddyProvider(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized.length > 0 && WORKBUDDY_PROVIDER_PATTERN.test(normalized); + } + + function isModLensWorkBuddyProvider(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return /^modlens-(?:work-?buddy|code-?buddy)(?:-|$)/.test(normalized); + } + + function button(text) { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = text; + Object.assign(element.style, { + minHeight: "44px", + padding: "0 12px", + border: "1px solid var(--dsw-border-subtle, #d0d5dd)", + borderRadius: "8px", + background: "var(--dsw-surface-subtle, transparent)", + color: "inherit", + cursor: "pointer", + whiteSpace: "nowrap", + boxSizing: "border-box", + font: "inherit", + fontSize: "13px", + fontWeight: "500", + transition: "background-color 120ms ease, border-color 120ms ease, opacity 120ms ease", + }); + return element; + } + + function accountPicker() { + const element = document.createElement("select"); + element.setAttribute("aria-label", "WorkBuddy 令牌账号"); + Object.assign(element.style, { + minHeight: "44px", + minWidth: "180px", + maxWidth: "260px", + flex: "1 1 220px", + boxSizing: "border-box", + padding: "0 10px", + border: "1px solid var(--dsw-border-subtle, #d0d5dd)", + borderRadius: "8px", + background: "var(--dsw-surface-subtle, transparent)", + color: "inherit", + font: "inherit", + fontSize: "13px", + }); + return element; + } + + function textInput(type, placeholder, ariaLabel) { + const element = document.createElement("input"); + element.type = type; + element.placeholder = placeholder; + element.setAttribute("aria-label", ariaLabel); + element.autocomplete = type === "password" ? "new-password" : "off"; + Object.assign(element.style, { + minHeight: "44px", + minWidth: "0", + flex: "1 1 200px", + boxSizing: "border-box", + padding: "0 12px", + border: "1px solid var(--dsw-border-subtle, #d0d5dd)", + borderRadius: "8px", + background: "var(--dsw-surface-subtle, transparent)", + color: "inherit", + font: "inherit", + fontSize: "13px", + }); + return element; + } + + function fieldLabel(text) { + const element = document.createElement("span"); + element.textContent = text; + Object.assign(element.style, { + flex: "0 0 auto", + minWidth: "92px", + fontSize: "13px", + lineHeight: "20px", + color: "var(--dsw-text-secondary, #667085)", + }); + return element; + } + + function row() { + const element = document.createElement("div"); + Object.assign(element.style, { + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", + width: "100%", + }); + return element; + } + + function section() { + const element = document.createElement("div"); + Object.assign(element.style, { + display: "flex", + flexDirection: "column", + gap: "8px", + width: "100%", + boxSizing: "border-box", + padding: "10px 12px", + border: "1px solid var(--dsw-border-subtle, #d0d5dd)", + borderRadius: "10px", + background: "var(--dsw-surface-subtle, rgba(0, 0, 0, 0.02))", + }); + return element; + } + + function setVisible(element, visible, display = "flex") { + element.hidden = !visible; + element.style.setProperty("display", visible ? display : "none", "important"); + } + + function isWorkBuddy(input) { + const editor = input.parentElement?.parentElement; + if (!editor) return false; + if (isWorkBuddyProvider(editor.textContent)) return true; + const provider = editor.parentElement?.querySelector('select[aria-label="提供方"]')?.value; + return isWorkBuddyProvider(provider); + } + + function accountText(account) { + const label = account?.accountName || account?.label || account?.account?.displayName || account?.account?.email || account?.account?.userId || "未命名账号"; + const userId = account?.userId || account?.account?.userId; + return userId && userId !== label ? `${label} · ${String(userId).slice(0, 8)}` : label; + } + + function accountLabel(account) { + return account?.accountName || account?.label || account?.account?.displayName || account?.account?.email || account?.account?.userId || "未命名账号"; + } + + function apiKeyText(key) { + const label = key?.label || (key?.kind === "environment" ? `环境变量 ${key.ref}` : "DSH 保存的 API Key"); + const suffix = key?.masked ? ` · ${key.masked}` : ""; + return key?.configured === false ? `${label}(不可用)` : `${label}${suffix}`; + } + + function apiKeyLabel(key) { + return key?.label || (key?.kind === "environment" ? `环境变量 ${key.ref}` : "DSH 保存的 API Key"); + } + + function formatCredits(value) { + const number = Number(value); + return Number.isFinite(number) ? number.toLocaleString("zh-CN", { maximumFractionDigits: 2 }) : "—"; + } + + async function authRequest(path, body, sessionId) { + const options = { + method: body === undefined ? "GET" : "POST", + cache: "no-store", + }; + if (body !== undefined) { + options.headers = { "content-type": "application/json" }; + options.body = JSON.stringify({ ...body, ...(sessionId && body.sessionId === undefined ? { sessionId: String(sessionId) } : {}) }); + } + const query = sessionId && body === undefined ? `?sessionId=${encodeURIComponent(String(sessionId))}` : ""; + const response = await fetch(`${ROUTE}/${path}${query}`, options); + const text = await response.text(); + let result = {}; + try { + result = text ? JSON.parse(text) : {}; + } catch { + result = {}; + } + if (!response.ok || result.ok === false) { + const error = new Error(result.message || `请求失败(${response.status})`); + error.status = response.status; + throw error; + } + return result; + } + + function notifyAuthState() { + if (typeof window !== "undefined") window.dispatchEvent(new Event(AUTH_STATE_EVENT)); + if (typeof document !== "undefined") { + for (const panel of document.querySelectorAll(`[${PENDING_MARKER}]`)) { + delete panel.dataset.loaded; + mountPendingSelector(); + } + } + } + + function selectedProvider(selection) { + return selection?.next?.provider ?? selection?.lastUsed?.provider ?? selection?.provider; + } + + function pendingCredentialOptions(status, mode) { + return mode === "token" + ? (Array.isArray(status.accounts) ? status.accounts : []).map((account) => ({ id: account.id, label: accountText(account) })) + : (Array.isArray(status.apiKeys) ? status.apiKeys : []).map((key) => ({ id: key.id, label: apiKeyText(key), disabled: key.configured === false })); + } + + function createPendingSelector() { + const panel = document.createElement("div"); + panel.setAttribute(PENDING_MARKER, ""); + panel.setAttribute("role", "group"); + panel.setAttribute("aria-label", "WorkBuddy 新会话凭证"); + Object.assign(panel.style, { + boxSizing: "border-box", + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + flexWrap: "wrap", + gap: "6px", + width: "100%", + maxWidth: "var(--dsh-composer-card-max-width, 720px)", + margin: "0 auto 8px", + padding: "0 8px", + color: "var(--dsw-text-tertiary, #98a2b3)", + fontSize: "12px", + lineHeight: "20px", + }); + const label = document.createElement("span"); + label.textContent = "新会话默认凭证"; + const mode = document.createElement("select"); + mode.setAttribute("aria-label", "新会话认证模式"); + const credential = document.createElement("select"); + credential.setAttribute("aria-label", "新会话 WorkBuddy 凭证"); + for (const element of [mode, credential]) Object.assign(element.style, { + boxSizing: "border-box", + minWidth: "0", + maxWidth: "min(240px, 100%)", + height: "28px", + padding: "0 6px", + border: "1px solid var(--dsw-border-subtle, #d0d5dd)", + borderRadius: "7px", + background: "var(--dsw-surface-subtle, transparent)", + color: "inherit", + font: "inherit", + fontSize: "12px", + }); + const hint = document.createElement("span"); + hint.textContent = "发送时自动绑定所选凭证"; + hint.style.color = "var(--dsw-text-tertiary, #98a2b3)"; + panel.append(label, mode, credential, hint); + panel._workbuddyMode = mode; + panel._workbuddyCredential = credential; + panel._workbuddyHint = hint; + const apply = async () => { + if (panel.dataset.busy === "1") return; + const value = credential.value; + if (!value || credential.selectedOptions[0]?.disabled) return; + panel.dataset.busy = "1"; + mode.disabled = true; + credential.disabled = true; + hint.textContent = "正在保存新会话默认凭证…"; + try { + const next = mode.value === "token" + ? await authRequest("token", { accountId: value }) + : await authRequest("api-key", { keyId: value }); + renderPendingSelector(panel, next); + notifyAuthState(); + } catch (error) { + hint.textContent = error instanceof Error ? error.message : "保存新会话凭证失败"; + hint.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + delete panel.dataset.busy; + mode.disabled = false; + credential.disabled = false; + } + }; + mode.addEventListener("change", () => { + renderPendingOptions(panel, panel._workbuddyStatus, mode.value); + void apply(); + }); + credential.addEventListener("change", () => void apply()); + return panel; + } + + function renderPendingOptions(panel, status, preferredMode) { + if (!status) return; + const tokenOptions = pendingCredentialOptions(status, "token"); + const apiOptions = pendingCredentialOptions(status, "api-key"); + const tokenAvailable = tokenOptions.length > 0; + const apiAvailable = apiOptions.some((option) => !option.disabled); + if (!status.routingEnabled || (!tokenAvailable && !apiAvailable)) { + panel.hidden = true; + panel.style.setProperty("display", "none", "important"); + return; + } + panel.hidden = false; + panel.style.setProperty("display", "flex", "important"); + const suggestedMode = status.suggestedBinding?.mode; + const modeAvailable = (value) => value === "token" ? tokenAvailable : value === "api-key" && apiAvailable; + const mode = modeAvailable(preferredMode) + ? preferredMode + : modeAvailable(suggestedMode) + ? suggestedMode + : status.mode === "token" && tokenAvailable ? "token" : "api-key"; + const modeSelect = panel._workbuddyMode; + const credentialSelect = panel._workbuddyCredential; + modeSelect.replaceChildren(); + for (const [value, label, disabled] of [["token", "令牌", !tokenAvailable], ["api-key", "API Key", !apiAvailable]]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + option.disabled = disabled; + modeSelect.append(option); + } + modeSelect.value = mode; + const options = mode === "token" ? tokenOptions : apiOptions; + credentialSelect.replaceChildren(...options.map((entry) => { + const option = document.createElement("option"); + option.value = entry.id; + option.textContent = entry.label; + option.disabled = entry.disabled === true; + return option; + })); + const activeId = mode === "token" + ? status.suggestedAccountId ?? status.activeAccountId + : status.suggestedApiKeyId ?? status.activeApiKeyId; + if (activeId && options.some((entry) => entry.id === activeId && !entry.disabled)) credentialSelect.value = activeId; + panel._workbuddyHint.textContent = "发送时自动绑定所选凭证"; + panel._workbuddyHint.style.color = "var(--dsw-text-tertiary, #98a2b3)"; + } + + function renderPendingSelector(panel, status) { + panel._workbuddyStatus = status; + renderPendingOptions(panel, status, panel._workbuddyMode.value); + } + + function mountPendingSelector() { + if (typeof document === "undefined") return; + // [自用定制 2026-10-10] 移除「新会话默认凭证」选择器:先摘掉已挂载的面板,再直接返回。 + for (const panel of document.querySelectorAll(`[${PENDING_MARKER}]`)) panel.remove(); + return; + const seats = Array.from(document.querySelectorAll("[data-composer-seat]")); + const seat = seats.find((candidate) => candidate.closest('[data-phase="hero"]')); + const panels = Array.from(document.querySelectorAll(`[${PENDING_MARKER}]`)); + if (!seat) { + for (const panel of panels) panel.remove(); + return; + } + for (const panel of panels) { + if (panel.parentElement !== seat) panel.remove(); + } + const panel = seat.querySelector(`[${PENDING_MARKER}]`) ?? createPendingSelector(); + if (!panel.parentElement) seat.append(panel); + if (panel.dataset.loading === "1" || panel.dataset.loaded === "1") return; + panel.dataset.loading = "1"; + authRequest("status") + .then((status) => { + panel.dataset.loaded = "1"; + renderPendingSelector(panel, status); + }) + .catch(() => { + panel.hidden = true; + panel.style.setProperty("display", "none", "important"); + }) + .finally(() => { + delete panel.dataset.loading; + }); + } + + function WorkBuddyCreditsDock({ useProjection, sessionId }) { + let selection; + try { + selection = typeof useProjection === "function" ? useProjection("modelSelection") : undefined; + } catch { + selection = undefined; + } + const provider = selectedProvider(selection); + const selected = isWorkBuddyProvider(provider); + const modlens = isModLensWorkBuddyProvider(provider); + const [state, setState] = useState(null); + + useEffect(() => { + let disposed = false; + let requestId = 0; + const load = async () => { + const currentRequestId = ++requestId; + if (!selected) { + setState(null); + return; + } + setState({ mode: "token", creditLoading: true, credits: undefined, todayUsage: null, creditError: null, todayUsageError: null }); + let status; + try { + status = await authRequest("status", undefined, sessionId); + } catch { + if (!disposed && currentRequestId === requestId) setState(null); + return; + } + if (disposed || currentRequestId !== requestId) return; + // [自用定制 2026-10-10] 原来在 API Key 模式下直接跳过积分查询;现在继续查。 + if (status.mode === "token" && !status.activeAccountId) { + setState({ ...status, creditLoading: false }); + return; + } + try { + const result = await authRequest("credits", { accountId: status.activeAccountId }, sessionId); + if (!disposed && currentRequestId === requestId) setState({ ...status, ...result, creditLoading: false }); + } catch (error) { + if (!disposed && currentRequestId === requestId) { + const message = error instanceof Error ? error.message : "查询 WorkBuddy 积分失败"; + setState({ + ...status, + credits: null, + creditLoading: false, + creditError: message, + todayUsage: null, + todayUsageError: "查询 WorkBuddy 今日请求量失败", + }); + } + } + }; + load(); + const onAuthState = () => load(); + window.addEventListener(AUTH_STATE_EVENT, onAuthState); + const timer = window.setInterval(load, 60_000); + return () => { + disposed = true; + window.removeEventListener(AUTH_STATE_EVENT, onAuthState); + window.clearInterval(timer); + }; + }, [provider, selected, sessionId]); + + // [自用定制 2026-10-10] A+ 方案:积分项自带悬浮卡片,不接管官方统计行。 + const [cardOpen, setCardOpen] = useState(false); + const cardRootRef = useRef(null); + useEffect(() => { + if (!cardOpen || typeof document === "undefined") return undefined; + const onDocPointerDown = (event) => { + if (cardRootRef.current && !cardRootRef.current.contains(event.target)) setCardOpen(false); + }; + const onKeyDown = (event) => { + if (event.key === "Escape") setCardOpen(false); + }; + document.addEventListener("pointerdown", onDocPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onDocPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [cardOpen]); + + const hasTokenAccount = state?.mode === "token" && Boolean(state.activeAccountId); + // [自用定制 2026-10-10] 放宽门槛:API Key 模式下同样渲染积分行。 + const hasApiKey = state?.mode !== "token" && Boolean(state?.activeApiKeyId || state?.apiKeyConfigured); + if (!selected || !state || (!state.routingEnabled && !hasTokenAccount && !hasApiKey) || (!sessionId && !hasTokenAccount && !hasApiKey)) return null; + const creditsText = state.creditLoading + ? "剩余积分:读取中…" + : state.unlimited + ? "剩余积分:不限量" + : typeof state.credits === "number" && Number.isFinite(state.credits) + ? `剩余积分:${formatCredits(state.credits)}` + : state.creditError + ? "剩余积分:暂不可用" + : "剩余积分:—"; + const today = state.todayUsage; + const usageText = today?.synced === true + ? `今日请求:${Number.isFinite(Number(today.count)) ? Number(today.count) : 0} 次 · 用量 ${formatCredits(today.used)} 积分` + : state.creditLoading + ? "今日请求:读取中…" + : state.todayUsageError + ? "今日请求:暂不可用" + : "今日请求:—"; + const modlensHint = modlens ? createElement("span", { + "data-workbuddy-modlens-hint": true, + title: "ModLens 会先处理会话中的历史图片,首次响应可能较慢;若超时、无响应或返回 429,请检查视觉引擎登录、网络和额度。纯文本任务可切换 WorkBuddy 直连。", + style: { + flex: "1 1 100%", + minWidth: 0, + color: "var(--dsw-text-tertiary, #98a2b3)", + fontSize: "11px", + lineHeight: "16px", + textAlign: "right", + }, + }, "ModLens:历史图片会先处理,响应较慢或出现 429 时请检查视觉引擎与额度;纯文本可切换 WorkBuddy 直连") : null; + const activeAccount = Array.isArray(state.accounts) ? state.accounts.find((account) => account.id === state.activeAccountId) : undefined; + const credentialOptions = state.mode === "token" + ? (Array.isArray(state.accounts) ? state.accounts.map((account) => createElement("option", { key: account.id, value: account.id }, accountText(account))) : []) + : (Array.isArray(state.apiKeys) ? state.apiKeys.map((key) => createElement("option", { key: key.id, value: key.id, disabled: key.configured === false }, apiKeyText(key))) : []); + const confirmBindingChange = (message) => typeof window === "undefined" || window.confirm(message); + const applyBindingResult = async (result) => { + setState(result); + if (result.mode === "token" && result.activeAccountId) { + try { + const credits = await authRequest("credits", { accountId: result.activeAccountId }, sessionId); + setState({ ...result, ...credits, creditLoading: false }); + } catch (error) { + setState({ + ...result, + credits: null, + creditLoading: false, + creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败", + todayUsage: null, + todayUsageError: "查询 WorkBuddy 今日请求量失败", + }); + } + } + }; + const bindCredential = async (mode, value, failureMessage) => { + try { + const result = mode === "token" + ? await authRequest("token", { accountId: value }, sessionId) + : await authRequest("api-key", { keyId: value }, sessionId); + await applyBindingResult(result); + } catch (error) { + setState({ ...state, creditError: error instanceof Error ? error.message : failureMessage }); + } + }; + const onSessionCredentialChange = async (event) => { + if (!state.routingEnabled || !sessionId) return; + const value = event.target.value; + if (!confirmBindingChange("切换后,当前已发出的请求继续使用原凭证;后续模型调用将使用新凭证。确定切换吗?")) { + event.target.value = state.mode === "token" ? state.activeAccountId ?? "" : state.activeApiKeyId ?? ""; + return; + } + await bindCredential(state.mode, value, "切换会话凭证失败"); + }; + const onSessionModeChange = async (event) => { + if (!state.routingEnabled || !sessionId) return; + const mode = event.target.value; + const value = mode === "token" ? state.accounts?.[0]?.id : state.apiKeys?.find((key) => key.configured)?.id; + if (!value) return; + if (!confirmBindingChange("切换后,当前已发出的请求继续使用原凭证;后续模型调用将使用新凭证。确定切换认证模式吗?")) { + event.target.value = state.mode; + return; + } + await bindCredential(mode, value, "切换会话认证模式失败"); + }; + const onUnbind = async () => { + if (!state.routingEnabled || !sessionId || !state.sessionBinding) return; + if (!confirmBindingChange("解除后,当前会话将在下一次模型调用时绑定当前默认凭证。确定解除吗?")) return; + try { + await applyBindingResult(await authRequest("unbind", {}, sessionId)); + } catch (error) { + setState({ ...state, creditError: error instanceof Error ? error.message : "解除会话凭证失败" }); + } + }; + const actionStyle = { border: "0", padding: "0 2px", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", cursor: "pointer", textDecoration: "underline" }; + const sessionControls = state.routingEnabled && sessionId ? createElement( + "span", + { "data-workbuddy-session-controls": true, style: { display: "inline-flex", alignItems: "center", justifyContent: "flex-end", flex: "1 1 100%", flexWrap: "wrap", gap: "4px", minWidth: 0, maxWidth: "100%" } }, + createElement("span", { title: state.sessionBinding ? "当前会话已使用独立凭证" : "当前会话未绑定,下次发送将固化当前默认凭证" }, state.sessionBinding ? "已绑定" : "未绑定"), + createElement("select", { value: state.mode, onChange: onSessionModeChange, "aria-label": "当前会话认证模式", style: { border: "0", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", minWidth: 0, maxWidth: "110px" } }, + createElement("option", { value: "token" }, "令牌"), + createElement("option", { value: "api-key" }, "API Key"), + ), + createElement("select", { value: state.mode === "token" ? state.activeAccountId ?? "" : state.activeApiKeyId ?? "", onChange: onSessionCredentialChange, "aria-label": "当前会话凭证", style: { border: "0", background: "transparent", color: "inherit", font: "inherit", fontSize: "12px", minWidth: 0, maxWidth: "190px" } }, credentialOptions), + state.sessionBinding ? createElement("button", { type: "button", onClick: onUnbind, title: "解除当前绑定,下次发送时重新绑定默认凭证", style: actionStyle }, "解除绑定") : null, + createElement("span", { "aria-hidden": true, style: { opacity: 0.55, padding: "0 2px" } }, "·"), + ) : null; + // [自用定制 2026-10-10] A+ 方案:药丸式积分项 + 点击弹出信息卡片;官方统计行保持原样。 + const creditValue = state.creditLoading + ? "读取中…" + : state.unlimited + ? "不限量" + : typeof state.credits === "number" && Number.isFinite(state.credits) + ? formatCredits(state.credits) + : state.creditError ? "暂不可用" : "—"; + const formatStamp = (value) => { + const matched = String(value ?? "").match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/); + return matched ? `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}` : String(value ?? ""); + }; + const cardRows = [ + ["剩余积分", creditValue], + state.planName ? ["套餐", state.planName] : null, + state.planCycleEnd ? ["下次重置", formatStamp(state.planCycleEnd)] : null, + ].filter(Boolean); + return createElement( + "div", + { + ref: cardRootRef, + className: "dsh-workbuddy-credits", + "data-workbuddy-credits": true, + "data-workbuddy-session-aware": state.routingEnabled ? true : undefined, + role: "status", + "aria-live": "polite", + style: { + boxSizing: "border-box", + position: "relative", + minWidth: 0, + width: "100%", + maxWidth: "100%", + minHeight: "20px", + padding: "0", + display: "inline-flex", + flexWrap: "wrap", + gap: "2px 8px", + flex: "0 1 auto", + justifyContent: "flex-end", + alignItems: "center", + color: "var(--dsw-text-tertiary, #98a2b3)", + fontSize: "12px", + lineHeight: "18px", + fontVariantNumeric: "tabular-nums", + whiteSpace: "normal", + overflow: "visible", + textOverflow: "clip", + }, + }, + createElement( + "button", + { + type: "button", + "data-workbuddy-credit-pill": true, + "aria-expanded": cardOpen ? "true" : "false", + title: [state.creditError, state.todayUsageError].filter(Boolean).join(";") || "点击查看 WorkBuddy 用量", + onClick: (event) => { + event.stopPropagation(); + setCardOpen((open) => !open); + }, + style: { + border: "0", + margin: "0", + padding: "1px 4px", + background: "transparent", + color: "inherit", + font: "inherit", + fontSize: "12px", + lineHeight: "18px", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", + cursor: "pointer", + borderRadius: "4px", + }, + }, + `剩余积分 ${creditValue}`, + ), + cardOpen && cardRows.length > 0 + ? createElement( + "div", + { + "data-workbuddy-credit-card": true, + role: "dialog", + style: { + position: "absolute", + right: "0", + bottom: "calc(100% + 8px)", + zIndex: 80, + minWidth: "216px", + padding: "10px 12px", + borderRadius: "10px", + background: "var(--dsw-surface-primary, #ffffff)", + border: "1px solid var(--dsw-border-tertiary, rgba(0,0,0,.12))", + boxShadow: "0 10px 28px rgba(0,0,0,.16)", + color: "var(--dsw-text-primary, #1f2329)", + fontSize: "12px", + lineHeight: "20px", + textAlign: "left", + whiteSpace: "normal", + }, + }, + createElement("div", { style: { fontWeight: 600, marginBottom: "6px" } }, "WorkBuddy"), + ...cardRows.map(([label, value]) => createElement( + "div", + { style: { display: "flex", justifyContent: "space-between", gap: "16px" } }, + createElement("span", { style: { color: "var(--dsw-text-tertiary, #98a2b3)" } }, label), + createElement("span", { style: { fontVariantNumeric: "tabular-nums" } }, value), + )), + ) + : null, + modlensHint, + ); + } + + function installComposerDockLayout() { + if (typeof document === "undefined" || document.querySelector('style[data-plugin-css="dsh-llm-workbuddy-composer-dock"]')) return; + const style = document.createElement("style"); + style.dataset.plugin = "@axiaohungry/dsh-llm-workbuddy"; + style.dataset.pluginCss = "dsh-llm-workbuddy-composer-dock"; + style.textContent = ` +[data-slot="conversation.composer.dock"]:has(> [data-composer-stats]), +[data-slot="conversation.composer.dock"]:has(> [data-workbuddy-credits]) { + box-sizing: border-box; + width: 100%; + min-width: 0; + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 0 4px 4px; + overflow: visible; +} +[data-slot="conversation.composer.dock"] > [data-composer-stats] { + grid-column: 2; + width: auto !important; + max-width: 100%; + min-width: 0; + margin: 0 !important; +} +[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] { + grid-column: 3; + justify-self: end; + width: 100% !important; + max-width: 100%; + min-width: 0; + margin: 0 !important; +} +[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] > span { + min-width: 0; +} +[data-workbuddy-credit-pill] { + transition: background-color 0.12s ease; +} +[data-workbuddy-credit-pill]:hover, +[data-workbuddy-credit-pill]:focus-visible { + background: var(--dsw-surface-subtle, rgba(127, 127, 127, 0.14)) !important; +} +[data-workbuddy-credit-card] { + pointer-events: auto; +} +[data-slot="conversation.composer.dock"] > [data-workbuddy-credits] > [data-workbuddy-session-controls] { + flex-basis: 100%; + width: 100%; +} +@media (max-width: 760px) { + [data-slot="conversation.composer.dock"]:has(> [data-composer-stats]), + [data-slot="conversation.composer.dock"]:has(> [data-workbuddy-credits]) { + display: flex !important; + flex-wrap: wrap; + justify-content: center; + } + [data-slot="conversation.composer.dock"] > [data-composer-stats], + [data-slot="conversation.composer.dock"] > [data-workbuddy-credits] { + flex: 0 1 auto; + } +} +`; + document.head.appendChild(style); + } + + function applyCreditStatus(stats, status, activeAccount) { + const visible = status.mode === "token" && Boolean(activeAccount); + setVisible(stats, visible); + if (!visible) return; + const credit = stats.querySelector('[data-workbuddy-stat="credits"]'); + const usage = stats.querySelector('[data-workbuddy-stat="usage"]'); + credit.title = status.creditError || ""; + usage.title = status.todayUsageError || ""; + if (status.creditLoading) credit.textContent = "剩余积分:读取中…"; + else if (status.unlimited) credit.textContent = "剩余积分:不限量"; + else if (typeof status.credits === "number" && Number.isFinite(status.credits)) credit.textContent = `剩余积分:${formatCredits(status.credits)}`; + else credit.textContent = status.creditError ? "剩余积分:暂不可用" : "剩余积分:—"; + const today = status.todayUsage; + if (today && today.synced === true) { + usage.textContent = `今日请求:${Number.isFinite(Number(today.count)) ? Number(today.count) : 0} 次 · 用量 ${formatCredits(today.used)} 积分`; + } else { + usage.textContent = status.creditLoading ? "今日请求:读取中…" : status.todayUsageError ? "今日请求:暂不可用" : "今日请求:—"; + } + } + + function applyMode(input, keyButton, tokenButton, keySection, keySourceRow, keySelect, keyHint, newKeyInput, newKeyLabelInput, saveKeyButton, removeKeyButton, tokenSection, accountRow, accountSelect, accountName, addButton, removeButton, accountStats, routingRow, routingToggle, message, status) { + const token = status.mode === "token"; + const apiKeys = Array.isArray(status.apiKeys) ? status.apiKeys : []; + const activeApiKeyId = status.activeApiKeyId ?? apiKeys[0]?.id; + const activeApiKey = apiKeys.find((key) => key.id === activeApiKeyId); + const accounts = Array.isArray(status.accounts) ? status.accounts : []; + const activeAccountId = status.activeAccountId ?? accounts[0]?.id; + input.disabled = token; + input.placeholder = token ? "当前使用 WorkBuddy 账号令牌" : "输入新的 API Key(保存到 DSH)"; + newKeyInput.disabled = token; + newKeyLabelInput.disabled = token; + keyButton.setAttribute("aria-pressed", String(!token)); + tokenButton.setAttribute("aria-pressed", String(token)); + keyButton.style.background = !token ? "var(--dsw-accent-subtle, #eef4ff)" : "var(--dsw-surface-subtle, transparent)"; + tokenButton.style.background = token ? "var(--dsw-accent-subtle, #eef4ff)" : "var(--dsw-surface-subtle, transparent)"; + setVisible(keySection, !token); + // [自用定制 2026-10-10] 自用只保留一个 Key:隐藏「当前 API Key」多 Key 来源下拉 + setVisible(keySourceRow, false); + keySelect.replaceChildren(...apiKeys.map((key) => { + const option = document.createElement("option"); + option.value = key.id; + option.textContent = apiKeyText(key); + option.title = apiKeyLabel(key); + return option; + })); + if (activeApiKeyId) keySelect.value = activeApiKeyId; + // [自用定制 2026-10-10] 不回显正在使用的 API Key + keyHint.textContent = token ? "" : (activeApiKey ? "" : "未检测到可用 API Key,可输入 Key 保存"); + keyHint.title = ""; + saveKeyButton.textContent = "添加并使用"; + removeKeyButton.hidden = !activeApiKey || activeApiKey.kind !== "dsh"; + setVisible(tokenSection, token); + tokenButton.textContent = token ? "令牌模式" : "令牌登录"; + addButton.textContent = accounts.length ? "添加账号" : "登录 WorkBuddy"; + setVisible(accountRow, token && accounts.length > 0); + accountSelect.replaceChildren(...accounts.map((account) => { + const option = document.createElement("option"); + option.value = account.id; + option.textContent = accountText(account); + option.title = accountLabel(account); + return option; + })); + if (activeAccountId) accountSelect.value = activeAccountId; + const activeAccount = accounts.find((account) => account.id === activeAccountId); + accountName.textContent = activeAccount ? `当前账号:${accountText(activeAccount)}` : ""; + accountName.title = activeAccount ? accountLabel(activeAccount) : ""; + removeButton.hidden = !token || !activeAccount; + setVisible(accountStats, token && Boolean(activeAccount)); + applyCreditStatus(accountStats, status, activeAccount); + routingToggle.checked = status.routingEnabled === true; + routingToggle.disabled = false; + routingRow.title = status.routingEnabled ? "已启用:每个会话可单独选择 WorkBuddy 账号或 API Key" : "关闭时使用原有的全局账号或 API Key"; + message.textContent = token + ? status.authenticated ? `令牌已登录:${activeAccount ? accountText(activeAccount) : "当前账号"}` : "令牌缺失,请重新登录" + : status.apiKeyConfigured ? `API Key 已就绪:${activeApiKey ? apiKeyText(activeApiKey) : "当前来源"}` : "未配置 API Key,请输入后保存"; + message.style.color = token + ? status.authenticated ? "var(--dsw-text-success, #2e7d32)" : "var(--dsw-text-danger, #c62828)" + : status.apiKeyConfigured ? "var(--dsw-text-success, #2e7d32)" : "var(--dsw-text-danger, #c62828)"; + } + + async function request(path, body) { + const options = { method: "POST" }; + if (body !== undefined) { + options.headers = { "content-type": "application/json" }; + options.body = JSON.stringify(body); + } + const response = await fetch(`${ROUTE}/${path}`, options); + const result = await response.json(); + if (!response.ok || !result.ok) { + const error = new Error(result.message || `请求失败(${response.status})`); + error.status = response.status; + throw error; + } + return result; + } + + function mount(input) { + const field = input.parentElement; + if (!field || field.querySelector(`[${MARKER}]`)) return; + field.setAttribute("data-workbuddy-auth-field", ""); + input.dataset.workbuddyPlaceholder = input.placeholder; + for (const nativeNode of field.children) nativeNode.hidden = true; + Object.assign(field.style, { display: "block", width: "100%", boxSizing: "border-box" }); + const controls = document.createElement("div"); + controls.setAttribute(MARKER, ""); + controls.setAttribute("role", "group"); + controls.setAttribute("aria-label", "WorkBuddy 认证方式"); + Object.assign(controls.style, { + display: "flex", + flexDirection: "column", + alignItems: "stretch", + gap: "8px", + width: "100%", + maxWidth: "100%", + paddingTop: "4px", + boxSizing: "border-box", + color: "var(--dsw-text-primary, inherit)", + }); + const modeRow = row(); + const keySection = section(); + const keySourceRow = row(); + const keyAddRow = row(); + const tokenSection = section(); + const accountRow = row(); + const tokenActionRow = row(); + const keyButton = button("API Key"); + const tokenButton = button("令牌登录"); + const keySelect = accountPicker(); + keySelect.setAttribute("aria-label", "WorkBuddy API Key 来源"); + const keySourceLabel = fieldLabel("当前 API Key"); + const newKeyLabel = fieldLabel("新增 API Key"); + const newKeyInput = textInput("password", "粘贴新的 API Key", "新增 WorkBuddy API Key"); + const newKeyLabelInput = textInput("text", "名称(可选)", "API Key 名称"); + const keyHint = document.createElement("span"); + Object.assign(keyHint.style, { + display: "block", + width: "100%", + minWidth: "0", + overflowWrap: "anywhere", + whiteSpace: "normal", + fontSize: "12px", + lineHeight: "18px", + color: "var(--dsw-text-secondary, #667085)", + }); + const saveKeyButton = button("添加并使用"); + const removeKeyButton = button("删除"); + const accountSelect = accountPicker(); + const accountName = document.createElement("span"); + Object.assign(accountName.style, { + flex: "1 1 220px", + minWidth: "0", + overflowWrap: "anywhere", + whiteSpace: "normal", + fontSize: "13px", + color: "var(--dsw-text-secondary, #667085)", + }); + const addButton = button("令牌登录"); + const removeButton = button("删除账号"); + const accountLabel = fieldLabel("令牌账号"); + const accountStats = document.createElement("div"); + accountStats.setAttribute("role", "status"); + accountStats.setAttribute("aria-live", "polite"); + Object.assign(accountStats.style, { + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", + width: "100%", + minHeight: "24px", + fontSize: "13px", + color: "var(--dsw-text-secondary, #667085)", + }); + const tokenHint = document.createElement("span"); + tokenHint.textContent = "令牌登录后可切换账号,并查看积分与今日请求量。"; + Object.assign(tokenHint.style, { + display: "block", + width: "100%", + fontSize: "12px", + lineHeight: "18px", + color: "var(--dsw-text-secondary, #667085)", + }); + const creditStat = document.createElement("span"); + creditStat.dataset.workbuddyStat = "credits"; + const usageStat = document.createElement("span"); + usageStat.dataset.workbuddyStat = "usage"; + accountStats.append(creditStat, usageStat); + const routingRow = row(); + const routingToggle = document.createElement("input"); + routingToggle.type = "checkbox"; + routingToggle.setAttribute("aria-label", "启用会话级账号/API Key"); + routingToggle.style.accentColor = "var(--dsw-accent, #2563eb)"; + const routingLabel = document.createElement("label"); + routingLabel.textContent = "会话级账号/API Key"; + routingLabel.style.fontSize = "13px"; + routingLabel.style.color = "var(--dsw-text-secondary, #667085)"; + const routingHint = document.createElement("span"); + routingHint.textContent = "关闭时保持原有全局认证方式"; + routingHint.style.fontSize = "12px"; + routingHint.style.color = "var(--dsw-text-tertiary, #98a2b3)"; + routingRow.append(routingToggle, routingLabel, routingHint); + const message = document.createElement("span"); + message.setAttribute("role", "status"); + message.setAttribute("aria-live", "polite"); + Object.assign(message.style, { fontSize: "12px", minHeight: "18px", lineHeight: "18px" }); + modeRow.append(keyButton, tokenButton); + keySourceRow.append(keySourceLabel, keySelect, removeKeyButton); + keyAddRow.append(newKeyLabel, newKeyInput, newKeyLabelInput, saveKeyButton); + keySection.append(keySourceRow, keyAddRow, keyHint); + accountRow.append(accountLabel, accountSelect, accountName, removeButton); + tokenActionRow.append(addButton); + tokenSection.append(tokenHint, accountRow, accountStats, tokenActionRow); + controls.append(modeRow, routingRow, keySection, tokenSection, message); + field.append(controls); + let current = { mode: "api-key", authenticated: false, accounts: [], apiKeys: [], credits: undefined, creditLoading: false, creditError: null, todayUsage: null, todayUsageError: null }; + const render = (status) => { + const previousMode = current.mode; + const previousAccountId = current.activeAccountId; + const previousApiKeyId = current.activeApiKeyId; + const previousAccounts = current.accounts; + const previousApiKeys = current.apiKeys; + const previousRouting = current.routingEnabled; + current = { ...current, ...status }; + applyMode(input, keyButton, tokenButton, keySection, keySourceRow, keySelect, keyHint, newKeyInput, newKeyLabelInput, saveKeyButton, removeKeyButton, tokenSection, accountRow, accountSelect, accountName, addButton, removeButton, accountStats, routingRow, routingToggle, message, current); + if (previousMode !== current.mode || previousAccountId !== current.activeAccountId || previousApiKeyId !== current.activeApiKeyId || previousAccounts !== current.accounts || previousApiKeys !== current.apiKeys || previousRouting !== current.routingEnabled) notifyAuthState(); + }; + render(current); + let creditRequestId = 0; + const loadCredits = async (accountId) => { + const requestId = ++creditRequestId; + if (current.mode !== "token" || !accountId) { + render({ credits: undefined, creditLoading: false, creditError: null, todayUsage: null, todayUsageError: null }); + return; + } + render({ credits: undefined, creditLoading: true, creditError: null, todayUsage: null, todayUsageError: null }); + try { + const result = await request("credits", { accountId }); + if (requestId !== creditRequestId || current.activeAccountId !== accountId) return; + render({ ...result, creditLoading: false }); + } catch (error) { + if (requestId !== creditRequestId || current.activeAccountId !== accountId) return; + render({ credits: null, creditLoading: false, creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败", todayUsage: null, todayUsageError: "查询 WorkBuddy 今日请求量失败" }); + } + }; + + const setBusy = (busy) => { + keyButton.disabled = busy; + tokenButton.disabled = busy; + keySelect.disabled = busy; + newKeyInput.disabled = busy || current.mode === "token"; + newKeyLabelInput.disabled = busy || current.mode === "token"; + saveKeyButton.disabled = busy; + removeKeyButton.disabled = busy; + accountSelect.disabled = busy; + addButton.disabled = busy; + removeButton.disabled = busy; + routingToggle.disabled = busy; + keyButton.style.cursor = busy ? "progress" : "pointer"; + tokenButton.style.cursor = busy ? "progress" : "pointer"; + saveKeyButton.style.cursor = busy ? "progress" : "pointer"; + removeKeyButton.style.cursor = busy ? "progress" : "pointer"; + addButton.style.cursor = busy ? "progress" : "pointer"; + removeButton.style.cursor = busy ? "progress" : "pointer"; + }; + keyButton.addEventListener("click", async () => { + setBusy(true); + message.textContent = "正在切换…"; + try { + render(await request("api-key", current.activeApiKeyId ? { keyId: current.activeApiKeyId } : undefined)); + newKeyInput.focus(); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "切换失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + keySelect.addEventListener("change", async () => { + setBusy(true); + message.textContent = "正在切换 API Key…"; + try { + render(await request("api-key", { keyId: keySelect.value })); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "切换失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + saveKeyButton.addEventListener("click", async () => { + const value = newKeyInput.value.trim(); + if (!value) { + message.textContent = "请输入新的 API Key"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + newKeyInput.focus(); + return; + } + setBusy(true); + saveKeyButton.textContent = "添加中…"; + message.textContent = "正在保存 API Key…"; + try { + const label = newKeyLabelInput.value.trim(); + const next = await request("api-key/add", { key: value, ...(label ? { label } : {}) }); + newKeyInput.value = ""; + newKeyLabelInput.value = ""; + render(next); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "保存失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + newKeyInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !saveKeyButton.disabled) { + event.preventDefault(); + saveKeyButton.click(); + } + }); + removeKeyButton.addEventListener("click", async () => { + if (!current.activeApiKeyId || !window.confirm("确定删除当前 DSH 保存的 API Key 吗?")) return; + setBusy(true); + message.textContent = "正在删除 API Key…"; + try { + render(await request("api-key/remove", { keyId: current.activeApiKeyId })); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "删除失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + tokenButton.addEventListener("click", async () => { + setBusy(true); + tokenButton.textContent = "切换中…"; + message.textContent = current.accounts?.length ? "正在切换令牌账号…" : "请在浏览器中完成 WorkBuddy 中国站登录"; + try { + const next = await request(current.accounts?.length ? "token" : "login"); + render(next); + await loadCredits(next.activeAccountId); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "登录失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + addButton.addEventListener("click", async () => { + setBusy(true); + addButton.textContent = "等待浏览器登录…"; + message.textContent = "请在浏览器中完成 WorkBuddy 中国站登录"; + try { + const next = await request("login"); + render(next); + await loadCredits(next.activeAccountId); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "登录失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + accountSelect.addEventListener("change", async () => { + setBusy(true); + message.textContent = "正在切换令牌账号…"; + try { + const next = await request("token", { accountId: accountSelect.value }); + render(next); + await loadCredits(next.activeAccountId); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "切换失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + removeButton.addEventListener("click", async () => { + if (!current.activeAccountId || !window.confirm("确定删除这个 WorkBuddy 登录账号吗?令牌将从 DSH 凭据中移除。")) return; + setBusy(true); + message.textContent = "正在删除账号…"; + try { + const next = await request("remove", { accountId: current.activeAccountId }); + render(next); + await loadCredits(next.activeAccountId); + } catch (error) { + message.textContent = error instanceof Error ? error.message : "删除失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + routingToggle.addEventListener("change", async () => { + setBusy(true); + message.textContent = routingToggle.checked ? "正在启用会话级认证…" : "正在关闭会话级认证…"; + try { + render(await authRequest("routing", { enabled: routingToggle.checked })); + } catch (error) { + routingToggle.checked = !routingToggle.checked; + message.textContent = error instanceof Error ? error.message : "切换会话级认证失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + } finally { + setBusy(false); + } + }); + fetch(`${ROUTE}/status`, { cache: "no-store" }) + .then((response) => response.json()) + .then((status) => { + render(status); + return loadCredits(status.activeAccountId); + }) + .catch(() => { + message.textContent = "认证状态读取失败"; + message.style.color = "var(--dsw-text-danger, #c62828)"; + }); + } + + function mountModelEditor(host) { + const heading = document.createElement("strong"); + heading.textContent = "模型目录"; + const actions = row(); + const fetchButton = button("获取可用模型"); + const addButton = button("添加模型"); + const resetButton = button("恢复在线目录"); + const saveButton = button("保存模型"); + const list = document.createElement("div"); + const candidates = document.createElement("div"); + const message = document.createElement("span"); + message.setAttribute("role", "status"); + message.setAttribute("aria-live", "polite"); + Object.assign(host.style, { display: "flex", flexDirection: "column", gap: "10px", width: "100%" }); + Object.assign(list.style, { display: "flex", flexDirection: "column", gap: "8px" }); + Object.assign(message.style, { fontSize: "12px", minHeight: "18px" }); + actions.append(fetchButton, addButton, resetButton, saveButton); + host.append(heading, actions, list, candidates, message); + let models = null; + let revision; + let busy = false; + const invalidReasoning = new Set(); + const say = (value, error = false) => { + message.textContent = value; + message.style.color = error ? "var(--dsw-text-danger, #c62828)" : "var(--dsw-text-secondary, #667085)"; + }; + const setBusy = (value) => { + busy = value; + for (const control of [fetchButton, addButton, resetButton, saveButton]) control.disabled = value; + }; + const makeInput = (value, label, onChange, type = "text") => { + const input = textInput(type, label, label); + input.value = value ?? ""; + input.title = label; + input.addEventListener("input", () => onChange(input.value)); + return input; + }; + const render = () => { + list.replaceChildren(); + if (models === null) { + const hint = document.createElement("span"); + hint.textContent = "使用当前账号的在线模型目录;获取后可选择模型进行自定义。"; + list.append(hint); + return; + } + for (const [index, model] of models.entries()) { + const item = section(); + const fields = row(); + const id = makeInput(model.id, "模型 ID", (value) => { model.id = value.trim(); }); + const name = makeInput(model.name, "显示名称", (value) => { model.name = value; }); + const context = makeInput(model.contextWindow, "上下文窗口", (value) => { + if (value) model.contextWindow = Number(value); + else delete model.contextWindow; + }, "number"); + const output = makeInput(model.maxTokens, "最大输出 Token", (value) => { + if (value) model.maxTokens = Number(value); + else delete model.maxTokens; + }, "number"); + for (const input of [id, name, context, output]) { + input.style.flex = "1 1 140px"; + fields.append(input); + } + const imageLabel = document.createElement("label"); + const images = document.createElement("input"); + images.type = "checkbox"; + images.checked = model.input?.includes("image") === true; + images.addEventListener("change", () => { model.input = images.checked ? ["text", "image"] : ["text"]; }); + imageLabel.append(images, " 图片输入"); + const remove = button("删除"); + remove.addEventListener("click", () => { invalidReasoning.delete(model); models.splice(index, 1); render(); }); + fields.append(imageLabel, remove); + item.append(fields); + if (model.reasoningEfforts !== undefined) { + const thinking = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "模型思考档位"; + const editor = document.createElement("textarea"); + editor.setAttribute("aria-label", `${model.id} 思考档位 JSON`); + editor.value = JSON.stringify(model.reasoningEfforts); + editor.style.width = "100%"; + editor.addEventListener("change", () => { + try { + const value = JSON.parse(editor.value); + if (value !== false && (!value || typeof value !== "object" || Array.isArray(value))) throw new Error(); + model.reasoningEfforts = value; + invalidReasoning.delete(model); + say(""); + } catch { invalidReasoning.add(model); say("思考档位 JSON 格式无效", true); } + }); + thinking.append(summary, editor); + item.append(thinking); + } + list.append(item); + } + }; + fetch(`${ROUTE}/models`, { cache: "no-store" }) + .then((response) => response.json()) + .then((state) => { + if (!state.ok) throw new Error(state.message); + models = state.models?.map((model) => ({ ...model })) ?? null; + revision = state.revision; + render(); + }) + .catch((error) => say(error instanceof Error ? error.message : "读取模型失败", true)); + addButton.addEventListener("click", () => { + models ??= []; + models.push({ id: "", name: "" }); + render(); + list.lastElementChild?.querySelector('input[aria-label="模型 ID"]')?.focus(); + }); + fetchButton.addEventListener("click", async () => { + if (busy) return; + setBusy(true); + say("正在获取模型…"); + try { + const result = await request("models", { action: "discover" }); + candidates.replaceChildren(); + const choices = result.models.filter((model) => !models?.some((existing) => existing.id === model.id)); + if (!choices.length) return say("没有新的可用模型"); + const selected = new Set(); + for (const model of choices) { + const label = document.createElement("label"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.addEventListener("change", () => checkbox.checked ? selected.add(model.id) : selected.delete(model.id)); + label.append(checkbox, ` ${model.name || model.id} (${model.id})`); + Object.assign(label.style, { display: "block", fontSize: "13px" }); + candidates.append(label); + } + const addSelected = button("添加选中模型"); + addSelected.addEventListener("click", () => { + models ??= []; + models.push(...choices.filter((model) => selected.has(model.id)).map((model) => ({ ...model }))); + candidates.replaceChildren(); + render(); + }); + candidates.append(addSelected); + say(`获取到 ${result.models.length} 个模型,请选择后保存`); + } catch (error) { say(error instanceof Error ? error.message : "获取模型失败", true); } + finally { setBusy(false); } + }); + saveButton.addEventListener("click", async () => { + if (busy || models === null) return; + if (invalidReasoning.size) return say("请先修正模型思考档位 JSON", true); + if (models.some((model) => !model.id || !Number.isSafeInteger(model.contextWindow ?? 1) + || !Number.isSafeInteger(model.maxTokens ?? 1))) return say("请检查模型 ID 和 Token 数值", true); + setBusy(true); + try { + const result = await request("models", { action: "save", models, revision }); + revision = result.revision; + say("模型已保存"); + } catch (error) { say(error instanceof Error ? error.message : "保存模型失败", true); } + finally { setBusy(false); } + }); + resetButton.addEventListener("click", async () => { + if (busy || !window.confirm("恢复在线模型目录?自定义模型列表会被移除。")) return; + setBusy(true); + try { + const result = await request("models", { action: "reset", revision }); + models = null; + revision = result.revision; + candidates.replaceChildren(); + render(); + say("已恢复在线模型目录"); + } catch (error) { say(error instanceof Error ? error.message : "恢复失败", true); } + finally { setBusy(false); } + }); + render(); + } + + function WorkBuddySettingsCard({ provider, embedded = false }) { + const root = useRef(null); + useEffect(() => { + if (provider?.provider !== "workbuddy-cn" || !root.current) return; + const host = root.current; + const field = section(); + const input = document.createElement("input"); + input.setAttribute("aria-label", "API 密钥"); + field.append(input); + host.append(field); + mount(input); + const models = section(); + host.append(models); + mountModelEditor(models); + const scope = embedded ? null : host.closest("li") ?? host.closest('[role="tabpanel"]') ?? host.closest('[class*="_addPanel"]'); + const hidden = new Map(); + const hideNative = () => { + if (!scope) return; + for (const node of scope.querySelectorAll('[class*="_editor"], [class*="_rowActions"]')) { + if (node.contains(host) || host.contains(node) || hidden.has(node)) continue; + hidden.set(node, { + hidden: node.hidden, + display: node.style.getPropertyValue("display"), + priority: node.style.getPropertyPriority("display"), + }); + node.hidden = true; + node.style.setProperty("display", "none", "important"); + } + }; + const observer = scope ? new MutationObserver(hideNative) : undefined; + observer?.observe(scope, { childList: true, subtree: true }); + hideNative(); + return () => { + observer?.disconnect(); + for (const [node, previous] of hidden) { + node.hidden = previous.hidden; + if (previous.display) node.style.setProperty("display", previous.display, previous.priority); + else node.style.removeProperty("display"); + } + }; + }, [provider?.provider]); + return provider?.provider === "workbuddy-cn" + ? createElement("div", { ref: root, "data-workbuddy-settings-card": "" }) + : null; + } + + function WorkBuddyPluginDetails({ subject }) { + return subject?.kind === "bundle" && subject.pkg?.name === "@axiaohungry/dsh-llm-workbuddy" + ? createElement(WorkBuddySettingsCard, { provider: { provider: "workbuddy-cn" }, embedded: true }) + : null; + } + + function enhance() { + for (const input of document.querySelectorAll('input[aria-label="API 密钥"]')) { + if (isWorkBuddy(input)) mount(input); + } + mountPendingSelector(); + } + + function apply(ctx) { + installComposerDockLayout(); + ctx.slots.inject("settings.models.provider-card", () => ctx.slots.register({ + name: "settings.models.provider-card", + key: "llm-workbuddy", + }, WorkBuddySettingsCard)); + // [自用定制 2026-10-10] 按用户要求移除「插件详情页」里的内置面板。 + // 需要还原:见同目录 client.js.bak-20261010 + // ctx.slots.inject("plugins.detail.section", () => ctx.slots.register({ + // name: "plugins.detail.section", + // id: "llm-workbuddy", + // }, WorkBuddyPluginDetails)); + ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({ + name: "conversation.composer.dock", + id: "workbuddy-credits", + order: 100, + label: "WorkBuddy 用量", + }, WorkBuddyCreditsDock)); + ctx.effect(() => { + const observer = new MutationObserver(enhance); + observer.observe(document.body, { childList: true, subtree: true }); + document.addEventListener("change", enhance, true); + enhance(); + return () => { + observer.disconnect(); + document.removeEventListener("change", enhance, true); + }; + }, "llm-workbuddy: auth switch"); + } + + return { name: "dsh-llm-workbuddy-client", inject: ["slots"], apply }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..77bdfcb --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,10 @@ +- id: llm-pi-ai + disabled: !!js >- + !ctx.get('profileContext')?.installAnchor || + Number(JSON.parse(process.getBuiltinModule('fs').readFileSync( + ctx.get('profileContext').installAnchor, 'utf8')).version + .match(/^0\.1\.(\d+)/)?.[1] ?? 7) < 7 + +- insert: + - id: llm-workbuddy + name: '@axiaohungry/dsh-llm-workbuddy' diff --git a/docs/反向代理调用WorkBuddy-API开发文档.md b/docs/反向代理调用WorkBuddy-API开发文档.md new file mode 100644 index 0000000..5b73bd7 --- /dev/null +++ b/docs/反向代理调用WorkBuddy-API开发文档.md @@ -0,0 +1,579 @@ +# WorkBuddy API 调用开发文档 + +> 适用对象:需要在其他 Agent、CLI、IDE 插件或本地代理中接入 CodeBuddy 模型的开发者 +> 验证日期:2026-08-14 +> 验证客户端:`@tencent-ai/codebuddy-code 2.136.0` +> 文档性质:基于已安装客户端、实际接口响应和本项目适配代码整理的第三方开发说明,不是腾讯官方 API 承诺。 + +## 1. 先说明名称关系 + +日常说的“调用 WorkBuddy API”实际包含两个不同角色: + +| 名称 | 实际作用 | +|---|---| +| WorkBuddy | 提供或签发 API Key | +| CodeBuddy | 使用该 Key 的 Agent/CLI 产品 | +| `copilot.tencent.com` | 当前实际提供模型目录和推理接口的服务端 | +| 本项目 | 把上述服务适配为 DSH Provider | + +因此,当前观察到的调用链是: + +```mermaid +flowchart LR + A["WorkBuddy 提供 API Key"] --> B["CodeBuddy 或第三方 Agent"] + B --> C["GET /v3/config\n获取当前 Key 的模型权限"] + B --> D["POST /v2/chat/completions\n发起模型推理"] + C --> E["copilot.tencent.com"] + D --> E +``` + +不存在一个需要额外调用的“WorkBuddy 换取 CodeBuddy Token”步骤。开发者拿到有效 Key +后,直接把它用于模型目录和聊天请求。 + +## 2. 重要边界 + +1. 接口目前不是公开、稳定承诺的开发者 API,路径、Header 和字段可能随 CodeBuddy 更新。 +2. 只能使用本人或组织授权的 API Key,不要绕过账号、额度、模型权限或企业策略。 +3. 模型列表与 API Key 绑定,不同 Key 返回的模型 ID 可能不同。 +4. 不要把模型目录写死在代码中;每次添加或替换 Key 后应重新获取 `/v3/config`。 +5. API Key 只能放在环境变量、凭据服务或 Secret Manager 中,不能提交到 Git。 + +## 3. 接口总览 + +| 用途 | 方法 | 地址 | 协议 | +|---|---|---|---| +| 获取模型与产品配置 | `GET` | `https://copilot.tencent.com/v3/config` | JSON | +| 模型推理 | `POST` | `https://copilot.tencent.com/v2/chat/completions` | OpenAI Chat Completions 兼容,推荐 SSE 流式 | + +当前不是 OpenAI Responses API,也不是 Anthropic Messages API。第三方 Agent 应优先实现 +`openai-completions` / Chat Completions 适配。 + +## 4. API Key 管理 + +推荐环境变量: + +```text +WORKBUDDY_API_KEY=<从 WorkBuddy 获取的 Key> +``` + +Windows PowerShell 设置用户环境变量: + +```powershell +[Environment]::SetEnvironmentVariable( + "WORKBUDDY_API_KEY", + "<在本机填写,不要提交到仓库>", + "User" +) +``` + +新进程才能读取新设置。不要在日志中输出 Key;诊断两个 Key 是否相同时,可以比较 +SHA-256 指纹,而不是打印明文。 + +本插件新版本使用 `WORKBUDDY_API_KEY`。为兼容旧版本,仍可读取 `CODEBUDDY_API_KEY`,但新 +项目建议统一改用前者。 + +## 5. 获取当前 Key 可用的模型 + +### 5.1 请求 + +```http +GET /v3/config HTTP/1.1 +Host: copilot.tencent.com +Accept: application/json +X-API-Key: +User-Agent: CLI/unknown CodeBuddy/2.136.0 +X-Product: SaaS +``` + +其中: + +- `X-API-Key`:模型目录接口的关键鉴权 Header; +- `User-Agent`:按当前 CodeBuddy CLI 行为填写,建议版本升级时同步验证; +- `X-Product: SaaS`:标识当前产品部署类型; +- Header 名大小写不敏感。 + +### 5.2 响应骨架 + +成功时 HTTP 通常为 `200`,同时还要检查业务字段 `code`: + +```json +{ + "code": 0, + "msg": "OK", + "data": { + "agents": [ + { + "name": "cli", + "models": ["hy3", "glm-5.3", "deepseek-v4-flash"] + } + ], + "models": [ + { + "id": "deepseek-v4-flash", + "name": "Deepseek-V4-Flash", + "maxInputTokens": 1000000, + "maxOutputTokens": 50000, + "maxAllowedSize": 1000000, + "supportsImages": true, + "supportsReasoning": true, + "onlyReasoning": true, + "reasoning": { + "effort": "high", + "summary": "auto" + } + } + ] + } +} +``` + +示例只展示与适配有关的字段;实际响应还可能包含企业信息和产品功能开关。 + +### 5.3 正确的模型筛选算法 + +不能直接把 `data.models` 全部暴露给用户。正确流程是: + +1. 在 `data.agents` 中找到 `name === "cli"` 的 Agent; +2. 读取该 Agent 的 `models`,这是当前 Key 对 CLI 开放的模型 ID; +3. 用这些 ID 与 `data.models[].id` 关联; +4. 丢弃没有容量信息或没有对应配置的异常条目; +5. 保留服务端顺序,避免客户端自行重排造成默认模型变化。 + +兼容旧响应时,可以同时检查: + +```javascript +const agents = Array.isArray(data.agents) + ? data.agents + : data.agent?.agents; +``` + +### 5.4 Node.js 获取模型示例 + +Node.js 22 以上可直接使用内置 `fetch`,不需要 SDK: + +```javascript +const apiKey = process.env.WORKBUDDY_API_KEY; +if (!apiKey) throw new Error("缺少 WORKBUDDY_API_KEY"); + +const response = await fetch("https://copilot.tencent.com/v3/config", { + headers: { + accept: "application/json", + "x-api-key": apiKey, + "user-agent": "CLI/unknown CodeBuddy/2.136.0", + "x-product": "SaaS", + }, + signal: AbortSignal.timeout(20_000), +}); + +if (!response.ok) { + throw new Error(`模型目录 HTTP ${response.status}`); +} + +const body = await response.json(); +if (body?.code !== 0) { + throw new Error(`模型目录业务错误:${body?.msg ?? body?.code}`); +} + +const agents = Array.isArray(body.data?.agents) + ? body.data.agents + : body.data?.agent?.agents; +const allowedIds = agents?.find((agent) => agent?.name === "cli")?.models ?? []; +const byId = new Map((body.data?.models ?? []).map((model) => [model.id, model])); + +const models = allowedIds.flatMap((id) => { + const model = byId.get(id); + if (!model) return []; + const contextWindow = model.maxInputTokens ?? model.maxAllowedSize; + const maxTokens = model.maxOutputTokens; + if (!Number.isSafeInteger(contextWindow) || !Number.isSafeInteger(maxTokens)) return []; + return [{ + id, + name: model.name ?? id, + contextWindow, + maxTokens, + supportsImages: model.supportsImages === true, + supportsReasoning: model.supportsReasoning === true, + onlyReasoning: model.onlyReasoning === true, + defaultReasoningEffort: model.reasoning?.effort, + thinkingLevelMap: model.thinkingLevelMap, + thinkingFormat: model.thinkingFormat, + }]; +}); + +console.table(models); +``` + +## 6. 为什么不同账号看到的模型不同 + +`agents[name=cli].models` 是 Key 级别的授权结果,不是全平台公共目录。 + +2026-08-14 实测,同一台电脑上的两把有效 Key 返回了不同结果: + +| 现象 | Key A | Key B | +|---|---|---| +| GLM 新版本 | 包含 `glm-5.3` | 最高为 `glm-5.2` | +| MiniMax ID | `minimax-m3-pay` | `minimax-m3` | +| Kimi ID | `kimi-k3-2` | `kimi-k3-1` | + +因此,出现“CodeBuddy 能看到 `glm-5.3`,另一个 Agent 看不到”时,先确认两个程序实际使用 +的是不是同一把 Key。不要通过硬编码 `glm-5.3` 解决,否则请求阶段仍会被服务端拒绝。 + +## 7. 发起 Chat Completions 请求 + +### 7.1 请求地址 + +```text +POST https://copilot.tencent.com/v2/chat/completions +``` + +### 7.2 鉴权 Header + +当前 CodeBuddy CLI 会为模型请求同时准备: + +```http +Authorization: Bearer +X-API-Key: +Content-Type: application/json +Accept: text/event-stream +``` + +本项目基于 OpenAI SDK 的适配路径使用 `Authorization: Bearer` 即可完成请求;为了更贴近 +CodeBuddy CLI 并兼容服务端策略变化,独立开发的新客户端建议同时发送 `Authorization` +和 `X-API-Key`。不要把 Key 放进 URL Query。 + +CodeBuddy CLI 还会添加请求 ID、会话 ID、IDE 名称、产品类型等内部 Header。第三方开发的 +最小客户端不应伪造这些字段;只有在服务端明确要求时再增加。 + +### 7.3 最小请求体 + +```json +{ + "model": "deepseek-v4-flash", + "messages": [ + { "role": "user", "content": "请只回复:连接成功" } + ], + "stream": true, + "stream_options": { "include_usage": true }, + "max_tokens": 1024 +} +``` + +注意: + +- 使用 `max_tokens`,不是 `max_completion_tokens`; +- `model` 必须来自当前 Key 的 CLI 模型列表; +- 推荐 `stream: true`,这是 CodeBuddy Agent 的主要工作模式; +- `max_tokens` 不应超过目录中的 `maxOutputTokens`; +- 输入和预期输出总量不能超过模型上下文限制。 + +## 8. 思考能力必须逐模型处理 + +不能为所有模型统一写死 `high`,也不能假设所有模型都支持 `xhigh` 或 `max`。至少读取: + +| 字段 | 含义 | 处理方式 | +|---|---|---| +| `supportsReasoning` | 模型是否支持推理 | `false` 时不显示思考档位,不发送推理参数 | +| `onlyReasoning` | 是否只能以推理模式工作 | `true` 时不提供 `off` | +| `reasoning.effort` | 该模型的默认思考档位 | 用户未选择时使用;不要拿一个模型的默认值套给其他模型 | +| `thinkingLevelMap` | UI 档位到线上参数的逐模型映射 | 存在时严格按映射提供选项和转换 | +| `thinkingFormat` | 推理参数协议 | 存在时按该格式转换;缺失时才使用当前端点的兼容默认 | + +当前实测就存在不同默认值:部分模型为 `high`,部分模型为 `medium`。 + +### 8.1 选择规则 + +1. 用户显式选择档位:发送该模型声明支持的档位; +2. 用户选择“默认”或未选择:使用该模型自己的 `reasoning.effort`; +3. 服务端没有声明默认值:省略思考参数,让服务端决定; +4. `supportsReasoning === false`:删除所有推理参数; +5. `onlyReasoning === true`:不要提供 `off`; +6. `thinkingLevelMap` 存在:未出现在 Map 中的档位视为不支持; +7. 不要自动把不支持的档位静默替换成另一个档位,最好在请求前报错。 + +### 8.2 OpenAI 风格 + +当前适配使用的默认格式是: + +```json +{ + "reasoning_effort": "medium" +} +``` + +常见候选值为: + +```text +minimal / low / medium / high / xhigh / max +``` + +候选值不等于所有模型均支持。以当前模型的能力声明为准。 + +### 8.3 其他思考格式 + +如果未来目录返回不同 `thinkingFormat`,适配器可能需要转换,例如: + +```json +{ "reasoning": { "effort": "high" } } +``` + +或: + +```json +{ "thinking": { "type": "enabled" }, "reasoning_effort": "high" } +``` + +不要同时无条件发送所有格式。应为每个模型只生成一种服务端声明的格式。 + +## 9. 工具调用 + +请求中的工具遵循 OpenAI Chat Completions 格式: + +```json +{ + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "查询天气", + "parameters": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false + } + } + } + ], + "tool_choice": "auto" +} +``` + +流式响应中的工具参数会分段出现在: + +```text +choices[0].delta.tool_calls[].function.arguments +``` + +客户端必须按 `tool_calls[].index` 累积字符串,结束后再解析 JSON。执行工具后,把结果作为 +`role: "tool"`、带相同 `tool_call_id` 的消息加入下一轮请求。 + +## 10. 图片输入 + +只有 `supportsImages === true` 的模型才能接收图片。OpenAI 兼容格式示例: + +```json +{ + "role": "user", + "content": [ + { "type": "text", "text": "描述这张图片" }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64," + } + } + ] +} +``` + +发送前应限制文件大小和 MIME 类型,不要仅根据扩展名判断图片。 + +## 11. SSE 流式响应解析 + +服务端返回 `text/event-stream`。每个事件通常形如: + +```text +data: {"id":"...","choices":[{"delta":{"content":"你"}}]} + +data: {"id":"...","choices":[{"delta":{"content":"好"},"finish_reason":"stop"}]} + +data: [DONE] +``` + +需要处理的字段: + +| 字段 | 用途 | +|---|---| +| `choices[0].delta.content` | 最终回答文本增量 | +| `choices[0].delta.reasoning_content` | 思考内容增量之一 | +| `choices[0].delta.reasoning` | 部分模型使用的思考内容字段 | +| `choices[0].delta.reasoning_text` | 另一种兼容思考字段 | +| `choices[0].delta.tool_calls` | 工具调用增量 | +| `choices[0].finish_reason` | `stop`、`length`、`tool_calls` 等结束原因 | +| `usage` | Token 用量,通常在开启 `include_usage` 后的尾部 Chunk 返回 | + +同一个 Chunk 可能没有 `choices`,只有 `usage`,不能因此判定响应异常。 + +## 12. 完整 Node.js 流式示例 + +```javascript +const apiKey = process.env.WORKBUDDY_API_KEY; +if (!apiKey) throw new Error("缺少 WORKBUDDY_API_KEY"); + +const response = await fetch("https://copilot.tencent.com/v2/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "x-api-key": apiKey, + "content-type": "application/json", + accept: "text/event-stream", + }, + body: JSON.stringify({ + model: "deepseek-v4-flash", + messages: [{ role: "user", content: "请只回复:连接成功" }], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 1024, + // reasoning_effort: "high", // 只在当前模型明确支持且用户选择时添加 + }), + signal: AbortSignal.timeout(300_000), +}); + +if (!response.ok) { + const detail = await response.text(); + throw new Error(`推理接口 HTTP ${response.status}: ${detail.slice(0, 500)}`); +} + +const decoder = new TextDecoder(); +let buffer = ""; +let answer = ""; +let reasoning = ""; + +for await (const chunk of response.body) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + const event = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + for (const line of event.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + + const payload = JSON.parse(data); + const delta = payload.choices?.[0]?.delta; + if (typeof delta?.content === "string") { + answer += delta.content; + process.stdout.write(delta.content); + } + const thought = delta?.reasoning_content ?? delta?.reasoning ?? delta?.reasoning_text; + if (typeof thought === "string") reasoning += thought; + if (payload.usage) console.error("\nusage:", payload.usage); + } + } +} + +console.log("\n\nanswer:", answer); +console.log("reasoning length:", reasoning.length); +``` + +生产代码还需要按 `tool_calls[].index` 累积工具参数,并处理单个 SSE 事件跨网络 Chunk 的情况。 + +## 13. 错误处理与重试 + +### 13.1 模型目录 + +同时检查 HTTP 状态和 JSON 业务状态: + +```javascript +if (!response.ok) throw new Error(`HTTP ${response.status}`); +if (body.code !== 0) throw new Error(body.msg ?? String(body.code)); +``` + +### 13.2 常见分类 + +| 情况 | 建议处理 | +|---|---| +| `401` / `403` | Key 无效、过期或无权限;停止重试并要求重新填写 | +| `408` / 网络断开 | 指数退避重试 | +| `429` | 遵守 `Retry-After`;提示额度或频率限制 | +| `5xx` | 短暂退避后有限重试 | +| 模型不在 CLI 列表 | 刷新目录,不要强制调用 | +| 流结束但没有结束原因 | 视为不完整响应,不要保存为成功回答 | + +流式请求只应在“尚未收到任何有效增量”时自动重试。收到文本或工具调用后再次自动重试, +可能造成重复输出或重复执行工具。 + +### 13.3 推荐超时 + +- 模型目录:20 秒总超时; +- 建立推理连接:30–60 秒; +- 流空闲超时:300 秒; +- 用户取消:通过 `AbortController` 立即向上游传播。 + +## 14. 缓存策略 + +模型目录可以按 Key 指纹缓存,但不能跨 Key 共用: + +```text +cache key = SHA-256(API Key) + 客户端产品类型 +``` + +建议: + +- 缓存 5–15 分钟; +- 用户点击“刷新模型”时绕过缓存; +- 更换 Key 后立即清除旧缓存; +- 缓存失败时可以使用最近一次成功目录,但 UI 必须标注“可能过期”; +- 不要把完整 Key 写进缓存键或日志。 + +## 15. 适配其他 Agent 的最小接口 + +一个可维护的 Provider 只需要四个职责: + +```text +resolveCredential() -> 安全读取 Key +discoverModels() -> 获取并按 CLI 权限过滤模型 +describeModel(id) -> 返回该模型容量、模态、思考档位和默认值 +stream(request) -> 转换消息并解析 SSE +``` + +DSH、OpenCode 或其他 Agent 自己负责: + +- Agent 循环; +- 上下文裁剪与压缩; +- 工具实际执行; +- 权限确认; +- 会话持久化; +- 重试策略和用户取消。 + +CodeBuddy 服务端负责模型推理,不会替第三方 Agent 自动执行本地工具。 + +## 16. 验收清单 + +开发完成后至少验证: + +- [ ] Key 不出现在源码、Git、日志和异常堆栈中; +- [ ] 两把不同 Key 的模型目录不会串用; +- [ ] 只显示 `agents[name=cli].models` 授权的模型; +- [ ] 新模型无需发布新代码即可出现; +- [ ] 旧模型下线后不会继续从缓存永久显示; +- [ ] 上下文窗口和最大输出来自逐模型字段; +- [ ] 非图片模型会在请求前拒绝图片; +- [ ] 非推理模型不显示思考控件; +- [ ] `onlyReasoning` 模型不显示 `off`; +- [ ] 每个模型使用自己的默认思考档位; +- [ ] 用户显式档位会转换为该模型自己的线上值; +- [ ] 能累计文本、思考和工具调用 SSE 增量; +- [ ] 用户取消能中止网络请求; +- [ ] 429 和 5xx 有限重试,不会无限循环; +- [ ] 工具调用不会因自动重试而重复执行。 + +## 17. 版本漂移检查 + +升级 CodeBuddy CLI 后,应重新核对: + +1. `product.json` 和 CLI 版本; +2. 配置接口是否仍为 `/v3/config`; +3. 推理接口是否仍为 `/v2/chat/completions`; +4. 鉴权是否仍接受 `Authorization` / `X-API-Key`; +5. 模型能力字段是否新增 `thinkingLevelMap`、`thinkingFormat` 等; +6. SSE 推理和工具字段是否变化; +7. 当前 Key 实际返回的 CLI 模型目录。 + +本项目的可执行实现位于 [`index.js`](../index.js),安装和使用说明见 +[`README.md`](../README.md)。 diff --git a/index.js b/index.js new file mode 100644 index 0000000..53d60fa --- /dev/null +++ b/index.js @@ -0,0 +1,982 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment"; +import { LlmError, assertUsableApiKey, resolveRetryPolicy } from "@deepseek-ai/dsh-llm"; +import { Config, PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; +import * as dshSettings from "@deepseek-ai/dsh-settings"; +import { createProvider } from "@earendil-works/pi-ai"; +import * as openAICompletionsApi from "@earendil-works/pi-ai/api/openai-completions"; +import * as openAIResponsesApi from "@earendil-works/pi-ai/api/openai-responses"; +import * as anthropicMessagesApi from "@earendil-works/pi-ai/api/anthropic-messages"; +import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import { + WORKBUDDY_SESSION_REF, + WORKBUDDY_SESSIONS_REF, + WORKBUDDY_SESSION_ROUTING_REF, + LEGACY_SESSION_REF, + LEGACY_SESSIONS_REF, + activeWorkBuddySession, + createWorkBuddySessionStore, + createWorkBuddySessionRoutingState, + parseWorkBuddySession, + parseWorkBuddySessions, + parseWorkBuddySessionRouting, + refreshWorkBuddySession, + serializeWorkBuddySession, + serializeWorkBuddySessionRouting, + serializeWorkBuddySessions, + sessionCacheDeadline, + sessionNeedsRefresh, + upsertWorkBuddySession, +} from "./workbuddy-auth.js"; +import { installWorkBuddyWeb } from "./workbuddy-web.js"; +import { probeEndpoint } from "./workbuddy-discovery.js"; + +export { Config }; + +export const name = "llm-workbuddy"; +export const inject = ["llm"]; + +const NS = typeof dshSettings.settingsNamespace === "function" ? dshSettings.settingsNamespace("llm-pi-ai") : "llm-pi-ai"; +const PROVIDER = "workbuddy-cn"; +const LEGACY_PROVIDER = "codebuddy-cn"; +const WORKBUDDY_PROVIDERS = new Set([PROVIDER, LEGACY_PROVIDER]); +const WORKBUDDY_PROVIDER_PATTERN = /(?:^|-)(?:work-?buddy|code-?buddy)(?:-|$)/; +const DISPLAY_NAME = "WorkBuddy"; +const API_KEY_ENV = "WORKBUDDY_API_KEY"; +const LEGACY_API_KEY_ENV = "CODEBUDDY_API_KEY"; +const BASE_URL = "https://copilot.tencent.com/v2"; +const CONFIG_URL = "https://copilot.tencent.com/v3/config"; +const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1"; +const STREAM_IDLE_TIMEOUT_MS = 300_000; +const traceContext = new AsyncLocalStorage(); +let nextTraceId = 0; +const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +const EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"]; +const THINKING_LEVELS = ["off", ...EFFORTS]; +const COMPAT = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + thinkingFormat: "openai", +}; + +function workBuddyRequestOptions(options) { + const trace = traceContext.getStore(); + const fetchImpl = options?.fetch ?? globalThis.fetch; + return { + ...options, + timeoutMs: options?.timeoutMs ?? STREAM_IDLE_TIMEOUT_MS, + headers: { ...(options?.headers ?? {}), "user-agent": USER_AGENT }, + ...(trace ? { fetch: async (input, init) => { + const started = Date.now(); + traceEvent(trace, "http.start"); + try { + const response = await fetchImpl(input, init); + traceEvent(trace, "http.headers", { elapsedMs: Date.now() - started, status: response.status }); + return response; + } catch (error) { + traceEvent(trace, "http.error", { elapsedMs: Date.now() - started, ...errorFields(error) }); + throw error; + } + } } : {}), + }; +} + +function errorFields(error) { + const safe = (value) => typeof value === "string" && /^[A-Za-z_][A-Za-z_0-9-]{0,39}$/.test(value) ? value : undefined; + return { + ...(safe(error?.name) ? { errorName: safe(error.name) } : {}), + ...(safe(error?.code) ? { errorCode: safe(error.code) } : {}), + ...(Number.isInteger(error?.status) ? { status: error.status } : {}), + }; +} + +function traceEvent(trace, stage, details = {}) { + if (!trace) return; + console.info("[dsh-llm-workbuddy]", JSON.stringify({ request: trace.id, stage, ...details })); +} + +function observedWorkBuddyStream(model, context, options) { + const trace = traceContext.getStore(); + const started = Date.now(); + const stream = openAICompletionsApi.streamSimple(model, context, workBuddyRequestOptions(options)); + return (async function* () { + traceEvent(trace, "model.start"); + let first = true; + let completed = false; + let failed = false; + try { + for await (const chunk of stream) { + if (first) { + first = false; + traceEvent(trace, "model.first-chunk", { elapsedMs: Date.now() - started }); + } + yield chunk; + } + completed = true; + traceEvent(trace, "model.done", { elapsedMs: Date.now() - started }); + } catch (error) { + failed = true; + traceEvent(trace, "model.error", { elapsedMs: Date.now() - started, ...errorFields(error) }); + throw error; + } finally { + if (!completed && !failed) traceEvent(trace, "model.cancelled", { elapsedMs: Date.now() - started }); + } + })(); +} + +const workBuddyApi = { + ...openAICompletionsApi, + stream: (model, context, options) => openAICompletionsApi.stream(model, context, workBuddyRequestOptions(options)), + streamSimple: observedWorkBuddyStream, +}; + +const FALLBACK_MODELS = [ + ["hy3", "Hy3", 192000, 64000, true], + ["glm-5.2", "GLM-5.2", 1000000, 48000, false], + ["glm-5.1", "GLM-5.1", 200000, 48000, false], + ["glm-5v-turbo", "GLM-5v-Turbo", 200000, 64000, true], + ["minimax-m3-pay", "MiniMax-M3", 512000, 128000, true], + ["minimax-m2.7", "MiniMax-M2.7", 200000, 48000, true], + ["kimi-k3-2", "Kimi-K3", 1000000, 32000, true], + ["kimi-k2.7", "Kimi-K2.7-Code", 256000, 32000, true], + ["kimi-k2.6", "Kimi-K2.6", 256000, 32000, true], + ["deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 50000, true], + ["deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, 50000, true], +].map(([id, modelName, contextWindow, maxTokens, images]) => + workBuddyModel({ id, name: modelName, contextWindow, maxTokens, images }), +); + +function workBuddyModel({ provider = PROVIDER, id, name: modelName, contextWindow, maxTokens, images, reasoning = true, thinkingLevelMap = { off: null }, defaultReasoningEffort, thinkingFormat }) { + return { + id, + name: modelName, + api: "openai-completions", + provider, + baseUrl: BASE_URL, + reasoning, + ...(reasoning ? { thinkingLevelMap: { ...thinkingLevelMap } } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + input: images ? ["text", "image"] : ["text"], + cost: { ...NO_COST }, + contextWindow, + maxTokens, + compat: { ...COMPAT, ...(thinkingFormat ? { thinkingFormat } : {}) }, + }; +} + +function remoteReasoning(raw, fallback) { + const reasoning = raw.supportsReasoning ?? fallback?.reasoning ?? raw.onlyReasoning === true; + if (!reasoning) return { reasoning: false }; + const declared = raw.thinkingLevelMap && typeof raw.thinkingLevelMap === "object" ? raw.thinkingLevelMap : undefined; + const thinkingLevelMap = declared + ? Object.fromEntries(THINKING_LEVELS.map((level) => [level, + Object.hasOwn(declared, level) && (typeof declared[level] === "string" || declared[level] === null) ? declared[level] : null])) + : { ...(fallback?.thinkingLevelMap ?? {}), ...(raw.onlyReasoning === true ? { off: null } : {}) }; + const effort = raw.reasoning?.effort; + const defaultReasoningEffort = EFFORTS.includes(effort) && thinkingLevelMap[effort] !== null ? effort : undefined; + return { + reasoning: true, + thinkingLevelMap, + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + ...(typeof raw.thinkingFormat === "string" ? { thinkingFormat: raw.thinkingFormat } : {}), + }; +} + +function configuredReasoning(entry, base) { + if (entry.reasoningEfforts === false) return { reasoning: false }; + if (!entry.reasoningEfforts || typeof entry.reasoningEfforts !== "object") { + return base ? { + reasoning: base.reasoning, + thinkingLevelMap: base.thinkingLevelMap, + defaultReasoningEffort: base.defaultReasoningEffort, + thinkingFormat: base.compat?.thinkingFormat, + } : { reasoning: false }; + } + const map = {}; + for (const level of THINKING_LEVELS) { + if (!Object.hasOwn(entry.reasoningEfforts, level)) map[level] = null; + else if (!(level === "off" && entry.reasoningEfforts[level] === null)) map[level] = entry.reasoningEfforts[level]; + } + return { reasoning: true, thinkingLevelMap: map, thinkingFormat: entry.compat?.thinkingFormat }; +} + +function positiveInteger(...values) { + return values.find((value) => Number.isSafeInteger(value) && value > 0); +} + +function text(...values) { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function modelsFromConfig(data) { + const agents = Array.isArray(data?.agents) ? data.agents : data?.agent?.agents; + const cli = Array.isArray(agents) ? agents.find((agent) => agent?.name === "cli") : undefined; + const allowed = Array.isArray(cli?.models) ? cli.models : []; + const source = Array.isArray(data?.models) ? data.models : []; + const byId = new Map(source.map((model) => [model?.id, model])); + return allowed.flatMap((id) => { + const raw = byId.get(id); + if (!raw) return []; + const fallback = FALLBACK_MODELS.find((model) => model.id === id); + const contextWindow = positiveInteger(raw.maxInputTokens, raw.maxAllowedSize, fallback?.contextWindow); + const maxTokens = positiveInteger(raw.maxOutputTokens, fallback?.maxTokens); + if (!contextWindow || !maxTokens) return []; + return [workBuddyModel({ + id, + name: text(raw.name, fallback?.name, id), + contextWindow, + maxTokens, + images: raw.supportsImages === true || fallback?.input.includes("image") === true, + ...remoteReasoning(raw, fallback), + })]; + }); +} + +function authenticationHeaders(credential) { + const value = assertUsableApiKey(credential.value, name, credential.ref ?? API_KEY_ENV); + return credential.kind === "bearer" ? { authorization: `Bearer ${value}` } : { "x-api-key": value }; +} + +async function fetchWorkBuddyModels(credential, signal) { + let response; + try { + response = await fetch(CONFIG_URL, { + headers: { + accept: "application/json", + ...authenticationHeaders(credential), + "user-agent": USER_AGENT, + "x-product": "SaaS", + }, + signal, + }); + } catch (error) { + if (signal?.aborted) throw new LlmError("WorkBuddy 模型列表获取已取消", "ABORTED", { cause: error }); + throw new LlmError("无法连接 WorkBuddy 模型配置接口", "DISCOVERY_FAILED", { cause: error }); + } + if (!response.ok) throw new LlmError(`WorkBuddy 模型配置接口返回 ${response.status}`, "DISCOVERY_FAILED"); + const body = await response.json(); + if (body?.code !== 0) throw new LlmError(`WorkBuddy 模型配置接口错误:${body?.msg ?? body?.code}`, "DISCOVERY_FAILED"); + const models = modelsFromConfig(body.data); + if (models.length === 0) throw new LlmError("WorkBuddy 没有返回 CLI 可用模型", "DISCOVERY_FAILED"); + return models; +} + +/** + * WorkBuddy's credential is already resolved by the DSH adapter. Do not + * reuse pi-ai's DeepSeek envApiKeyAuth here: newer pi-ai releases require a + * signal argument while older DSH adapters call auth resolvers without one. + * This small adapter accepts both contracts and keeps bearer/API-key values + * opaque to the provider implementation. + */ +function workBuddyApiKeyAuth() { + return { + name: `${DISPLAY_NAME} API Key`, + login: async (interaction) => { + const signal = interaction?.signal; + signal?.throwIfAborted?.(); + const key = await interaction.prompt({ type: "secret", message: `Enter ${DISPLAY_NAME} API Key` }); + signal?.throwIfAborted?.(); + return { type: "api_key", key }; + }, + resolve: async ({ credential, signal } = {}) => { + signal?.throwIfAborted?.(); + if (!credential?.key) return undefined; + return { + auth: { apiKey: credential.key }, + ...(credential.env ? { env: credential.env } : {}), + source: "DSH credential", + }; + }, + }; +} + +function workBuddyProvider(models, provider = PROVIDER) { + return createProvider({ + id: provider, + name: DISPLAY_NAME, + baseUrl: BASE_URL, + auth: { apiKey: workBuddyApiKeyAuth() }, + models: models.map((model) => ({ ...model, provider })), + api: workBuddyApi, + }); +} + +const GENERIC_APIS = Object.freeze({ + "openai-completions": openAICompletionsApi, + "openai-responses": openAIResponsesApi, + "anthropic-messages": anthropicMessagesApi, +}); +const GENERIC_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +function genericApiKeyAuth(provider) { + return { + name: `${provider} API Key`, + resolve: async ({ credential, signal } = {}) => { + signal?.throwIfAborted?.(); + if (!credential?.key) return undefined; + return { auth: { apiKey: credential.key }, source: "DSH credential" }; + }, + }; +} + +function genericModel(provider, source, entry) { + const reasoningEfforts = entry.reasoningEfforts; + const reasoning = reasoningEfforts !== false && reasoningEfforts && typeof reasoningEfforts === "object"; + const thinkingLevelMap = reasoning + ? Object.fromEntries(GENERIC_LEVELS.filter((level) => Object.hasOwn(reasoningEfforts, level)).map((level) => [level, reasoningEfforts[level]])) + : undefined; + return { + id: entry.id, + name: entry.name ?? entry.id, + api: source.api, + provider, + baseUrl: source.baseURL, + input: Array.isArray(entry.input) && entry.input.length > 0 ? [...entry.input] : [...source.defaultInput ?? ["text"]], + cost: { ...NO_COST }, + contextWindow: entry.contextWindow ?? source.defaultContextWindow ?? 262144, + maxTokens: entry.maxTokens ?? source.defaultMaxTokens ?? 32768, + ...(reasoning ? { reasoning: true, thinkingLevelMap } : {}), + ...(entry.compat ?? source.compat ? { compat: { ...(source.compat ?? {}), ...(entry.compat ?? {}) } } : {}), + }; +} + +function genericProvider(provider, source = {}) { + const api = GENERIC_APIS[source.api]; + if (!api || !source.baseURL || !Array.isArray(source.models) || source.models.length === 0) return undefined; + return createProvider({ + id: provider, + name: source.displayName ?? provider, + baseUrl: source.baseURL, + headers: source.headers, + auth: { apiKey: genericApiKeyAuth(provider) }, + models: source.models.map((entry) => genericModel(provider, source, entry)), + api, + }); +} + +function resolvedProfile(provider, source, piProvider, configuredMaxTokens = new Map(), requestContext) { + const apiKeyEnv = source.apiKeyEnv === undefined ? undefined : credentialRef(source.apiKeyEnv); + return { + ...source, + headers: runtimeHeaders(source.headers, requestContext), + provider, + displayName: source.displayName ?? piProvider.name ?? provider, + // dsh-llm-pi-ai reads this map for every exact model during catalog + // resolution. WorkBuddy profiles have no per-model validation failures + // here, but must still provide the empty map for the shared adapter API. + modelErrors: new Map(), + ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }), + streamIdleTimeoutMs: source.streamIdleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS, + retryPolicy: resolveRetryPolicy(source.retryPolicy, `${name}: provider "${provider}" retryPolicy`), + configuredMaxTokens, + piProvider, + }; +} + +function selectBuiltinModels(base, entries) { + if (!Array.isArray(entries) || entries.length === 0) return base; + const byId = new Map(base.getModels().map((model) => [model.id, model])); + const selected = entries.flatMap((entry) => { + const model = byId.get(entry.id); + if (!model) return []; + return [{ + ...model, + ...(entry.name ? { name: entry.name } : {}), + ...(entry.contextWindow ? { contextWindow: entry.contextWindow } : {}), + ...(entry.maxTokens ? { maxTokens: entry.maxTokens } : {}), + ...(Array.isArray(entry.input) && entry.input.length ? { input: [...entry.input] } : {}), + }]; + }); + return { ...base, getModels: () => selected }; +} + +function selectWorkBuddyModels(base, entries) { + if (!Array.isArray(entries) || entries.length === 0) return base; + const byId = new Map(base.map((model) => [model.id, model])); + return entries.map((entry) => { + const model = byId.get(entry.id); + const reasoning = configuredReasoning(entry, model); + return workBuddyModel({ + id: entry.id, + name: entry.name ?? model?.name ?? entry.id, + contextWindow: entry.contextWindow ?? model?.contextWindow ?? 262144, + maxTokens: entry.maxTokens ?? model?.maxTokens ?? 32768, + images: entry.input?.includes("image") ?? model?.input.includes("image") ?? false, + ...reasoning, + }); + }); +} + +function ownsProvider(provider, builtins, source) { + return WORKBUDDY_PROVIDERS.has(provider) || builtins.has(provider) || genericProvider(provider, source) !== undefined; +} + +function runtimeHeaders(headers, requestContext) { + const base = { ...(headers ?? {}) }; + if (!requestContext) return base; + return new Proxy(base, { + ownKeys(target) { + const extra = requestContext.getStore()?.headers ?? {}; + return [...new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(extra)])]; + }, + getOwnPropertyDescriptor(target, property) { + const extra = requestContext.getStore()?.headers ?? {}; + if (!Reflect.has(target, property) && !Reflect.has(extra, property)) return undefined; + return { configurable: true, enumerable: true, writable: true, value: this.get(target, property) }; + }, + get(target, property, receiver) { + const extra = requestContext.getStore()?.headers ?? {}; + return Reflect.has(extra, property) ? extra[property] : Reflect.get(target, property, receiver); + }, + }); +} + +function sessionBindingFor(routing, sessionId) { + if (!routing?.enabled) return undefined; + const id = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined; + return id && Object.hasOwn(routing.bindings, id) ? routing.bindings[id] : undefined; +} + +function normalizedProviderName(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function isWorkBuddyProviderName(value) { + const normalized = normalizedProviderName(value); + return normalized.length > 0 && WORKBUDDY_PROVIDER_PATTERN.test(normalized); +} + +function directWorkBuddyProvider(value) { + const normalized = normalizedProviderName(value); + if (normalized === PROVIDER || normalized === LEGACY_PROVIDER) return normalized; + return undefined; +} + +function interruptedToolTailAssistantIndex(messages) { + let tailIndex = messages.length - 1; + while (tailIndex >= 0 && messages[tailIndex]?.role === "system") tailIndex -= 1; + if (tailIndex < 0) return -1; + const tail = messages[tailIndex]; + if (tail?.role !== "user" || !Array.isArray(tail.content)) return -1; + if (!tail.content.some((block) => block?.type === "tool-result" && block.isError === true)) return -1; + for (let index = tailIndex - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== "assistant") continue; + return Array.isArray(message.content) && message.content.some((block) => block?.type === "tool-call") ? index : -1; + } + return -1; +} + +/** + * Make WorkBuddy replay metadata safe across direct and wrapped provider ids. + * The returned messages are request-only copies; durable session history is + * never rewritten. An interrupted tool result deliberately loses only the + * preceding assistant replayState so the model receives ordinary history. + */ +function normalizeWorkBuddyReplay(options) { + if (!Array.isArray(options?.messages)) return options; + const currentProvider = options.provider; + if (!isWorkBuddyProviderName(currentProvider)) return options; + const interruptedIndex = interruptedToolTailAssistantIndex(options.messages); + let changed = false; + const messages = options.messages.map((message, index) => { + const source = message?.source; + const state = source?.replayState; + if (message?.role !== "assistant" || !source || state?.kind !== "pi-ai" || state?.version !== 1) return message; + + let nextSource = source; + const sourceProvider = source.provider; + const replayProvider = state.provider; + if (isWorkBuddyProviderName(sourceProvider) && isWorkBuddyProviderName(replayProvider)) { + const canonical = directWorkBuddyProvider(replayProvider) + ?? directWorkBuddyProvider(sourceProvider) + ?? directWorkBuddyProvider(currentProvider); + if (canonical && (sourceProvider !== canonical || replayProvider !== canonical)) { + nextSource = { + ...nextSource, + provider: canonical, + replayState: { ...state, provider: canonical }, + }; + } + } + + if (index === interruptedIndex && nextSource.replayState !== undefined) { + const { replayState: _ignored, ...withoutReplay } = nextSource; + nextSource = withoutReplay; + } + if (nextSource === source) return message; + changed = true; + return { ...message, source: nextSource }; + }); + return changed ? { ...options, messages } : options; +} + +// The rc.6 pi-ai adapter rejects replay metadata it does not understand. A +// newer DSH may persist a v2 envelope, so let old adapters use the durable +// message content as provider-neutral history instead of failing the request. +function stripUnsupportedReplay(options) { + if (!Array.isArray(options?.messages)) return options; + let changed = false; + const messages = options.messages.map((message) => { + const source = message?.source; + const state = source?.replayState; + if (state === undefined || (state?.kind === "pi-ai" && state?.version === 1)) return message; + changed = true; + const { replayState: _ignored, ...sourceWithoutReplay } = source; + return { ...message, source: sourceWithoutReplay }; + }); + return changed ? { ...options, messages } : options; +} + +function prepareWorkBuddyOptions(options, legacyReplay = true) { + const normalized = normalizeWorkBuddyReplay(options); + return legacyReplay ? stripUnsupportedReplay(normalized) : normalized; +} + +function workBuddySource(config, source) { + const providers = providerSettings(config); + return Object.hasOwn(providers, PROVIDER) || Object.hasOwn(providers, LEGACY_PROVIDER) + ? source + : { ...source, apiKeyEnv: source.apiKeyEnv ?? API_KEY_ENV }; +} + +function providerSettings(config) { + return typeof config?.providers?.get === "function" ? config.providers.get() : config?.providers ?? {}; +} + +function installSettingsCompat(ctx, ns, schema, entry, hooks) { + if (typeof entry?.providers?.get === "function") { + return ctx.inject(["settings"], (child) => { + child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)); + }); + } + if (typeof dshSettings.installSettingsSection === "function") { + return dshSettings.installSettingsSection(ctx, ns, schema, entry, hooks); + } + return ctx.inject(["settings"], (settingsCtx) => { + if (!settingsCtx.settings || typeof settingsCtx.settings.installSection !== "function") { + throw new Error(`${name}: DSH settings service does not provide installSection`); + } + return settingsCtx.settings.installSection(ctx, ns, schema, entry, hooks); + }); +} + +export const __testing = Object.freeze({ authenticationHeaders, workBuddyApiKeyAuth, workBuddyRequestOptions, workBuddySource, providerSettings, genericProvider, modelsFromConfig, ownsProvider, runtimeHeaders, stripUnsupportedReplay, normalizeWorkBuddyReplay, prepareWorkBuddyOptions, selectWorkBuddyModels, sessionBindingFor }); + +export function apply(ctx, config) { + const modernSettings = typeof config?.providers?.get === "function"; + const settingsNs = modernSettings ? ctx.fiber?.entry?.options?.id ?? name : NS; + installWorkBuddyWeb(ctx, settingsNs); + let current = () => config; + const requestContext = new AsyncLocalStorage(); + let remoteModels; + let generation = 0; + let memoRaw; + let memoProviders; + let memoGeneration = -1; + let memoized; + const loginSessionPromises = new Map(); + let remoteModelsKey; + const builtins = new Map(builtinProviders().map((provider) => [provider.id, provider])); + + const effectiveConfig = () => { + const raw = current() ?? {}; + const providers = providerSettings(raw); + const configured = providers[PROVIDER] ?? providers[LEGACY_PROVIDER]; + return { + ...raw, + providers: { + ...providers, + [PROVIDER]: configured ?? { apiKeyEnv: API_KEY_ENV }, + }, + }; + }; + + const profiles = () => { + const raw = effectiveConfig(); + const configuredProviders = providerSettings(current()); + if (memoRaw === current() && memoProviders === configuredProviders && memoGeneration === generation && memoized) return memoized; + const result = new Map(); + for (const [provider, source] of Object.entries(raw.providers)) { + if (modernSettings && !WORKBUDDY_PROVIDERS.has(provider)) continue; + if (!ownsProvider(provider, builtins, source)) continue; + if (WORKBUDDY_PROVIDERS.has(provider)) { + const sourceWithAuth = workBuddySource(current(), source); + const models = selectWorkBuddyModels(remoteModels ?? FALLBACK_MODELS, source.models); + const configured = new Map((source.models ?? []).flatMap((model) => + Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [], + )); + result.set(provider, resolvedProfile(provider, { + ...sourceWithAuth, + headers: runtimeHeaders(sourceWithAuth.headers, requestContext), + displayName: DISPLAY_NAME, + }, workBuddyProvider(models, provider), configured, requestContext)); + continue; + } + const base = builtins.get(provider); + if (!base) { + const generic = genericProvider(provider, source); + if (!generic) continue; + const configured = new Map((source.models ?? []).flatMap((model) => + Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [], + )); + result.set(provider, resolvedProfile(provider, source, generic, configured)); + continue; + } + const selected = selectBuiltinModels(base, source.models); + const configured = new Map((source.models ?? []).flatMap((model) => + Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [], + )); + result.set(provider, resolvedProfile(provider, source, selected, configured)); + } + memoRaw = current(); + memoProviders = configuredProviders; + memoGeneration = generation; + memoized = result; + return result; + }; + + const readSessionRouting = async () => { + const credentials = ctx.get("credentials"); + const env = launchEnvironmentOf(ctx); + const ref = credentialRef(WORKBUDDY_SESSION_ROUTING_REF); + const stored = await credentials?.resolve(ref); + const value = stored?.value ?? env.get(ref)?.value; + return value ? parseWorkBuddySessionRouting(value) : createWorkBuddySessionRoutingState(); + }; + let routingBindingQueue = Promise.resolve(); + const persistDefaultSessionBinding = (sessionId, fallbackBinding) => { + const task = routingBindingQueue.then(async () => { + const latest = await readSessionRouting(); + const existing = sessionBindingFor(latest, sessionId); + if (existing) return existing; + const binding = latest.lastUsed ?? fallbackBinding; + if (!binding) return undefined; + const credentials = ctx.get("credentials"); + if (!credentials) throw new Error("DSH 凭据服务不可用,无法保存会话认证"); + await credentials.set(credentialRef(WORKBUDDY_SESSION_ROUTING_REF), serializeWorkBuddySessionRouting({ + ...latest, + bindings: { ...latest.bindings, [sessionId]: binding }, + })); + return binding; + }); + routingBindingQueue = task.then(() => undefined, () => undefined); + return task; + }; + + const resolveLoginSession = async (requestedId) => { + const key = typeof requestedId === "string" && requestedId ? requestedId : "active"; + let promise = loginSessionPromises.get(key); + if (!promise) { + promise = (async () => { + const credentials = ctx.get("credentials"); + const env = launchEnvironmentOf(ctx); + const sessionsRef = credentialRef(WORKBUDDY_SESSIONS_REF); + const sessionRefs = [sessionsRef, credentialRef(LEGACY_SESSIONS_REF)]; + let sessionsValue; + for (const ref of sessionRefs) { + const storedSessions = await credentials?.resolve(ref); + sessionsValue = storedSessions?.value ?? env.get(ref)?.value; + if (sessionsValue) break; + } + let store; + if (sessionsValue) { + store = parseWorkBuddySessions(sessionsValue); + } else { + const legacyRefs = [credentialRef(WORKBUDDY_SESSION_REF), credentialRef(LEGACY_SESSION_REF)]; + let legacyValue; + for (const ref of legacyRefs) { + const storedLegacy = await credentials?.resolve(ref); + legacyValue = storedLegacy?.value ?? env.get(ref)?.value; + if (legacyValue) break; + } + if (!legacyValue) throw new Error("未找到 WorkBuddy 登录凭据"); + store = createWorkBuddySessionStore([parseWorkBuddySession(legacyValue)]); + } + const active = typeof requestedId === "string" && requestedId + ? store.sessions.find((entry) => entry.id === requestedId) + : activeWorkBuddySession(store); + if (!active) throw new Error("未找到 WorkBuddy 登录账号"); + let session = active; + if (sessionNeedsRefresh(session)) { + const trace = traceContext.getStore(); + const started = Date.now(); + traceEvent(trace, "auth.refresh-start"); + try { + session = { ...session, ...(await refreshWorkBuddySession(session)), updatedAt: Date.now() }; + traceEvent(trace, "auth.refresh-done", { elapsedMs: Date.now() - started }); + } catch (error) { + traceEvent(trace, "auth.refresh-error", { elapsedMs: Date.now() - started, ...errorFields(error) }); + throw error; + } + const nextStore = { + ...store, + sessions: store.sessions.map((entry) => entry.id === session.id ? session : entry), + }; + await credentials?.set(sessionsRef, serializeWorkBuddySessions(nextStore)); + if (nextStore.activeId === session.id) await credentials?.set(credentialRef(WORKBUDDY_SESSION_REF), serializeWorkBuddySession(session)); + } + return { ...session, sessionId: active.id, expiresAt: sessionCacheDeadline(session) }; + })().finally(() => { + loginSessionPromises.delete(key); + }); + loginSessionPromises.set(key, promise); + } + return promise; + }; + + const resolveCredential = async (provider, profile, context = requestContext.getStore()) => { + context ??= {}; + const ref = profile.apiKeyEnv; + const routing = WORKBUDDY_PROVIDERS.has(provider) ? await readSessionRouting() : createWorkBuddySessionRoutingState(); + const sessionId = context?.sessionId ? String(context.sessionId) : undefined; + let binding = sessionBindingFor(routing, sessionId); + if (WORKBUDDY_PROVIDERS.has(provider) && routing.enabled && sessionId && !binding) { + let fallbackBinding; + if (!routing.lastUsed) { + if (ref) fallbackBinding = { mode: "api-key", apiKeyRef: ref }; + else { + try { + const active = await resolveLoginSession(); + fallbackBinding = { mode: "token", accountId: active.sessionId }; + } catch (error) { + throw new LlmError(`${name}: 没有可用于当前会话的默认 WorkBuddy 凭证`, "MISSING_CREDENTIAL", { cause: error }); + } + } + } + try { + binding = await persistDefaultSessionBinding(sessionId, fallbackBinding); + } catch (error) { + throw new LlmError(`${name}: 无法保存当前会话的 WorkBuddy 凭证绑定`, "MISSING_CREDENTIAL", { cause: error }); + } + if (!binding) throw new LlmError(`${name}: 没有可用于当前会话的默认 WorkBuddy 凭证`, "MISSING_CREDENTIAL"); + } + if (WORKBUDDY_PROVIDERS.has(provider) && binding?.mode === "token") { + let session; + try { + session = await resolveLoginSession(binding.accountId); + } catch (error) { + throw new LlmError(`${name}: 当前会话绑定的 WorkBuddy 登录账号不可用`, "MISSING_CREDENTIAL", { cause: error }); + } + context.headers = { + ...(session.account.userId ? { "X-User-Id": session.account.userId } : {}), + ...(session.account.enterpriseId ? { "X-Enterprise-Id": session.account.enterpriseId, "X-Tenant-Id": session.account.enterpriseId } : {}), + ...(session.auth.domain ? { "X-Domain": session.auth.domain } : {}), + }; + return { value: assertUsableApiKey(session.auth.accessToken, name, "WorkBuddy login session"), kind: "bearer", sessionId: session.sessionId }; + } + if (WORKBUDDY_PROVIDERS.has(provider) && binding?.mode === "api-key") { + const bindingRef = binding.apiKeyRef; + if (!bindingRef) throw new LlmError(`${name}: 当前会话绑定的 API Key 引用无效`, "MISSING_CREDENTIAL"); + const stored = await ctx.get("credentials")?.resolve(credentialRef(bindingRef)); + const value = stored?.value ?? launchEnvironmentOf(ctx).get(credentialRef(bindingRef))?.value; + if (value) return { value: assertUsableApiKey(value, name, bindingRef), kind: "api-key", ref: bindingRef }; + throw new LlmError(`${name}: 当前会话绑定的 API Key 不可用`, "MISSING_CREDENTIAL"); + } + if (!ref && WORKBUDDY_PROVIDERS.has(provider)) { + let session; + try { + session = await resolveLoginSession(); + } catch (error) { + throw new LlmError(`${name}: 未找到可用的 WorkBuddy 登录令牌,请运行 dsh-llm-workbuddy login`, "MISSING_CREDENTIAL", { cause: error }); + } + context.headers = { + ...(session.account.userId ? { "X-User-Id": session.account.userId } : {}), + ...(session.account.enterpriseId ? { "X-Enterprise-Id": session.account.enterpriseId, "X-Tenant-Id": session.account.enterpriseId } : {}), + ...(session.auth.domain ? { "X-Domain": session.auth.domain } : {}), + }; + return { value: assertUsableApiKey(session.auth.accessToken, name, "WorkBuddy login session"), kind: "bearer", sessionId: session.sessionId }; + } + if (!ref) return { value: undefined, kind: "none" }; + const stored = await ctx.get("credentials")?.resolve(ref); + let value = stored?.value ?? launchEnvironmentOf(ctx).get(ref)?.value; + if (!value && ref === API_KEY_ENV) { + const legacyRef = credentialRef(LEGACY_API_KEY_ENV); + const legacyStored = await ctx.get("credentials")?.resolve(legacyRef); + value = legacyStored?.value ?? launchEnvironmentOf(ctx).get(legacyRef)?.value; + } + if (value) return { value: assertUsableApiKey(value, name, ref), kind: "api-key", ref }; + throw new LlmError(`${name}: Provider "${provider}" 缺少 API Key,请在 WebUI 的模型设置中填写`, "MISSING_CREDENTIAL"); + }; + + const resolveApiKey = async (provider, profile) => { + const trace = WORKBUDDY_PROVIDERS.has(provider) ? traceContext.getStore() : undefined; + const started = Date.now(); + traceEvent(trace, "auth.start"); + try { + const credential = await resolveCredential(provider, profile); + traceEvent(trace, "auth.done", { elapsedMs: Date.now() - started, mode: credential.kind }); + return credential.value; + } catch (error) { + traceEvent(trace, "auth.error", { elapsedMs: Date.now() - started, ...errorFields(error) }); + throw error; + } + }; + + const adapter = new PiAiAdapter({ + profiles, + resolveApiKey, + resolveAttachments: () => ctx.get("attachments"), + }); + const refreshPromises = new Map(); + const refreshWorkBuddyModels = async (provider, signal) => { + const profile = profiles().get(provider); + const credential = await resolveCredential(provider, profile); + const cacheKey = credential.kind === "bearer" ? `token:${credential.sessionId ?? "active"}` : `api:${credential.ref ?? API_KEY_ENV}`; + if (remoteModels && remoteModelsKey === cacheKey) return; + let pending = refreshPromises.get(cacheKey); + if (!pending) { + pending = (async () => { + remoteModels = await fetchWorkBuddyModels(credential, signal); + remoteModelsKey = cacheKey; + generation += 1; + })().finally(() => refreshPromises.delete(cacheKey)); + refreshPromises.set(cacheKey, pending); + } + return pending; + }; + const ensureWorkBuddyModel = async (provider, model, signal) => { + if (!WORKBUDDY_PROVIDERS.has(provider)) return; + if (profiles().get(provider)?.piProvider.getModels().some((entry) => entry.id === model)) return; + await refreshWorkBuddyModels(provider, signal); + }; + const sessionScopedStream = (stream, options) => { + const context = { sessionId: options?.sessionId === undefined ? undefined : String(options.sessionId), headers: {}, id: ++nextTraceId }; + const run = (action) => traceContext.run(context, () => requestContext.run(context, action)); + const source = run(() => stream(options)); + const iterator = source[Symbol.asyncIterator](); + return { + [Symbol.asyncIterator]() { return this; }, + next(value) { return run(() => iterator.next(value)); }, + return(value) { return run(() => iterator.return?.(value) ?? Promise.resolve({ done: true, value })); }, + throw(error) { return run(() => iterator.throw?.(error) ?? Promise.reject(error)); }, + }; + }; + const adapterStream = adapter.stream.bind(adapter); + const legacyAdapter = typeof adapter.prepareCall !== "function"; + const invokeAdapterStream = (options) => adapterStream(prepareWorkBuddyOptions(options, legacyAdapter)); + adapter.stream = (options) => sessionScopedStream(invokeAdapterStream, options); + // `prepareCall` was added after the DSH rc.6 adapter. Keep the direct + // `stream` path working on older hosts while wrapping prepared calls on + // newer hosts, whose runtime dispatches through the returned stream handle. + if (!legacyAdapter) { + const adapterPrepareCall = adapter.prepareCall.bind(adapter); + adapter.prepareCall = async (...args) => { + await ensureWorkBuddyModel(args[0], args[1], args[2]); + const prepared = await adapterPrepareCall(...args); + return { + ...prepared, + stream: (options) => sessionScopedStream((preparedOptions) => prepared.stream(prepareWorkBuddyOptions(preparedOptions, false)), options), + }; + }; + } else { + // DSH 0.1.5 calls prepareCall unconditionally, while the rc.6 pi-ai + // adapter shipped without it. Keep that older adapter usable by exposing + // the same prepared-call shape from its existing methods. + adapter.prepareCall = async (provider, model, signal) => ({ + model: await adapter.resolveModel(provider, model, signal), + stream: (options) => sessionScopedStream(invokeAdapterStream, options), + }); + } + const resolveModel = adapter.resolveModel.bind(adapter); + adapter.resolveModel = async (provider, model, signal) => { + await ensureWorkBuddyModel(provider, model, signal); + const resolved = await resolveModel(provider, model, signal); + if (!WORKBUDDY_PROVIDERS.has(provider) || !resolved.reasoning) return resolved; + const configured = profiles().get(provider)?.piProvider.getModels().find((entry) => entry.id === model); + const effort = configured?.defaultReasoningEffort; + if (!effort || !resolved.reasoning.efforts.some((entry) => entry.id === effort)) return resolved; + return { ...resolved, reasoning: { ...resolved.reasoning, defaultEffort: effort } }; + }; + const listModels = adapter.listModels.bind(adapter); + adapter.listModels = async (provider) => { + if (WORKBUDDY_PROVIDERS.has(provider)) { + try { + await refreshWorkBuddyModels(provider); + } catch { + // Keep the built-in catalog available while the key or network is absent. + } + } + return listModels(provider); + }; + + const workBuddyDirectory = () => [{ + provider: PROVIDER, + displayName: DISPLAY_NAME, + settingsNs, + settingsPath: ["providers", PROVIDER], + declared: false, + }]; + const directoryEntries = () => modernSettings ? workBuddyDirectory() : [...workBuddyDirectory(), ...[...builtins.values()].flatMap((provider) => provider.auth?.apiKey ? [{ + provider: provider.id, + displayName: provider.name, + settingsNs, + settingsPath: ["providers", provider.id], + declared: false, + }] : []), ...Object.entries(effectiveConfig().providers ?? {}).flatMap(([provider, source]) => { + if (WORKBUDDY_PROVIDERS.has(provider) || builtins.has(provider) || !genericProvider(provider, source)) return []; + return [{ + provider, + displayName: source.displayName ?? provider, + settingsNs, + settingsPath: ["providers", provider], + declared: true, + }]; + })]; + + let directory = ctx.llm.registerConfigurableProviders(directoryEntries()); + let registration = ctx.llm.registerAdapter([...profiles().keys()], adapter); + + ctx.llm.registerModelDiscovery(settingsNs, async (request, signal) => { + const discoverySignal = signal ?? request.signal; + if (WORKBUDDY_PROVIDERS.has(request.provider)) { + const profile = profiles().get(request.provider); + const credential = request.apiKey + ? { value: request.apiKey, kind: "api-key", ref: API_KEY_ENV } + : await resolveCredential(request.provider, profile); + remoteModels = await fetchWorkBuddyModels(credential, discoverySignal); + remoteModelsKey = credential.kind === "bearer" ? `token:${credential.sessionId ?? "active"}` : `api:${credential.ref ?? API_KEY_ENV}`; + generation += 1; + return remoteModels.map((model) => ({ + id: model.id, + name: model.name, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })); + } + const provider = builtins.get(request.provider); + if (!provider) return probeEndpoint({ ...request, signal: discoverySignal }, { profiles, resolveCredential }); + return provider.getModels().map((model) => ({ + id: model.id, + name: model.name, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })); + }); + + // Keep WorkBuddy out of the settings base layer so it appears in WebUI's + // "Add provider" dropdown. The runtime profile above still exists as the + // built-in implementation; selecting it only persists the credential ref. + const refreshRegistrations = () => { + memoRaw = undefined; + const providers = profiles(); + registration.replace([...providers.keys()]); + directory.replace(directoryEntries()); + }; + if (modernSettings) ctx.on("loader/volatile-update", refreshRegistrations); + installSettingsCompat(ctx, settingsNs, Config, config ?? { providers: {} }, { + setSource(source) { + current = source; + }, + onChange() { + refreshRegistrations(); + }, + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..41a9ac1 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "@axiaohungry/dsh-llm-workbuddy", + "version": "1.3.21-custom.1", + "description": "[个人定制版] 基于 @axiaohungry/dsh-llm-workbuddy 1.3.21:API Key 模式下在对话页显示剩余积分,可点击查看套餐与重置时间", + "author": "Axiaohungry (original) / customized by repo owner", + "keywords": [ + "deepseek-harness", + "dsh", + "workbuddy", + "llm", + "provider" + ], + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "bin": { + "dsh-llm-workbuddy": "cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Axiaohungry/dsh-llm-workbuddy.git" + }, + "homepage": "https://github.com/Axiaohungry/dsh-llm-workbuddy#readme", + "bugs": { + "url": "https://github.com/Axiaohungry/dsh-llm-workbuddy/issues" + }, + "files": [ + "index.js", + "workbuddy-discovery.js", + "workbuddy-auth.js", + "workbuddy-credits.js", + "workbuddy-web.js", + "client.js", + "cli.js", + "cordis.patch.yml", + "docs/反向代理调用WorkBuddy-API开发文档.md", + "README.md", + "LICENSE" + ], + "scripts": { + "check": "node --check index.js && node --check workbuddy-discovery.js && node --check workbuddy-auth.js && node --check workbuddy-credits.js && node --check workbuddy-web.js && node --check client.js && node --check cli.js && node --test test.js && node cli.js --self-test" + }, + "dsh": { + "engines": { + "dsh": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + }, + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@earendil-works/pi-ai": "^0.87.1", + "pnpm": "11.19.0", + "yaml": "2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-credentials": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2", + "@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2", + "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2", + "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2", + "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2", + "react": "^18.2.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/workbuddy-auth.js b/workbuddy-auth.js new file mode 100644 index 0000000..2d73657 --- /dev/null +++ b/workbuddy-auth.js @@ -0,0 +1,430 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +export const WORKBUDDY_SESSION_REF = "WORKBUDDY_LOGIN_SESSION"; +export const WORKBUDDY_SESSIONS_REF = "WORKBUDDY_LOGIN_SESSIONS"; +export const WORKBUDDY_SESSION_ROUTING_REF = "WORKBUDDY_SESSION_ROUTING"; +export const WORKBUDDY_API_KEYS_REF = "WORKBUDDY_API_KEYS"; +export const LEGACY_SESSION_REF = "CODEBUDDY_LOGIN_SESSION"; +export const LEGACY_SESSIONS_REF = "CODEBUDDY_LOGIN_SESSIONS"; +export const LEGACY_API_KEYS_REF = "CODEBUDDY_API_KEYS"; + +function normalizeWorkBuddySessionBinding(binding) { + if (!binding || typeof binding !== "object") return undefined; + if (binding.mode === "token" && typeof binding.accountId === "string" && binding.accountId.trim()) { + return { mode: "token", accountId: binding.accountId.trim() }; + } + if (binding.mode === "api-key" && typeof binding.apiKeyRef === "string" && binding.apiKeyRef.trim()) { + return { mode: "api-key", apiKeyRef: binding.apiKeyRef.trim() }; + } + return undefined; +} + +export function createWorkBuddySessionRoutingState(enabled = false, bindings = {}, lastUsed) { + const normalized = {}; + if (bindings && typeof bindings === "object" && !Array.isArray(bindings)) { + for (const [sessionId, binding] of Object.entries(bindings)) { + if (typeof sessionId !== "string" || !sessionId.trim() || !binding || typeof binding !== "object") continue; + const value = normalizeWorkBuddySessionBinding(binding); + if (value) normalized[sessionId] = value; + } + } + const recent = normalizeWorkBuddySessionBinding(lastUsed); + return { + version: 1, + enabled: enabled === true, + bindings: normalized, + ...(recent ? { lastUsed: recent } : {}), + }; +} + +export function serializeWorkBuddySessionRouting(state) { + return JSON.stringify(createWorkBuddySessionRoutingState(state?.enabled, state?.bindings, state?.lastUsed)); +} + +export function parseWorkBuddySessionRouting(value) { + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error("WorkBuddy 会话级认证配置已损坏,请重新设置", { cause: error }); + } + return createWorkBuddySessionRoutingState(parsed?.enabled, parsed?.bindings, parsed?.lastUsed); +} + +const BASE_URL = "https://copilot.tencent.com/v2/plugin"; +const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1"; +const REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": USER_AGENT, + "x-product": "SaaS", +}; +const NO_ACCOUNT_HEADERS = { + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", +}; +const NO_ID_HEADERS = { + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", +}; + +function calculateExpiresAt(auth, now = Date.now()) { + const result = { ...auth }; + if (!result.expiresAt && Number.isFinite(result.expiresIn)) result.expiresAt = now + result.expiresIn * 1000; + if (!result.refreshExpiresAt && Number.isFinite(result.refreshExpiresIn)) result.refreshExpiresAt = now + result.refreshExpiresIn * 1000; + return result; +} + +async function responseBody(response, action) { + try { + return await response.json(); + } catch (error) { + throw new Error(`${action}返回了无法解析的数据`, { cause: error }); + } +} + +async function request(path, options, action) { + let response; + try { + response = await fetch(`${BASE_URL}${path}`, options); + } catch (error) { + if (options.signal?.aborted) throw new Error(`${action}已取消`, { cause: error }); + throw new Error(`${action}无法连接 WorkBuddy 中国站`, { cause: error }); + } + const body = await responseBody(response, action); + if (!response.ok || body?.code !== 0) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`); + return body.data; +} + +function enterpriseHeaders(session) { + const enterpriseId = session.account?.enterpriseId; + return { + ...(enterpriseId ? { "X-Enterprise-Id": enterpriseId, "X-Tenant-Id": enterpriseId } : {}), + ...(session.auth?.domain ? { "X-Domain": session.auth.domain } : {}), + }; +} + +async function poll(path, headers, action, timeoutMs, signal) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await delay(1000, undefined, { signal }); + let response; + try { + response = await fetch(`${BASE_URL}${path}`, { headers: { ...REQUEST_HEADERS, ...headers }, signal }); + } catch (error) { + if (signal?.aborted) throw new Error(`${action}已取消`, { cause: error }); + continue; + } + const body = await responseBody(response, action); + if (response.ok && body?.code === 0 && body.data) return body.data; + if (response.status === 401 || response.status === 403) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`); + } + throw new Error(`${action}超时`); +} + +function openBrowser(url) { + const [command, args] = process.platform === "win32" + ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]] + : process.platform === "darwin" + ? ["open", [url]] + : ["xdg-open", [url]]; + return new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + child.once("error", reject); + }); +} + +function textValue(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return undefined; +} + +function tokenClaims(token) { + if (typeof token !== "string") return {}; + try { + const payload = token.split(".")[1]; + if (!payload) return {}; + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function normalizeApiKeyEntry(entry, now = Date.now()) { + const ref = textValue(entry?.ref); + if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) throw new Error("WorkBuddy API Key 引用无效"); + const id = textValue(entry?.id) ?? `dsh:${ref}`; + return { + id, + ref, + label: textValue(entry?.label) ?? "DSH 保存的 API Key", + createdAt: Number.isFinite(entry?.createdAt) ? entry.createdAt : now, + updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : now, + }; +} + +export function createWorkBuddyApiKeyStore(entries = [], activeId) { + const keys = []; + for (const entry of entries) { + const normalized = normalizeApiKeyEntry(entry); + if (!keys.some((item) => item.id === normalized.id || item.ref === normalized.ref)) keys.push(normalized); + } + const selected = activeId === null + ? null + : typeof activeId === "string" && keys.some((entry) => entry.id === activeId) ? activeId : keys[0]?.id; + return { version: 1, activeId: selected, entries: keys }; +} + +export function upsertWorkBuddyApiKey(store, entry, now = Date.now()) { + const current = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId); + const incoming = normalizeApiKeyEntry({ ...entry, updatedAt: now }, now); + const index = current.entries.findIndex((item) => item.id === incoming.id || item.ref === incoming.ref); + if (index >= 0) incoming.createdAt = current.entries[index].createdAt; + const entries = index >= 0 + ? current.entries.map((item, position) => position === index ? incoming : item) + : [...current.entries, incoming]; + return { version: 1, activeId: incoming.id, entries }; +} + +export function serializeWorkBuddyApiKeys(store) { + const normalized = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId); + return JSON.stringify(normalized); +} + +export function parseWorkBuddyApiKeys(value) { + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error("WorkBuddy API Key 列表已损坏,请重新配置", { cause: error }); + } + if (!Array.isArray(parsed?.entries)) throw new Error("WorkBuddy API Key 列表格式无效,请重新配置"); + return createWorkBuddyApiKeyStore(parsed.entries, parsed.activeId); +} + +export function workBuddyApiKeyEntries(store) { + return (store?.entries ?? []).map(({ id, ref, label, createdAt, updatedAt }) => ({ id, ref, label, createdAt, updatedAt })); +} + +function normalizeAccount(account, auth) { + // The account endpoint has returned both a flat object and wrapped objects + // across WorkBuddy versions. Keep all known wrappers in the lookup so a newly + // added account gets the same display metadata as an imported account. + const sources = [ + account, + account?.account, + account?.user, + account?.userInfo, + account?.profile, + account?.data, + ].filter((source) => source && typeof source === "object"); + const read = (...keys) => textValue(...sources.flatMap((source) => keys.map((key) => source[key]))); + const claims = tokenClaims(auth?.accessToken); + const userId = read("userId", "uid", "user_id", "id") ?? textValue(claims.userId, claims.uid, claims.user_id, claims.sub); + const enterpriseId = read("enterpriseId", "tenantId", "enterprise_id", "tenant_id") + ?? textValue(claims.enterpriseId, claims.tenantId, claims.enterprise_id, claims.tenant_id); + const email = read("email", "mail", "emailAddress") ?? textValue(claims.email, claims.mail); + const uin = read("uin", "phoneNumber", "phone", "mobile", "mobilePhone") + ?? textValue(claims.uin, claims.phoneNumber, claims.phone_number, claims.mobile); + const type = read("type", "accountType", "account_type"); + const displayName = read("displayName", "name", "nickname", "username", "accountName") + ?? uin + ?? textValue(claims.displayName, claims.name, claims.nickname, claims.username, claims.preferred_username) + ?? email; + return { + ...(userId ? { userId } : {}), + ...(enterpriseId ? { enterpriseId } : {}), + ...(email ? { email } : {}), + ...(uin ? { uin } : {}), + ...(type ? { type } : {}), + ...(displayName ? { displayName } : {}), + }; +} + +export function workBuddySessionId(session) { + const account = normalizeAccount(session?.account, session?.auth); + if (account.userId) return `user:${account.userId}`; + if (account.email) return `email:${account.email}`; + if (account.enterpriseId) return `enterprise:${account.enterpriseId}`; + const refreshToken = session?.auth?.refreshToken ?? session?.auth?.accessToken; + if (!refreshToken) throw new Error("WorkBuddy 登录会话缺少账号标识和令牌"); + return `token:${createHash("sha256").update(refreshToken).digest("hex")}`; +} + +export function workBuddySessionLabel(session) { + const account = normalizeAccount(session?.account, session?.auth); + return account.displayName ?? account.email ?? account.userId ?? account.enterpriseId ?? `账号 ${workBuddySessionId(session).slice(-8)}`; +} + +export function normalizeWorkBuddySessionEntry(session, now = Date.now()) { + const normalized = { + auth: calculateExpiresAt(session?.auth), + account: normalizeAccount(session?.account, session?.auth), + }; + if (!normalized.auth.accessToken || !normalized.auth.refreshToken) throw new Error("WorkBuddy 登录会话无效"); + const id = typeof session?.id === "string" && session.id.trim() ? session.id : workBuddySessionId(normalized); + const createdAt = Number.isFinite(session?.createdAt) ? session.createdAt : now; + return { + id, + label: typeof session?.label === "string" && session.label.trim() ? session.label : workBuddySessionLabel(normalized), + createdAt, + updatedAt: Number.isFinite(session?.updatedAt) ? session.updatedAt : now, + ...normalized, + }; +} + +export function createWorkBuddySessionStore(entries = [], activeId) { + const sessions = []; + for (const entry of entries) { + const normalized = normalizeWorkBuddySessionEntry(entry); + if (!sessions.some((item) => item.id === normalized.id)) sessions.push(normalized); + } + const selected = typeof activeId === "string" && sessions.some((entry) => entry.id === activeId) ? activeId : sessions[0]?.id; + return { version: 1, activeId: selected, sessions }; +} + +export function upsertWorkBuddySession(store, session, now = Date.now()) { + const current = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId); + const incoming = normalizeWorkBuddySessionEntry({ ...session, updatedAt: now }, now); + const index = current.sessions.findIndex((entry) => entry.id === incoming.id); + if (index >= 0) incoming.createdAt = current.sessions[index].createdAt; + const sessions = index >= 0 + ? current.sessions.map((entry, position) => position === index ? incoming : entry) + : [...current.sessions, incoming]; + return { version: 1, activeId: incoming.id, sessions }; +} + +export function activeWorkBuddySession(store) { + return store?.sessions?.find((entry) => entry.id === store.activeId) ?? store?.sessions?.[0]; +} + +export function workBuddySessionAccounts(store) { + return (store?.sessions ?? []).map(({ id, label, account, createdAt, updatedAt }) => ({ + id, + label, + accountName: label, + userId: account?.userId ?? null, + account, + createdAt, + updatedAt, + })); +} + +export async function loginWorkBuddy(onAuthUrl, signal) { + const state = await request("/auth/state?platform=CLI", { + method: "POST", + headers: { ...REQUEST_HEADERS, ...NO_ACCOUNT_HEADERS }, + body: "{}", + signal, + }, "创建 WorkBuddy 登录会话"); + if (!state?.state || !state?.authUrl) throw new Error("WorkBuddy 登录接口没有返回登录地址"); + try { + await openBrowser(state.authUrl); + onAuthUrl?.(state.authUrl, true); + } catch { + onAuthUrl?.(state.authUrl, false); + } + const auth = calculateExpiresAt(await poll( + `/auth/token?state=${encodeURIComponent(state.state)}`, + NO_ACCOUNT_HEADERS, + "等待 WorkBuddy 登录", + 10 * 60_000, + signal, + )); + if (!auth.accessToken || !auth.refreshToken) throw new Error("WorkBuddy 登录接口没有返回完整令牌"); + const account = await poll( + `/login/account?state=${encodeURIComponent(state.state)}`, + { ...enterpriseHeaders({ auth }), authorization: `Bearer ${auth.accessToken}`, ...NO_ID_HEADERS }, + "获取 WorkBuddy 账号", + 60_000, + signal, + ); + return { auth, account: normalizeAccount(account, auth) }; +} + +export async function refreshWorkBuddySession(session, signal) { + if (!session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话缺少刷新令牌,请重新登录"); + const auth = await request("/auth/token/refresh", { + method: "POST", + headers: { + ...REQUEST_HEADERS, + ...enterpriseHeaders(session), + "X-Refresh-Token": session.auth.refreshToken, + "X-Auth-Refresh-Source": "plugin", + }, + body: "{}", + signal, + }, "刷新 WorkBuddy 登录令牌"); + const fresh = calculateExpiresAt(auth); + const merged = { ...session.auth, ...fresh, refreshToken: fresh?.refreshToken ?? session.auth.refreshToken }; + if (!merged.accessToken) throw new Error("WorkBuddy 刷新接口没有返回访问令牌"); + return { auth: merged, account: normalizeAccount(session.account, merged) }; +} + +export function serializeWorkBuddySession(session) { + if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话无效"); + return JSON.stringify({ auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) }); +} + +export function serializeWorkBuddySessions(store) { + const normalized = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId); + if (normalized.sessions.length === 0) throw new Error("WorkBuddy 登录账号列表为空"); + return JSON.stringify(normalized); +} + +export function parseWorkBuddySession(value) { + let session; + try { + session = JSON.parse(value); + } catch (error) { + throw new Error("WorkBuddy 登录凭据已损坏,请重新登录", { cause: error }); + } + if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录凭据不完整,请重新登录"); + return { auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) }; +} + +export function parseWorkBuddySessions(value) { + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error("WorkBuddy 登录账号列表已损坏,请重新登录", { cause: error }); + } + if (Array.isArray(parsed?.sessions)) return createWorkBuddySessionStore(parsed.sessions, parsed.activeId); + if (parsed?.auth) { + const session = parseWorkBuddySession(value); + return createWorkBuddySessionStore([session], workBuddySessionId(session)); + } + throw new Error("WorkBuddy 登录账号列表格式无效,请重新登录"); +} + +export function sessionNeedsRefresh(session, now = Date.now()) { + const expiresAt = Number(session?.auth?.expiresAt); + if (Number.isFinite(expiresAt)) return expiresAt <= now + 2 * 60_000; + try { + const payload = JSON.parse(Buffer.from(session.auth.accessToken.split(".")[1], "base64url").toString("utf8")); + return Number.isFinite(payload.exp) ? payload.exp * 1000 <= now + 2 * 60_000 : true; + } catch { + return true; + } +} + +export function sessionCacheDeadline(session, now = Date.now()) { + const expiresAt = Number(session?.auth?.expiresAt); + return Number.isFinite(expiresAt) + ? Math.max(now, Math.min(expiresAt - 2 * 60_000, now + 30 * 60_000)) + : now + 5 * 60_000; +} diff --git a/workbuddy-credits.js b/workbuddy-credits.js new file mode 100644 index 0000000..79dfe49 --- /dev/null +++ b/workbuddy-credits.js @@ -0,0 +1,448 @@ +const DEFAULT_BILLING_HOST = "https://www.codebuddy.cn"; +const BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"; +const ENTERPRISE_EDITIONS = new Set(["ultimate", "exclusive"]); +const REMAINING_FIELDS = [ + "SlicePeriodCapacityRemainPrecise", + "SlicePeriodCapacityRemain", + "CycleCapacityRemainPrecise", + "CycleCapacityRemain", + "CapacityRemainPrecise", + "CapacityRemain", + "RemainPrecise", + "Remain", + "Remaining", + "Balance", +]; +const TOTAL_FIELDS = [ + "SlicePeriodCapacitySizePrecise", + "SlicePeriodCapacitySize", + "CycleCapacitySizePrecise", + "CycleCapacitySize", + "CycleCapacityPrecise", + "CycleCapacity", + "CapacityPrecise", + "Capacity", + "TotalCapacityPrecise", + "TotalCapacity", + "PackageCapacity", + "Quota", + "Amount", +]; +const EXPIRY_FIELDS = [ + "DeductionEndTime", + "ExpiredTime", + "SlicePeriodEndTime", + "PackageEndTime", + "EndTime", + "CycleEndTime", + "ExpireTime", + "ExpirationTime", + "ValidEndTime", + "ValidPeriodEndTime", + "EndAt", + "ExpireAt", +]; +const LABEL_FIELDS = ["PackageName", "PackageTypeName", "AccountName", "ProductName", "Name", "RuleName", "Description"]; +const MAX_USAGE_PAGES = 100; +const PAGE_SIZE = 100; + +function normalizeHost(value) { + const fallback = new URL(DEFAULT_BILLING_HOST); + if (typeof value !== "string" || !value.trim()) return fallback.origin; + try { + const candidate = new URL(value.includes("://") ? value : `https://${value}`); + if (candidate.protocol !== "https:") return fallback.origin; + const host = candidate.hostname.toLowerCase(); + if (!["codebuddy.cn", "www.codebuddy.cn", "workbuddy.cn", "www.workbuddy.cn"].includes(host)) return fallback.origin; + return candidate.origin; + } catch { + return fallback.origin; + } +} + +function billingHost(session) { + return normalizeHost(session?.auth?.domain); +} + +function authToken(session) { + const token = typeof session?.auth?.accessToken === "string" ? session.auth.accessToken.trim() : ""; + if (!token) throw new Error("WorkBuddy 登录令牌为空"); + return token; +} + +function billingHeaders(session, host, enterpriseId) { + const headers = { + accept: "application/json, text/plain, */*", + "content-type": "application/json", + "x-client-platform": "web", + origin: host, + referer: `${host}/profile/plans-usage`, + authorization: `Bearer ${authToken(session)}`, + "user-agent": BROWSER_USER_AGENT, + }; + const domain = typeof session?.auth?.domain === "string" ? session.auth.domain.trim() : ""; + if (domain) headers["x-domain"] = domain; + const userId = typeof session?.account?.userId === "string" ? session.account.userId.trim() : ""; + if (userId) headers["x-user-id"] = userId; + if (enterpriseId) { + headers["x-enterprise-id"] = String(enterpriseId); + headers["x-tenant-id"] = String(enterpriseId); + } + return headers; +} + +function timeoutSignal(timeoutMs, externalSignal) { + if (externalSignal) return externalSignal; + if (typeof AbortSignal?.timeout === "function") return AbortSignal.timeout(timeoutMs); + const controller = new AbortController(); + setTimeout(() => controller.abort(), timeoutMs).unref?.(); + return controller.signal; +} + +async function readJson(response, action) { + const raw = await response.text(); + if (!raw.trim()) throw new Error(`${action}返回空响应(HTTP ${response.status})`); + let payload; + try { + payload = JSON.parse(raw); + } catch (error) { + throw new Error(`${action}返回了无法解析的数据`, { cause: error }); + } + if (!response.ok) throw new Error(`${action} HTTP ${response.status}: ${raw.slice(0, 160)}`); + if (payload?.code !== undefined && payload.code !== null && payload.code !== 0) { + throw new Error(`${action}失败(${payload.msg ?? payload.message ?? `code=${payload.code}`})`); + } + return payload; +} + +function firstNumber(value, fields) { + for (const field of fields) { + const raw = value?.[field]; + if (raw === undefined || raw === null || raw === "") continue; + const number = Number(raw); + if (Number.isFinite(number)) return number; + } + return null; +} + +function parseTimestamp(value) { + if (value === undefined || value === null || value === "") return null; + if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) { + const number = Number(value); + if (!Number.isFinite(number)) return null; + return number < 1e12 ? Math.round(number * 1000) : Math.round(number); + } + const parsed = Date.parse(String(value).replace(/^(\d{4}-\d\d-\d\d)\s+/, "$1T")); + return Number.isFinite(parsed) ? parsed : null; +} + +function firstTimestamp(value, fields) { + for (const field of fields) { + const parsed = parseTimestamp(value?.[field]); + if (parsed !== null) return parsed; + } + return null; +} + +function firstText(value, fields) { + for (const field of fields) { + const text = value?.[field]; + if (typeof text === "string" && text.trim()) return text.trim(); + } + return ""; +} + +function extractAccounts(payload) { + const candidates = [ + payload?.data?.Response?.Data?.Accounts, + payload?.data?.data?.Response?.Data?.Accounts, + payload?.data?.accounts, + payload?.data?.data?.accounts, + payload?.Response?.Data?.Accounts, + ]; + return candidates.find(Array.isArray) ?? []; +} + +function extractCreditSegments(accounts, source = "积分") { + return (Array.isArray(accounts) ? accounts : []) + .flatMap((account) => { + const details = Array.isArray(account?.SlicePeriodUsageDetails) && account.SlicePeriodUsageDetails.length + ? account.SlicePeriodUsageDetails.map((detail) => ({ ...account, ...detail })) + : [account]; + return details.map((item) => { + const remaining = firstNumber(item, REMAINING_FIELDS); + if (remaining === null || remaining <= 0) return null; + const total = firstNumber(item, TOTAL_FIELDS); + return { + remaining: Number(remaining.toFixed(2)), + total: Number((total === null ? remaining : Math.max(total, remaining)).toFixed(2)), + expiresAt: firstTimestamp(item, EXPIRY_FIELDS), + source: firstText(item, LABEL_FIELDS) || source, + packageCode: item?.PackageCode ? String(item.PackageCode) : "", + }; + }); + }) + .filter(Boolean); +} + +function sortSegments(segments) { + return (Array.isArray(segments) ? segments : []) + .filter((segment) => segment && Number(segment.remaining) > 0) + .map((segment) => ({ + remaining: Number(Number(segment.remaining).toFixed(2)), + total: Number(Number(segment.total || segment.remaining).toFixed(2)), + expiresAt: segment.expiresAt === null || segment.expiresAt === undefined ? null : Number(segment.expiresAt), + source: String(segment.source || "积分"), + packageCode: String(segment.packageCode || ""), + })) + .sort((left, right) => { + if (left.expiresAt === null && right.expiresAt !== null) return 1; + if (left.expiresAt !== null && right.expiresAt === null) return -1; + return (left.expiresAt || 0) - (right.expiresAt || 0); + }); +} + +function mergeSegments(segments) { + const merged = new Map(); + for (const segment of Array.isArray(segments) ? segments : []) { + if (!segment || Number(segment.remaining) <= 0) continue; + const key = [segment.packageCode || segment.source || "积分", segment.expiresAt ?? "unknown"].join("|"); + const previous = merged.get(key); + if (previous) { + previous.remaining += Number(segment.remaining) || 0; + previous.total += Number(segment.total || segment.remaining) || 0; + } else { + merged.set(key, { + remaining: Number(segment.remaining) || 0, + total: Number(segment.total || segment.remaining) || 0, + expiresAt: segment.expiresAt === undefined ? null : segment.expiresAt, + source: String(segment.source || "积分"), + packageCode: String(segment.packageCode || ""), + }); + } + } + return sortSegments(Array.from(merged.values())); +} + +function buildCreditResourceBody(now = new Date()) { + const end = new Date(now.getTime()); + end.setFullYear(end.getFullYear() + 101); + const format = (date) => { + const pad = (value) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + }; + return { + PageNumber: 1, + PageSize: 100, + ProductCode: "p_tcaca", + Status: [0, 3], + PackageEndTimeRangeBegin: format(now), + PackageEndTimeRangeEnd: format(end), + }; +} + +async function postJson(url, session, body, action, options = {}) { + const response = await (options.fetchImpl || globalThis.fetch)(url, { + method: "POST", + headers: billingHeaders(session, options.host, options.enterpriseId), + body: JSON.stringify(body), + signal: timeoutSignal(options.timeoutMs ?? 12_000, options.signal), + }); + return readJson(response, action); +} + +function enterpriseUsage(payload) { + const candidates = [payload?.data, payload?.data?.data, payload?.data?.Response?.Data, payload]; + const data = candidates.find((value) => value && typeof value === "object" && ("limitNum" in value || "LimitNum" in value)); + if (!data) return null; + const limitNum = Number(data.limitNum ?? data.LimitNum); + if (!Number.isFinite(limitNum)) return null; + const reset = firstTimestamp(data, ["cycleResetTime", "CycleResetTime", "CycleResetTimeMs"]); + if (limitNum === -1) return { unlimited: true, credits: null, total: null, count: 0, segments: [], cycleResetTime: reset }; + const credit = Number(data.credit ?? data.Credit); + const used = Number.isFinite(credit) ? credit : 0; + const credits = Math.max(0, limitNum - used); + return { + unlimited: false, + credits: Number(credits.toFixed(2)), + total: Number(limitNum.toFixed(2)), + count: 1, + segments: sortSegments([{ remaining: credits, total: limitNum, expiresAt: reset, source: "企业配额" }]), + cycleResetTime: reset, + }; +} + +async function queryPersonalCredits(session, host, options = {}) { + const payload = await postJson(`${host}/v2/billing/meter/get-user-resource`, session, buildCreditResourceBody(), "WorkBuddy 积分接口", { ...options, host }); + const accounts = extractAccounts(payload); + let credits = 0; + for (const account of accounts) { + const remaining = firstNumber(account, [ + "CycleCapacityRemainPrecise", + "CycleCapacityRemain", + "CapacityRemainPrecise", + "CapacityRemain", + ]); + if (remaining !== null) credits += remaining; + } + // [自用定制 2026-10-10] 提取会员套餐:账单里 CapacityType=2 是套餐/会员类, + // CapacityType=1 是签到/活动/兑换码赠送的积分包。 + const planCandidates = accounts.filter((account) => String(account?.CapacityType) === "2"); + const plan = planCandidates.find((account) => /高级版|标准版|旗舰版|专业版/.test(String(account?.PackageName || ""))) + ?? planCandidates[0]; + return { + credits: Number(credits.toFixed(2)), + count: accounts.length, + totalDosage: payload?.data?.Response?.Data?.TotalDosage ?? payload?.data?.data?.Response?.Data?.TotalDosage ?? null, + segments: mergeSegments(extractCreditSegments(accounts)), + unlimited: false, + cycleResetTime: null, + planName: plan?.PackageName ?? null, + planCycleEnd: plan?.CycleEndTime ?? null, + }; +} + +async function resolveEnterpriseId(session, host, options = {}) { + const uid = session?.account?.userId; + if (!uid) return ""; + const response = await (options.fetchImpl || globalThis.fetch)(`${host}/console/accounts`, { + method: "GET", + headers: billingHeaders(session, host), + signal: timeoutSignal(8_000, options.signal), + }); + const payload = await readJson(response, "WorkBuddy 企业账号接口"); + const accounts = payload?.data?.accounts; + const first = Array.isArray(accounts) ? accounts[0] : undefined; + return typeof first?.enterpriseId === "string" ? first.enterpriseId.trim() : ""; +} + +async function queryEnterpriseCredits(session, host, enterpriseId, options = {}) { + const payload = await postJson(`${host}/v2/billing/meter/get-enterprise-user-usage`, session, {}, "WorkBuddy 企业积分接口", { + ...options, + host, + enterpriseId, + }); + const parsed = enterpriseUsage(payload); + if (!parsed) throw new Error("WorkBuddy 企业积分接口返回数据无法解析"); + return parsed; +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +function formatLocalDateTime(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; +} + +function localDateString(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function parseUsageTime(value) { + return parseTimestamp(value); +} + +function usageRows(payload) { + const data = payload?.data; + const rows = data && Array.isArray(data.data) ? data.data : data && Array.isArray(data.rows) ? data.rows : []; + const total = Number(data?.total); + return { rows, total: Number.isSafeInteger(total) && total >= 0 ? total : rows.length }; +} + +async function queryTodayUsage(session, host, options = {}) { + const now = new Date(); + const start = new Date(now.getTime()); + start.setHours(0, 0, 0, 0); + const fetchImpl = options.fetchImpl || globalThis.fetch; + const records = []; + let expectedTotal = null; + let fetched = 0; + for (let pageNum = 1; pageNum <= MAX_USAGE_PAGES; pageNum += 1) { + const payload = await postJson(`${host}/billing/meter/get-user-request-usage`, session, { + startTime: formatLocalDateTime(start), + endTime: formatLocalDateTime(now), + pageNum, + pageSize: PAGE_SIZE, + }, "WorkBuddy 今日请求量接口", { ...options, host, fetchImpl, timeoutMs: options.usageTimeoutMs ?? 8_000 }); + const page = usageRows(payload); + if (expectedTotal === null) expectedTotal = page.total; + if (!page.rows.length) break; + for (const row of page.rows) { + const requestTime = parseUsageTime(row?.requestTime ?? row?.RequestTime ?? row?.createdAt); + const credit = Number(row?.credit ?? row?.Credit ?? 0); + if (requestTime === null || !Number.isFinite(credit) || credit < 0) continue; + records.push({ requestTime, credit }); + } + fetched += page.rows.length; + if (fetched >= expectedTotal || page.rows.length < PAGE_SIZE) break; + } + const used = records.reduce((sum, record) => sum + record.credit, 0); + return { + date: localDateString(now), + used: Number(used.toFixed(2)), + count: records.length, + synced: true, + }; +} + +async function retry(task, attempts = 2) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt)); + } + } + throw lastError; +} + +export async function fetchWorkBuddyCredits(session, options = {}) { + const host = billingHost(session); + const account = session?.account && typeof session.account === "object" ? session.account : {}; + let creditResult; + let creditError = null; + try { + const enterpriseId = typeof account.enterpriseId === "string" ? account.enterpriseId.trim() : ""; + const enterpriseEdition = typeof account.type === "string" && ENTERPRISE_EDITIONS.has(account.type.toLowerCase()); + if (enterpriseId || enterpriseEdition) { + const resolvedId = enterpriseId || await retry(() => resolveEnterpriseId(session, host, options)); + if (!resolvedId) throw new Error("企业账号缺少 enterpriseId"); + creditResult = await retry(() => queryEnterpriseCredits(session, host, resolvedId, options)); + } else { + creditResult = await retry(() => queryPersonalCredits(session, host, options)); + } + } catch (error) { + creditError = error instanceof Error ? error.message : String(error); + creditResult = { credits: null, count: 0, totalDosage: null, segments: [], unlimited: false, cycleResetTime: null }; + } + + let todayUsage; + let todayUsageError = null; + try { + todayUsage = await retry(() => queryTodayUsage(session, host, options)); + } catch (error) { + todayUsageError = error instanceof Error ? error.message : String(error); + } + return { + ...creditResult, + creditError, + todayUsage: todayUsage ?? null, + todayUsageError, + }; +} + +export const __testing = Object.freeze({ + billingHost, + buildCreditResourceBody, + enterpriseUsage, + extractAccounts, + extractCreditSegments, + formatLocalDateTime, + mergeSegments, + normalizeHost, + queryTodayUsage, + sortSegments, +}); diff --git a/workbuddy-discovery.js b/workbuddy-discovery.js new file mode 100644 index 0000000..89efed5 --- /dev/null +++ b/workbuddy-discovery.js @@ -0,0 +1,113 @@ +import { LlmError } from "@deepseek-ai/dsh-llm"; + +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const OPENAI_APIS = new Set(["openai-completions", "openai-responses"]); + +function listingUrl(baseURL, api) { + const base = baseURL.replace(/\/+$/, ""); + return api === "anthropic-messages" + ? `${base.endsWith("/v1") ? base : `${base}/v1`}/models?limit=1000` + : `${base}/models`; +} + +function positive(...values) { + return values.find((value) => Number.isSafeInteger(value) && value > 0); +} + +function nonempty(...values) { + return values.find((value) => typeof value === "string" && value.trim())?.trim(); +} + +async function readBounded(response) { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + await response.body?.cancel(); + throw new LlmError("模型目录响应超过 4 MiB", "DISCOVERY_FAILED"); + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) throw new LlmError("模型目录响应超过 4 MiB", "DISCOVERY_FAILED"); + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +function modelEntries(body) { + if (Array.isArray(body?.data)) return body.data; + if (body?.models && typeof body.models === "object" && !Array.isArray(body.models)) { + return Object.entries(body.models).flatMap(([id, value]) => + typeof value === "object" && value !== null && !Array.isArray(value) ? [{ ...value, id }] : []); + } + throw new LlmError("端点未返回可识别的模型列表,请手工填写模型", "DISCOVERY_FAILED"); +} + +export async function probeEndpoint(request, { profiles, resolveCredential }) { + const profile = request.provider ? profiles().get(request.provider) : undefined; + const baseURL = nonempty(request.baseURL, profile?.baseURL); + if (!baseURL) throw new LlmError(`Provider "${request.provider ?? ""}" 缺少 baseURL,请填写 API 地址或手工填写模型`, "DISCOVERY_FAILED"); + const api = request.api ?? "openai-completions"; + if (!OPENAI_APIS.has(api) && api !== "anthropic-messages") { + throw new LlmError(`协议 "${api}" 不支持自动获取模型,请手工填写`, "DISCOVERY_UNSUPPORTED"); + } + const key = request.apiKey || (profile ? (await resolveCredential(request.provider, profile)).value : undefined); + const headers = new Headers(profile?.headers === undefined ? undefined : Object.entries(profile.headers)); + headers.set("accept", "application/json"); + if (key) { + const value = String(key).trim(); + if (!value || /[\r\n]/.test(value)) throw new LlmError("API Key 包含无效字符", "INVALID_CREDENTIAL"); + if (api === "anthropic-messages") { + headers.delete("authorization"); + headers.set("x-api-key", value); + } else { + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${value}`); + } + } + if (api === "anthropic-messages") headers.set("anthropic-version", "2023-06-01"); + const url = listingUrl(baseURL, api); + let response; + try { + response = await fetch(url, { headers, signal: request.signal }); + } catch (error) { + if (request.signal?.aborted) throw new LlmError("模型列表获取已取消", "ABORTED", { cause: error }); + throw new LlmError("无法连接模型目录端点", "DISCOVERY_FAILED", { cause: error }); + } + if (!response.ok) throw new LlmError(`模型目录端点返回 HTTP ${response.status}`, "DISCOVERY_FAILED"); + let body; + try { + body = JSON.parse(await readBounded(response)); + } catch (error) { + if (error instanceof LlmError) throw error; + if (request.signal?.aborted) throw new LlmError("模型列表获取已取消", "ABORTED", { cause: error }); + throw new LlmError("模型目录端点未返回有效 JSON", "DISCOVERY_FAILED", { cause: error }); + } + return modelEntries(body).flatMap((raw) => { + const id = nonempty(raw?.id); + if (!id) return []; + const name = nonempty(raw.name, raw.display_name, raw.displayName); + const contextWindow = positive(raw.contextWindow, raw.context_window, raw.context_length, raw.max_input_tokens, raw.limit?.context); + const maxTokens = positive(raw.maxTokens, raw.max_output_tokens, raw.max_tokens, raw.top_provider?.max_completion_tokens); + return [{ + id, + ...(name ? { name } : {}), + ...(contextWindow ? { contextWindow } : {}), + ...(maxTokens ? { maxTokens } : {}), + }]; + }); +} diff --git a/workbuddy-web.js b/workbuddy-web.js new file mode 100644 index 0000000..401b016 --- /dev/null +++ b/workbuddy-web.js @@ -0,0 +1,735 @@ +import { randomBytes } from "node:crypto"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import { + WORKBUDDY_API_KEYS_REF, + WORKBUDDY_SESSION_REF, + WORKBUDDY_SESSIONS_REF, + WORKBUDDY_SESSION_ROUTING_REF, + LEGACY_API_KEYS_REF, + LEGACY_SESSION_REF, + LEGACY_SESSIONS_REF, + activeWorkBuddySession, + workBuddyApiKeyEntries, + workBuddySessionAccounts, + createWorkBuddyApiKeyStore, + createWorkBuddySessionStore, + createWorkBuddySessionRoutingState, + parseWorkBuddyApiKeys, + loginWorkBuddy, + parseWorkBuddySession, + parseWorkBuddySessions, + refreshWorkBuddySession, + serializeWorkBuddyApiKeys, + serializeWorkBuddySession, + serializeWorkBuddySessions, + serializeWorkBuddySessionRouting, + parseWorkBuddySessionRouting, + sessionNeedsRefresh, + upsertWorkBuddyApiKey, + upsertWorkBuddySession, +} from "./workbuddy-auth.js"; +import { fetchWorkBuddyCredits } from "./workbuddy-credits.js"; + +const PROVIDER = "workbuddy-cn"; +const LEGACY_PROVIDER = "codebuddy-cn"; +const API_KEY_ENV = "WORKBUDDY_API_KEY"; +const LEGACY_API_KEY_ENV = "CODEBUDDY_API_KEY"; +const ROUTE = "/dsh-llm-workbuddy/auth"; +const ENV_SOURCES = new Set(["env", "user-env", "project-env"]); + +export function authenticationMode(config) { + const profile = config?.providers?.[PROVIDER] ?? config?.providers?.[LEGACY_PROVIDER]; + return profile && profile.apiKeyEnv === undefined ? "token" : "api-key"; +} + +function json(res, status, body) { + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + res.end(JSON.stringify(body)); +} + +function localPost(req) { + const address = req.socket.remoteAddress; + const loopback = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; + if (!loopback) return false; + const origin = req.headers.origin; + if (!origin) return req.headers["sec-fetch-site"] === "same-origin" + || /(?:^|\s)@deepseek-ai\/dsh-desktop\/\d/.test(req.headers["user-agent"] ?? ""); + if (origin === "dsh-app://app") return true; + try { + return ["127.0.0.1", "localhost", "[::1]"].includes(new URL(origin).hostname); + } catch { + return false; + } +} + +async function requestBody(req) { + let raw = ""; + for await (const chunk of req) { + raw += chunk.toString(); + if (raw.length > 64 * 1024) throw new Error("请求体过大"); + } + if (!raw.trim()) return {}; + try { + const value = JSON.parse(raw); + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } catch { + throw new Error("请求参数格式无效"); + } +} + +async function setMode(settings, mode, apiKeyRef = API_KEY_ENV) { + const config = settings.get("llm-pi-ai"); + const providers = config?.providers ?? {}; + const exists = Object.hasOwn(providers, PROVIDER); + const legacy = !exists && Object.hasOwn(providers, LEGACY_PROVIDER) ? providers[LEGACY_PROVIDER] : undefined; + const path = ["providers", PROVIDER]; + if (!exists) { + const value = { ...(legacy ?? {}), ...(mode === "token" ? {} : { apiKeyEnv: apiKeyRef }) }; + await settings.mutate("llm-pi-ai", [ + { op: "set", path, value }, + ...(legacy ? [{ op: "unset", path: ["providers", LEGACY_PROVIDER] }] : []), + ]); + return; + } + await settings.mutate("llm-pi-ai", [ + { + op: mode === "token" ? "unset" : "set", + path: [...path, "apiKeyEnv"], + ...(mode === "api-key" ? { value: apiKeyRef } : {}), + }, + ...(Object.hasOwn(providers, LEGACY_PROVIDER) ? [{ op: "unset", path: ["providers", LEGACY_PROVIDER] }] : []), + ]); +} + +function configuredApiKeyRef(settings) { + const providers = settings.get("llm-pi-ai")?.providers ?? {}; + return providers[PROVIDER]?.apiKeyEnv ?? providers[LEGACY_PROVIDER]?.apiKeyEnv ?? API_KEY_ENV; +} + +function settingsAccess(service, settingsNs) { + return { + get: () => typeof service.get === "function" + ? service.get(settingsNs) + : service.describe().find((entry) => entry.ns === settingsNs)?.value, + revision: () => typeof service.get !== "function" && typeof service.describe === "function" + ? service.describe().find((entry) => entry.ns === settingsNs)?.revision + : undefined, + mutate: (_ns, ops, revision) => service.mutate(settingsNs, ops, revision), + }; +} + +function validModelOverrides(models) { + if (!Array.isArray(models) || models.length > 100) return false; + const ids = new Set(); + for (const model of models) { + if (!model || typeof model !== "object" || Array.isArray(model) + || typeof model.id !== "string" || !model.id.trim() || model.id.length > 200 + || ids.has(model.id)) return false; + ids.add(model.id); + if (model.name !== undefined && (typeof model.name !== "string" || model.name.length > 200)) return false; + for (const key of ["contextWindow", "maxTokens"]) { + if (model[key] !== undefined && (!Number.isSafeInteger(model[key]) || model[key] <= 0)) return false; + } + if (model.input !== undefined && (!Array.isArray(model.input) + || model.input.some((value) => value !== "text" && value !== "image"))) return false; + } + return true; +} + +export const __testing = Object.freeze({ settingsAccess, setMode, validModelOverrides, localPost }); + +function maskApiKey(value) { + const text = typeof value === "string" ? value : ""; + return text.length > 4 ? `••••${text.slice(-4)}` : text ? "••••" : ""; +} + +function textLabel(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function storedApiKeyRef() { + return `WORKBUDDY_API_KEY_DSH_${Date.now().toString(36).toUpperCase()}_${randomBytes(6).toString("hex").toUpperCase()}`; +} + +async function readApiKeyStore(credentials) { + for (const ref of [WORKBUDDY_API_KEYS_REF, LEGACY_API_KEYS_REF]) { + const stored = await credentials.resolve(credentialRef(ref)); + if (stored?.value) return parseWorkBuddyApiKeys(stored.value); + } + return createWorkBuddyApiKeyStore(); +} + +async function writeApiKeyStore(credentials, store) { + const normalized = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId); + if (normalized.entries.length === 0) { + await credentials.unset(credentialRef(WORKBUDDY_API_KEYS_REF)); + await credentials.unset(credentialRef(LEGACY_API_KEYS_REF)); + return; + } + await credentials.set(credentialRef(WORKBUDDY_API_KEYS_REF), serializeWorkBuddyApiKeys(normalized)); + await credentials.unset(credentialRef(LEGACY_API_KEYS_REF)); +} + +async function currentApiKeyState(webCtx, settings) { + const store = await readApiKeyStore(webCtx.credentials); + const apiMode = authenticationMode(settings.get("llm-pi-ai")) === "api-key"; + const ref = apiMode ? configuredApiKeyRef(settings) : undefined; + const items = []; + const seenRefs = new Set(); + for (const envRef of [API_KEY_ENV, LEGACY_API_KEY_ENV]) { + const environment = await webCtx.credentials.resolve(credentialRef(envRef)); + if (environment?.value) { + items.push({ + id: `env:${envRef}`, + kind: "environment", + label: `环境变量 ${envRef}`, + ref: envRef, + configured: true, + masked: maskApiKey(environment.value), + source: environment.source, + }); + seenRefs.add(envRef); + } + } + for (const entry of workBuddyApiKeyEntries(store)) { + if (seenRefs.has(entry.ref)) continue; + const resolved = await webCtx.credentials.resolve(credentialRef(entry.ref)); + items.push({ + ...entry, + kind: "dsh", + configured: Boolean(resolved?.value), + ...(resolved?.value ? { masked: maskApiKey(resolved.value), source: resolved.source } : {}), + }); + seenRefs.add(entry.ref); + } + if (ref && !seenRefs.has(ref)) { + const resolved = await webCtx.credentials.resolve(credentialRef(ref)); + if (resolved?.value) { + items.push({ + id: `dsh:${ref}`, + kind: ENV_SOURCES.has(resolved.source) ? "environment" : "dsh", + label: ENV_SOURCES.has(resolved.source) ? `环境变量 ${ref}` : "DSH 默认 API Key", + ref, + configured: true, + masked: maskApiKey(resolved.value), + source: resolved.source, + }); + } + } + const configured = apiMode + ? items.find((item) => item.ref === ref) + ?? (ref === API_KEY_ENV ? items.find((item) => item.ref === LEGACY_API_KEY_ENV) : undefined) + : undefined; + const active = configured ?? items.find((item) => item.id === store.activeId) ?? items[0]; + return { + apiKeys: items, + activeApiKeyId: active?.id ?? null, + apiKeyConfigured: Boolean(configured?.configured), + }; +} + +async function readSessionStore(credentials) { + for (const ref of [WORKBUDDY_SESSIONS_REF, LEGACY_SESSIONS_REF]) { + const stored = await credentials.resolve(credentialRef(ref)); + if (stored?.value) return parseWorkBuddySessions(stored.value); + } + for (const ref of [WORKBUDDY_SESSION_REF, LEGACY_SESSION_REF]) { + const legacy = await credentials.resolve(credentialRef(ref)); + if (legacy?.value) return createWorkBuddySessionStore([parseWorkBuddySession(legacy.value)]); + } + return createWorkBuddySessionStore(); +} + +async function writeSessionStore(credentials, store) { + const active = activeWorkBuddySession(store); + if (!active) { + await credentials.unset(credentialRef(WORKBUDDY_SESSIONS_REF)); + await credentials.unset(credentialRef(WORKBUDDY_SESSION_REF)); + await credentials.unset(credentialRef(LEGACY_SESSIONS_REF)); + await credentials.unset(credentialRef(LEGACY_SESSION_REF)); + return; + } + await credentials.set(credentialRef(WORKBUDDY_SESSIONS_REF), serializeWorkBuddySessions(store)); + // Keep the old single-session reference as a compatibility pointer for older plugin versions. + await credentials.set(credentialRef(WORKBUDDY_SESSION_REF), serializeWorkBuddySession(active)); + await credentials.unset(credentialRef(LEGACY_SESSIONS_REF)); + await credentials.unset(credentialRef(LEGACY_SESSION_REF)); +} + +async function readSessionRouting(credentials) { + const stored = await credentials.resolve(credentialRef(WORKBUDDY_SESSION_ROUTING_REF)); + return stored?.value ? parseWorkBuddySessionRouting(stored.value) : createWorkBuddySessionRoutingState(); +} + +async function writeSessionRouting(credentials, state) { + await credentials.set(credentialRef(WORKBUDDY_SESSION_ROUTING_REF), serializeWorkBuddySessionRouting(state)); +} + +function sessionIdOf(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function clearSessionBindings(credentials, predicate) { + const state = await readSessionRouting(credentials); + const bindings = Object.fromEntries(Object.entries(state.bindings).filter(([, binding]) => !predicate(binding))); + const clearRecent = state.lastUsed && predicate(state.lastUsed); + if (clearRecent || Object.keys(bindings).length !== Object.keys(state.bindings).length) { + await writeSessionRouting(credentials, { + ...state, + bindings, + ...(clearRecent ? { lastUsed: undefined } : {}), + }); + } +} + +async function resolveSession(webCtx, accountId, sessionId) { + const store = await readSessionStore(webCtx.credentials); + const routing = await readSessionRouting(webCtx.credentials); + const binding = routing.enabled && sessionIdOf(sessionId) ? routing.bindings[sessionIdOf(sessionId)] : undefined; + const boundAccountId = binding?.mode === "token" ? binding.accountId : undefined; + const requestedId = typeof accountId === "string" && accountId + ? accountId + : boundAccountId ?? store.activeId; + let session = store.sessions.find((entry) => entry.id === requestedId) ?? activeWorkBuddySession(store); + if (!session) throw new Error("没有找到该 WorkBuddy 登录账号"); + if (boundAccountId && session.id !== boundAccountId) throw new Error("当前会话绑定的 WorkBuddy 登录账号不存在"); + if (sessionNeedsRefresh(session)) { + session = { ...session, ...(await refreshWorkBuddySession(session)), updatedAt: Date.now() }; + const nextStore = { + ...store, + sessions: store.sessions.map((entry) => entry.id === session.id ? session : entry), + }; + await writeSessionStore(webCtx.credentials, nextStore); + } + return session; +} + +export function installWorkBuddyWeb(ctx, settingsNs = "llm-pi-ai") { + ctx.inject(["webServer", "settings", "credentials"], (webCtx) => { + const settings = settingsAccess(webCtx.settings, settingsNs); + let loginPromise; + const currentState = async (requestedSessionId) => { + const sessionId = sessionIdOf(requestedSessionId); + const store = await readSessionStore(webCtx.credentials); + const active = activeWorkBuddySession(store); + const routing = await readSessionRouting(webCtx.credentials); + const apiKeys = await currentApiKeyState(webCtx, settings); + const globalMode = authenticationMode(settings.get("llm-pi-ai")); + const sessionBinding = routing.enabled && sessionId ? routing.bindings[sessionId] : undefined; + const recentBinding = routing.enabled ? routing.lastUsed : undefined; + const exactAccount = sessionBinding?.mode === "token" ? store.sessions.find((entry) => entry.id === sessionBinding.accountId) : undefined; + const exactApiKey = sessionBinding?.mode === "api-key" ? apiKeys.apiKeys.find((entry) => entry.ref === sessionBinding.apiKeyRef) : undefined; + const recentAccount = recentBinding?.mode === "token" ? store.sessions.find((entry) => entry.id === recentBinding.accountId) : undefined; + const recentApiKey = recentBinding?.mode === "api-key" ? apiKeys.apiKeys.find((entry) => entry.ref === recentBinding.apiKeyRef) : undefined; + const sessionBindingValid = !sessionBinding || (sessionBinding.mode === "token" ? Boolean(exactAccount) : Boolean(exactApiKey?.configured)); + const recentBindingValid = !recentBinding || (recentBinding.mode === "token" ? Boolean(recentAccount) : Boolean(recentApiKey?.configured)); + const effectiveBinding = sessionBinding; + const suggestedBinding = !sessionBinding && recentBindingValid ? recentBinding : undefined; + // Unbound Sessions display the new-Session default, but runtime auth only + // becomes exact after the first model call persists it for that Session. + const displayedBinding = effectiveBinding ?? suggestedBinding; + const boundAccount = displayedBinding?.mode === "token" ? (effectiveBinding ? exactAccount : recentAccount) : undefined; + const boundApiKey = displayedBinding?.mode === "api-key" ? (effectiveBinding ? exactApiKey : recentApiKey) : undefined; + const mode = displayedBinding?.mode ?? globalMode; + return { + ok: true, + mode, + globalMode, + routingEnabled: routing.enabled, + sessionId: sessionId ?? null, + sessionBinding: sessionBinding ?? null, + sessionBindingValid, + lastUsedBinding: recentBinding ?? null, + lastUsedBindingValid: recentBindingValid, + effectiveBinding: effectiveBinding ?? null, + suggestedBinding: suggestedBinding ?? null, + suggestedAccountId: suggestedBinding?.mode === "token" ? recentAccount?.id ?? null : null, + suggestedApiKeyId: suggestedBinding?.mode === "api-key" ? recentApiKey?.id ?? null : null, + authenticated: displayedBinding + ? displayedBinding.mode === "token" ? Boolean(boundAccount) : Boolean(boundApiKey?.configured) + : globalMode === "token" ? active !== undefined : apiKeys.apiKeyConfigured, + activeAccountId: displayedBinding?.mode === "token" ? boundAccount?.id ?? null : active?.id ?? null, + globalActiveAccountId: active?.id ?? null, + accounts: workBuddySessionAccounts(store), + ...apiKeys, + activeApiKeyId: displayedBinding?.mode === "api-key" ? boundApiKey?.id ?? null : apiKeys.activeApiKeyId, + globalActiveApiKeyId: apiKeys.activeApiKeyId, + apiKeyConfigured: mode === "api-key" && (displayedBinding?.mode === "api-key" ? Boolean(boundApiKey?.configured) : apiKeys.apiKeyConfigured), + }; + }; + const status = async (req, res) => { + try { + const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId"); + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "读取 WorkBuddy 认证状态失败" }); + } + }; + const models = async (req, res) => { + try { + const providers = settings.get("llm-pi-ai")?.providers ?? {}; + const profile = providers[PROVIDER] ?? providers[LEGACY_PROVIDER]; + if (req.method === "GET") return json(res, 200, { + ok: true, + models: profile?.models ?? null, + revision: settings.revision(), + }); + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面编辑模型" }); + const body = await requestBody(req); + if (body.action === "discover") { + return json(res, 200, { ok: true, models: await webCtx.llm.discoverModels(settingsNs, { provider: PROVIDER }) }); + } + if (body.action !== "save" && body.action !== "reset") return json(res, 400, { ok: false, message: "模型操作无效" }); + if (body.action === "save" && !validModelOverrides(body.models)) return json(res, 400, { ok: false, message: "模型列表格式无效" }); + if (profile === undefined && body.action === "reset") return json(res, 200, { ok: true, models: null, revision: settings.revision() }); + const path = ["providers", providers[PROVIDER] === undefined && providers[LEGACY_PROVIDER] !== undefined ? LEGACY_PROVIDER : PROVIDER]; + const ops = profile === undefined + ? [{ op: "set", path, value: { apiKeyEnv: API_KEY_ENV, ...(body.action === "save" ? { models: body.models } : {}) } }] + : [body.action === "save" + ? { op: "set", path: [...path, "models"], value: body.models } + : { op: "unset", path: [...path, "models"] }]; + await settings.mutate("llm-pi-ai", ops, body.revision); + return json(res, 200, { ok: true, models: body.action === "save" ? body.models : null, revision: settings.revision() }); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "编辑 WorkBuddy 模型失败" }); + } + }; + const routing = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换会话认证" }); + try { + const body = await requestBody(req); + if (typeof body.enabled !== "boolean") return json(res, 400, { ok: false, message: "会话级认证开关参数无效" }); + const state = await readSessionRouting(webCtx.credentials); + await writeSessionRouting(webCtx.credentials, { ...state, enabled: body.enabled }); + json(res, 200, await currentState(body.sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换会话级认证失败" }); + } + }; + const unbind = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面解除会话认证" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + if (!sessionId) return json(res, 400, { ok: false, message: "缺少当前会话 ID" }); + const state = await readSessionRouting(webCtx.credentials); + if (Object.hasOwn(state.bindings, sessionId)) { + const bindings = { ...state.bindings }; + delete bindings[sessionId]; + await writeSessionRouting(webCtx.credentials, { ...state, bindings }); + } + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "解除会话认证失败" }); + } + }; + const apiKey = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + const routingState = await readSessionRouting(webCtx.credentials); + const sessionRouting = routingState.enabled && sessionId; + let ref = API_KEY_ENV; + if (typeof body.keyId === "string" && body.keyId) { + const state = await currentApiKeyState(webCtx, settings); + const selected = state.apiKeys.find((entry) => entry.id === body.keyId); + if (!selected) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy API Key" }); + if (!selected.configured) return json(res, 409, { ok: false, message: "该 API Key 已不可用,请删除后重新添加" }); + ref = selected.ref; + if (!sessionRouting && !routingState.enabled) { + const store = await readApiKeyStore(webCtx.credentials); + await writeApiKeyStore(webCtx.credentials, { ...store, activeId: selected.kind === "dsh" ? selected.id : null }); + } + } + credentialRef(ref); + if (routingState.enabled && !body.keyId) { + const state = await currentApiKeyState(webCtx, settings); + const selected = state.apiKeys.find((entry) => entry.ref === ref) ?? state.apiKeys.find((entry) => entry.configured); + if (!selected?.configured) return json(res, 409, { ok: false, message: "未检测到可用 WorkBuddy API Key" }); + ref = selected.ref; + } + if (sessionRouting) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + bindings: { ...routingState.bindings, [sessionId]: { mode: "api-key", apiKeyRef: ref } }, + lastUsed: { mode: "api-key", apiKeyRef: ref }, + }); + } else if (routingState.enabled) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + lastUsed: { mode: "api-key", apiKeyRef: ref }, + }); + } else { + await setMode(settings, "api-key", ref); + } + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换 API Key 失败" }); + } + }; + const addApiKey = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面保存 API Key" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + const value = typeof body.key === "string" ? body.key.trim() : ""; + if (!value) return json(res, 400, { ok: false, message: "请输入 API Key" }); + if (value.length > 16 * 1024) return json(res, 413, { ok: false, message: "API Key 长度超出限制" }); + const store = await readApiKeyStore(webCtx.credentials); + const ref = storedApiKeyRef(); + const entry = { + id: `dsh:${ref}`, + ref, + label: textLabel(body.label) ?? `DSH API Key ${store.entries.length + 1}`, + }; + await webCtx.credentials.set(credentialRef(ref), value); + try { + const next = upsertWorkBuddyApiKey(store, entry); + await writeApiKeyStore(webCtx.credentials, next); + const routingState = await readSessionRouting(webCtx.credentials); + if (routingState.enabled && sessionId) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + bindings: { ...routingState.bindings, [sessionId]: { mode: "api-key", apiKeyRef: ref } }, + lastUsed: { mode: "api-key", apiKeyRef: ref }, + }); + } else if (routingState.enabled) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + lastUsed: { mode: "api-key", apiKeyRef: ref }, + }); + } else { + await setMode(settings, "api-key", ref); + } + } catch (error) { + await webCtx.credentials.unset(credentialRef(ref)); + throw error; + } + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "保存 API Key 失败" }); + } + }; + const removeApiKey = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面删除 API Key" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + const store = await readApiKeyStore(webCtx.credentials); + const entry = store.entries.find((item) => item.id === body.keyId); + if (!entry) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy API Key" }); + const activeRef = configuredApiKeyRef(settings); + await webCtx.credentials.unset(credentialRef(entry.ref)); + const remaining = store.entries.filter((item) => item.id !== entry.id); + await writeApiKeyStore(webCtx.credentials, { version: 1, activeId: remaining[0]?.id, entries: remaining }); + await clearSessionBindings(webCtx.credentials, (binding) => binding.mode === "api-key" && binding.apiKeyRef === entry.ref); + if (authenticationMode(settings.get("llm-pi-ai")) === "api-key" && activeRef === entry.ref) { + const environment = await webCtx.credentials.resolve(credentialRef(API_KEY_ENV)); + let fallback = API_KEY_ENV; + if (!environment?.value) { + for (const candidate of remaining) { + if ((await webCtx.credentials.resolve(credentialRef(candidate.ref)))?.value) { + fallback = candidate.ref; + break; + } + } + } + await setMode(settings, "api-key", fallback); + } + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "删除 API Key 失败" }); + } + }; + const token = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" }); + try { + const body = await requestBody(req); + const store = await readSessionStore(webCtx.credentials); + const sessionId = sessionIdOf(body.sessionId); + const accountId = typeof body.accountId === "string" ? body.accountId : store.activeId; + const active = store.sessions.find((entry) => entry.id === accountId); + if (!active) return json(res, 409, { ok: false, message: "没有找到该 WorkBuddy 登录账号" }); + const routingState = await readSessionRouting(webCtx.credentials); + if (routingState.enabled && sessionId) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + bindings: { ...routingState.bindings, [sessionId]: { mode: "token", accountId: active.id } }, + lastUsed: { mode: "token", accountId: active.id }, + }); + } else if (routingState.enabled) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + lastUsed: { mode: "token", accountId: active.id }, + }); + } else { + await writeSessionStore(webCtx.credentials, { ...store, activeId: active.id }); + await setMode(settings, "token"); + } + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换令牌账号失败" }); + } + }; + const credits = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面查询 WorkBuddy 积分" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + const selection = await currentState(sessionId); + if (selection.mode !== "token") { + // [自用定制 2026-10-10] 账单接口接受 API Key 作为 Bearer 令牌,故 API Key 模式同样可以查积分。 + // 还原:见同目录 workbuddy-web.js.bak-20261010 + const keyStore = await readApiKeyStore(webCtx.credentials); + const activeKey = keyStore.entries.find((entry) => entry.id === keyStore.activeId) ?? keyStore.entries[0]; + const candidates = [activeKey?.ref, API_KEY_ENV, LEGACY_API_KEY_ENV] + .filter((value) => typeof value === "string" && value); + let apiKey; + for (const ref of candidates) { + const stored = await webCtx.credentials.resolve(credentialRef(ref)); + if (stored?.value) { apiKey = stored.value; break; } + } + if (!apiKey) { + return json(res, 200, { + ok: true, + accountId: null, + credits: null, + totalDosage: null, + segments: [], + unlimited: false, + cycleResetTime: null, + creditError: "未找到可用的 API Key", + todayUsage: null, + todayUsageError: "今日请求量查询仅支持 WorkBuddy 令牌登录", + }); + } + const keyed = await fetchWorkBuddyCredits({ + auth: { accessToken: apiKey, domain: "www.codebuddy.cn" }, + account: {}, + }); + return json(res, 200, { + ok: true, + accountId: null, + credits: keyed.credits, + totalDosage: keyed.totalDosage, + segments: keyed.segments, + unlimited: !!keyed.unlimited, + cycleResetTime: keyed.cycleResetTime ?? null, + planName: keyed.planName ?? null, + planCycleEnd: keyed.planCycleEnd ?? null, + creditError: keyed.creditError ?? null, + todayUsage: null, + todayUsageError: "今日请求量查询仅支持 WorkBuddy 令牌登录", + }); + } + const session = await resolveSession(webCtx, selection.activeAccountId ?? body.accountId, sessionId); + const result = await fetchWorkBuddyCredits(session); + json(res, 200, { + ok: true, + accountId: session.id, + credits: result.credits, + totalDosage: result.totalDosage, + segments: result.segments, + unlimited: !!result.unlimited, + cycleResetTime: result.cycleResetTime ?? null, + planName: result.planName ?? null, + planCycleEnd: result.planCycleEnd ?? null, + creditError: result.creditError ?? null, + todayUsage: result.todayUsage ?? null, + todayUsageError: result.todayUsageError ?? null, + }); + } catch (error) { + json(res, 200, { + ok: true, + accountId: null, + credits: null, + totalDosage: null, + segments: [], + unlimited: false, + cycleResetTime: null, + creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败", + todayUsage: null, + todayUsageError: error instanceof Error ? error.message : "查询 WorkBuddy 今日请求量失败", + }); + } + }; + const login = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面登录" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + loginPromise ??= (async () => { + const session = await loginWorkBuddy(); + const store = await readSessionStore(webCtx.credentials); + const nextStore = upsertWorkBuddySession(store, session); + await writeSessionStore(webCtx.credentials, nextStore); + const routingState = await readSessionRouting(webCtx.credentials); + if (routingState.enabled && sessionId) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + bindings: { ...routingState.bindings, [sessionId]: { mode: "token", accountId: nextStore.activeId } }, + lastUsed: { mode: "token", accountId: nextStore.activeId }, + }); + } else if (routingState.enabled) { + await writeSessionRouting(webCtx.credentials, { + ...routingState, + lastUsed: { mode: "token", accountId: nextStore.activeId }, + }); + } else { + await setMode(settings, "token"); + } + })().finally(() => { + loginPromise = undefined; + }); + await loginPromise; + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "WorkBuddy 登录失败" }); + } + }; + const remove = async (req, res) => { + if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" }); + if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面管理登录账号" }); + try { + const body = await requestBody(req); + const sessionId = sessionIdOf(body.sessionId); + const store = await readSessionStore(webCtx.credentials); + const accountId = typeof body.accountId === "string" ? body.accountId : store.activeId; + const sessions = store.sessions.filter((entry) => entry.id !== accountId); + if (sessions.length === store.sessions.length) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy 登录账号" }); + const activeId = accountId === store.activeId ? sessions[0]?.id : store.activeId; + await writeSessionStore(webCtx.credentials, { version: 1, activeId, sessions }); + await clearSessionBindings(webCtx.credentials, (binding) => binding.mode === "token" && binding.accountId === accountId); + json(res, 200, await currentState(sessionId)); + } catch (error) { + json(res, 500, { ok: false, message: error instanceof Error ? error.message : "删除令牌账号失败" }); + } + }; + webCtx.effect(() => { + const dispose = [ + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/status`, handler: status }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/models`, handler: models }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/routing`, handler: routing }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/unbind`, handler: unbind }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key`, handler: apiKey }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key/add`, handler: addApiKey }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key/remove`, handler: removeApiKey }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/token`, handler: token }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/credits`, handler: credits }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/login`, handler: login }), + webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/remove`, handler: remove }), + ]; + return () => dispose.forEach((fn) => fn()); + }, "llm-workbuddy: web login routes"); + }); +}