dsh-llm-workbuddy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +156 -0
- package/lib/index.js +835 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# dsh-llm-workbuddy
|
|
2
|
+
|
|
3
|
+
在 DeepSeek Harness 中使用你的 **WorkBuddy / CodeBuddy** 账号模型的 LLM 适配器插件。
|
|
4
|
+
|
|
5
|
+
它把 `workbuddy` 这个 provider 路由指向本地运行的
|
|
6
|
+
[workbuddy2api](https://github.com/hawklithm/workbuddy2api) 代理
|
|
7
|
+
(默认 `http://127.0.0.1:8787/v1`)。workbuddy2api 把 CodeBuddy/WorkBuddy 的
|
|
8
|
+
私有协议转成标准 OpenAI chat-completions 格式,并用本地保存的登录态完成认证,
|
|
9
|
+
因此本插件**不需要任何 API Key**。
|
|
10
|
+
|
|
11
|
+
装好并启动后提供两件事:
|
|
12
|
+
|
|
13
|
+
1. **模型能力**:Web 界面的模型选择器(composer 模型菜单或 `/model` 命令)会多出
|
|
14
|
+
一个 **WorkBuddy** 分组,模型(DeepSeek-V4、GLM-5.x、Kimi-K2.x、MiniMax-M3、
|
|
15
|
+
Hy3、Hunyuan…)随账号可用列表实时同步,点一下即可切换。
|
|
16
|
+
2. **Web 登录状态小组件**:在 Web GUI 右下角常驻一个状态胶囊,**实时显示登录/
|
|
17
|
+
代理状态**,未登录时一键在新标签页打开 WorkBuddy 登录页,登录完成后自动变绿。
|
|
18
|
+
无需再回到终端手动跑登录脚本。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 架构一览
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
┌──────────────────────────── DeepSeek Harness Web GUI ───────────────────────────┐
|
|
26
|
+
│ │
|
|
27
|
+
│ [ 模型选择器 (WorkBuddy 分组) ] [ WorkBuddy 状态胶囊 (右下角) ] │
|
|
28
|
+
│ │ │ │
|
|
29
|
+
│ │ GET /v1/models (代理发现) │ GET /api/workbuddy/status │
|
|
30
|
+
│ ▼ │ POST /api/workbuddy/login │
|
|
31
|
+
│ lib/index.js (WorkBuddyAdapter) ▼ │
|
|
32
|
+
│ │ fetch lib/index.js │
|
|
33
|
+
│ ▼ POST /chat/completions (registerWorkbuddyRoutes) │
|
|
34
|
+
│ workbuddy2api 代理 (127.0.0.1:8787) ──health──▶ ├─ 读 .workbuddy/session.json│
|
|
35
|
+
│ │ ├─ 探测代理 /health │
|
|
36
|
+
│ ▼ └─ spawn login_workbuddy.py │
|
|
37
|
+
│ WorkBuddy / CodeBuddy 云端 │ │
|
|
38
|
+
│ ▼ │
|
|
39
|
+
│ login_workbuddy.py ──▶ 写回 │
|
|
40
|
+
│ (设备流) session │
|
|
41
|
+
└──────────────────────────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **后端路由**(`lib/index.js` 的 `registerWorkbuddyRoutes`)运行在 `dsh web` 的
|
|
45
|
+
HTTP 服务上,通过 Cordis 的 `ctx.webServer` 注册,headless profile 下自动跳过。
|
|
46
|
+
- **前端胶囊**(`lib/client.js`)是零依赖的原生浏览器 JS,由 DSH 的 `dsh.client`
|
|
47
|
+
双端机制在 `window.__DSH_BOOT__` 中注入,serve 于
|
|
48
|
+
`/plugins/dsh-llm-workbuddy/client.js`。
|
|
49
|
+
- **登录脚本**(`login_workbuddy.py`,仓库根)实现与官方 CodeBuddy 插件一致的
|
|
50
|
+
device flow(`platform=CLI` + codebuddy.cn 请求头)。
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 前置条件(重要)
|
|
55
|
+
|
|
56
|
+
1. 代理必须跑在 **workbuddy2api 主分支源码**上,不能用 PyPI 的 2.0.3:
|
|
57
|
+
旧版缺少 `X-Product-Code` / Genie-IDE 等请求头,登录会在最后一步 401。
|
|
58
|
+
2. 登录请使用仓库里的 `login_workbuddy.py`(`platform=CLI` + codebuddy.cn
|
|
59
|
+
请求头,与官方插件一致),**不要**用代理自带的 `--login`(VSCode platform
|
|
60
|
+
会 401)。
|
|
61
|
+
3. 插件装入 profile 后需要**重启 `dsh web`** 才能加载新的 bundle(包括本小部件)。
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 安装插件
|
|
66
|
+
|
|
67
|
+
在插件包目录(本仓库 `dsh-llm-workbuddy/`)执行:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
dsh plugin --profile web add ./dsh-llm-workbuddy
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile.bundles`,
|
|
74
|
+
同时在 `package.json` 的 `dsh.client` 声明里登记浏览器入口(见下文「Web 小部件
|
|
75
|
+
工作原理」)。然后**重启** `dsh web`。
|
|
76
|
+
|
|
77
|
+
> 本插件**没有任何构建步骤、零运行时依赖**:`lib/client.js` 是浏览器原生 JS,
|
|
78
|
+
> 直接被 DSH serve,不需要 vite / react / TypeScript 编译。
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 启动代理(日常,先决条件)
|
|
83
|
+
|
|
84
|
+
小组件依赖本地代理才能判断「代理是否运行」。先启动它:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
./start-workbuddy.sh
|
|
88
|
+
# 监听 http://127.0.0.1:8787
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
验证:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
curl http://127.0.0.1:8787/health # {"status":"ok","authenticated":true,...}
|
|
95
|
+
curl http://127.0.0.1:8787/v1/models # 模型列表(含 glm-5.2 / deepseek-v4-pro ...)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> 登录态过期时 `authenticated` 变回 `false`,重跑登录即可,代理无需重启
|
|
99
|
+
> (会话按请求读取)。
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 登录:两种方式
|
|
104
|
+
|
|
105
|
+
### 方式 A(推荐):在 Web GUI 里点一下
|
|
106
|
+
|
|
107
|
+
1. 浏览器打开 `dsh web`(默认 `http://127.0.0.1:3080`)。
|
|
108
|
+
2. 看**右下角**的状态胶囊:
|
|
109
|
+
- 🟢 `WorkBuddy · <账号昵称>` —— 已登录,无需操作。
|
|
110
|
+
- 🔴 `WorkBuddy · 未登录` 或带 `代理未运行` 提示 —— 点胶囊里的 **「登录」** 按钮。
|
|
111
|
+
3. 点击后,后端 `POST /api/workbuddy/login` 会 `spawn` 运行 `login_workbuddy.py`,
|
|
112
|
+
解析它打印的设备流链接(`authUrl`)返回给前端;前端用 `window.open` 在**新标签页**
|
|
113
|
+
打开该登录页。
|
|
114
|
+
4. 在新标签页用 WorkBuddy / CodeBuddy 账号(腾讯账号)完成扫码/授权。
|
|
115
|
+
5. 胶囊会自动从每 5 秒轮询加快到每 2 秒(最多 30 次),一旦检测到
|
|
116
|
+
`.workbuddy/session.json` 的 `auth.expiresAt` 未过期且含 `accessToken`,
|
|
117
|
+
就切回 🟢 绿态,显示账号昵称。
|
|
118
|
+
|
|
119
|
+
整个过程**不需要离开浏览器、不需要回终端**。
|
|
120
|
+
|
|
121
|
+
### 方式 B(传统):在终端手动跑
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
./login-workbuddy.sh
|
|
125
|
+
# 浏览器打开打印的链接,用 WorkBuddy/CodeBuddy 账号登录
|
|
126
|
+
# 完成后自动保存会话到 .workbuddy/session.json(也提示已写入 ~/.codebuddy-session.json)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
两种方式的产物是同一个 `session.json`,可混用:终端登录后 Web 胶囊会自动变绿,
|
|
130
|
+
Web 登录后终端脚本也读得到同一份会话。
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Web 小部件工作原理
|
|
135
|
+
|
|
136
|
+
### 后端路由(`lib/index.js`)
|
|
137
|
+
|
|
138
|
+
插件在 `apply(ctx, config)` 里通过 `ctx.webServer.register(...)` 注册两条路由
|
|
139
|
+
(headless profile 无 `webServer`,自动跳过):
|
|
140
|
+
|
|
141
|
+
| 方法 + 路径 | 行为 |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `GET /api/workbuddy/status` | 读取 `.workbuddy/session.json` 的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }` |
|
|
144
|
+
| `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则 `spawn` `.tools/uv run login_workbuddy.py --session-file .workbuddy/session.json`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成) |
|
|
145
|
+
|
|
146
|
+
> 会话文件与登录脚本路径按插件包位置自动推导:
|
|
147
|
+
> `dsh-llm-workbuddy/lib/index.js` 向上两级即仓库根 `dsh-workbuddy/`,其下的
|
|
148
|
+
> `.workbuddy/session.json` 与 `login_workbuddy.py` 即为目标。
|
|
149
|
+
|
|
150
|
+
### 前端胶囊(`lib/client.js`)
|
|
151
|
+
|
|
152
|
+
- 作为经典 `<script>` 被 DSH 注入页面,IIFE 内直接操作 DOM,**零依赖**。
|
|
153
|
+
- 启动时在 `document.body` 末尾挂一个 `position:fixed` 的胶囊(右下角)。
|
|
154
|
+
- 每 **5 秒** `GET /api/workbuddy/status`;点「登录」后加快到每 **2 秒**轮询、
|
|
155
|
+
最多 30 次,直到 `authenticated:true`。
|
|
156
|
+
- 状态映射:
|
|
157
|
+
- `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
|
|
158
|
+
- 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
|
|
159
|
+
|
|
160
|
+
### 为什么不需要构建
|
|
161
|
+
|
|
162
|
+
DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"dsh": { "client": { "platform": "web" } },
|
|
167
|
+
"exports": { "./client": "./lib/client.js" }
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`dsh web` 会自动把 `exports["./client"]` 指向的文件 serve 到
|
|
172
|
+
`/plugins/<package-name>/client.js` 并注入引导清单,**不要求打包器**。
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 配置
|
|
177
|
+
|
|
178
|
+
插件入口配置(profile 的 `cordis.patch.yml` 或用户设置文档的 `llm-workbuddy:`
|
|
179
|
+
分节)。注意:状态小组件使用的代理地址取自这里的 `baseURL`(去掉 `/v1` 后探
|
|
180
|
+
`/health`)。
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
- id: llm-workbuddy
|
|
184
|
+
name: 'dsh-llm-workbuddy'
|
|
185
|
+
config:
|
|
186
|
+
baseURL: http://127.0.0.1:8787/v1 # workbuddy2api 端点(小组件据此探 /health)
|
|
187
|
+
# apiKey: '' # 代理一般不需要;如代理以 --api-key 启动则填写
|
|
188
|
+
# maxTokens: 32000 # 单请求输出上限
|
|
189
|
+
# defaultContextWindow: 200000 # 未在目录中标明容量的模型使用
|
|
190
|
+
# discovery: true # 实时拉取代理的 /v1/models(30s 缓存)
|
|
191
|
+
# models: [...] # 静态目录(代理不可达时的兜底)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
模型列表默认取插件内置目录;代理可达时改为实时拉取 `/v1/models`(支持
|
|
195
|
+
`{"models": [...]}` / `{"data": [...]}` 两种返回),未列出的模型 id 仍可原样
|
|
196
|
+
传递。
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 排错
|
|
201
|
+
|
|
202
|
+
| 现象 | 可能原因 / 解决 |
|
|
203
|
+
|---|---|
|
|
204
|
+
| 右下角没有胶囊 | `dsh web` 没重启加载新 bundle → 重启 `dsh web`;或 `curl /plugins/dsh-llm-workbuddy/client.js` 应返回 200 |
|
|
205
|
+
| 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
|
|
206
|
+
| 胶囊红 + `代理未运行` | `start-workbuddy.sh` 没起或挂了 → 启动代理后再看 |
|
|
207
|
+
| 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → `login_workbuddy.py` 或 `.tools/uv` 不在仓库根;检查路径 |
|
|
208
|
+
| 新标签页打开后登录完成,胶囊仍是红 | 会话文件 `.workbuddy/session.json` 未刷新或 `expiresAt` 已过期 → 刷新页面或重跑登录 |
|
|
209
|
+
| 启动 `dsh web` 报 `EPERM ... cordis.yml` | `.dsh` 所在系统卷受保护(`/System/Volumes/Data` 带 `protect`)。解决:`sudo chown -R $(whoami) /Users/jiyunyang/.dsh`,或 `export DSH_HOME=$HOME/dsh-home` 后重新 `dsh plugin --profile web add` 并把插件链接进新 home |
|
|
210
|
+
| 模型请求 `TRANSPORT` 错误 | 代理未运行或端口不对(连接被拒绝) |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 限制
|
|
215
|
+
|
|
216
|
+
- 当前为纯文本适配器:图片输入会以 `UNSUPPORTED_CONTENT` 拒绝(后续可加)。
|
|
217
|
+
- 不公布 reasoning effort 选择器;模型按平台默认强度运行。
|
|
218
|
+
- 代理未运行时,模型请求会以 `TRANSPORT` 错误快速失败(连接被拒绝);但状态
|
|
219
|
+
小组件本身不依赖代理——代理挂了它仍能显示「代理未运行」并允许触发登录。
|
|
220
|
+
- 登录态有效期由 WorkBuddy 云端决定;过期后胶囊变红,重新点「登录」即可,
|
|
221
|
+
代理无需重启。
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 文件索引
|
|
226
|
+
|
|
227
|
+
| 路径 | 作用 |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由注册 |
|
|
230
|
+
| `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程),被 `dsh.client` 注入 |
|
|
231
|
+
| `cordis.patch.yml` | 本包的 Cordis bundle 挂载声明(`id: llm-workbuddy`) |
|
|
232
|
+
| `package.json` | 包元数据、`dsh.client` 浏览器入口声明、`llm-workbuddy` peer 依赖 |
|
|
233
|
+
| `../login_workbuddy.py` | 设备流登录脚本(被后端路由 spawn) |
|
|
234
|
+
| `../start-workbuddy.sh` | 启动 workbuddy2api 代理 |
|
|
235
|
+
| `../.workbuddy/session.json` | 登录会话文件(胶囊与适配器共读) |
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# dsh-llm-workbuddy bundle patch
|
|
2
|
+
#
|
|
3
|
+
# This file is the `dsh.bundle.patch` layer of the package: it mounts the
|
|
4
|
+
# plugin row into the profile. Installing through the official CLI —
|
|
5
|
+
#
|
|
6
|
+
# dsh plugin --profile web add dsh-llm-workbuddy
|
|
7
|
+
#
|
|
8
|
+
# — reconciles `dsh.profile.bundles` against installed packages and, seeing
|
|
9
|
+
# this declaration, appends `dsh-llm-workbuddy` to the bundle stack.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: llm-workbuddy
|
|
12
|
+
name: 'dsh-llm-workbuddy'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-workbuddy — 浏览器端(客户端)插件
|
|
3
|
+
*
|
|
4
|
+
* DSH 会把一个声明了 `dsh.client`(platform: web)的包视作 client-module:
|
|
5
|
+
* 它把本文件编译/服务为 /plugins/dsh-llm-workbuddy/client.js 并以 classic
|
|
6
|
+
* script 注入。client-modules 加载器(@deepseek-ai/dsh-client-modules)要求
|
|
7
|
+
* 这种 bundle 在脚本执行时只做一件事——调用
|
|
8
|
+
* `window.__ModuleLoader__.load({ id, factory })` 注册自己(工厂是惰性的,
|
|
9
|
+
* 不能执行副作用)。之后加载器会物化 factory,并调用其返回模块面的
|
|
10
|
+
* `apply(ctx)` 来激活这个客户端插件。
|
|
11
|
+
*
|
|
12
|
+
* 因此这里用一个标准的 Cordis 客户端插件面({ name, apply })作为模块内容,
|
|
13
|
+
* 最后用 __ModuleLoader__.load 把它注册给加载器——这是「无构建、零依赖」
|
|
14
|
+
* 环境下最贴近官方 defineClient 产物的一种写法(官方构建产物本质上也是
|
|
15
|
+
* 把 { name, apply } 包进 load 注册)。
|
|
16
|
+
*/
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: "dsh-llm-workbuddy",
|
|
19
|
+
factory: function () {
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
/** 与服务器端插件同名,便于在浏览器端 Cordis 中辨识。 */
|
|
24
|
+
name: "llm-workbuddy",
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 客户端插件入口。DSH 在浏览器端激活本插件时调用。
|
|
28
|
+
*
|
|
29
|
+
* 负责在 Web GUI 右下角注入一个常驻状态小圆点:轮询
|
|
30
|
+
* GET /api/workbuddy/status 显示绿/红登录态;点击「登录」会调用
|
|
31
|
+
* POST /api/workbuddy/login,打开返回的 device-flow authUrl,并持续
|
|
32
|
+
* 轮询直到会话 authenticated。
|
|
33
|
+
*/
|
|
34
|
+
apply: function () {
|
|
35
|
+
startWidget();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Widget 实现
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
var POLL_MS = 5000;
|
|
46
|
+
var LOGIN_POLL_MS = 2000;
|
|
47
|
+
var LOGIN_POLLS = 30;
|
|
48
|
+
|
|
49
|
+
/** 插件激活后、DOM 就绪时开始挂载 widget。 */
|
|
50
|
+
function startWidget() {
|
|
51
|
+
if (document.readyState === "loading") {
|
|
52
|
+
document.addEventListener("DOMContentLoaded", runWidget, { once: true });
|
|
53
|
+
} else {
|
|
54
|
+
runWidget();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 真正的 widget 逻辑:注入样式、渲染状态条、轮询、登录。 */
|
|
59
|
+
function runWidget() {
|
|
60
|
+
var host = document.createElement("div");
|
|
61
|
+
host.id = "wb-status-host";
|
|
62
|
+
host.style.cssText =
|
|
63
|
+
"position:fixed;right:12px;bottom:12px;z-index:2147483647;" +
|
|
64
|
+
"font:12px/1.4 system-ui,-apple-system,sans-serif;";
|
|
65
|
+
|
|
66
|
+
var style = document.createElement("style");
|
|
67
|
+
style.textContent =
|
|
68
|
+
".wb-pill{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;" +
|
|
69
|
+
"background:#111827;color:#e5e7eb;box-shadow:0 2px 8px rgba(0,0,0,.35);}" +
|
|
70
|
+
".wb-dot{width:8px;height:8px;border-radius:50%;background:#9ca3af;}" +
|
|
71
|
+
".wb-pill--ok .wb-dot{background:#22c55e;}" +
|
|
72
|
+
".wb-pill--bad .wb-dot{background:#ef4444;}" +
|
|
73
|
+
".wb-btn{margin-left:4px;border:0;border-radius:6px;background:#2563eb;color:#fff;" +
|
|
74
|
+
"padding:3px 8px;cursor:pointer;font:inherit;}" +
|
|
75
|
+
".wb-btn:disabled{opacity:.6;cursor:default;}" +
|
|
76
|
+
".wb-warn{margin-left:6px;color:#f59e0b;font-size:11px;}";
|
|
77
|
+
document.head.appendChild(style);
|
|
78
|
+
document.body.appendChild(host);
|
|
79
|
+
|
|
80
|
+
var status = null;
|
|
81
|
+
var busy = false;
|
|
82
|
+
var timer = null;
|
|
83
|
+
|
|
84
|
+
function fmtExpiry(expiresAt) {
|
|
85
|
+
if (!expiresAt) return "";
|
|
86
|
+
try { return new Date(expiresAt).toLocaleString(); }
|
|
87
|
+
catch (e) { return ""; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function render() {
|
|
91
|
+
if (!status) {
|
|
92
|
+
host.innerHTML = '<div class="wb-pill wb-pill--bad">WorkBuddy · …</div>';
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
var ok = status.authenticated && status.proxyUp;
|
|
96
|
+
var label = ok
|
|
97
|
+
? "WorkBuddy · " + (status.account && (status.account.nickname || status.account.uid) || "已登录")
|
|
98
|
+
: "WorkBuddy · 未登录";
|
|
99
|
+
var html =
|
|
100
|
+
'<div class="wb-pill ' + (ok ? "wb-pill--ok" : "wb-pill--bad") + '">' +
|
|
101
|
+
'<span class="wb-dot"></span>' +
|
|
102
|
+
'<span class="wb-label">' + label + "</span>";
|
|
103
|
+
if (!ok) {
|
|
104
|
+
var disabled = busy || !status.loginScriptAvailable ? " disabled" : "";
|
|
105
|
+
html += '<button class="wb-btn" id="wb-login"' + disabled + ">" +
|
|
106
|
+
(busy ? "登录中…" : "登录") + "</button>";
|
|
107
|
+
}
|
|
108
|
+
if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
|
|
109
|
+
html += "</div>";
|
|
110
|
+
host.innerHTML = html;
|
|
111
|
+
var btn = host.querySelector("#wb-login");
|
|
112
|
+
if (btn) btn.addEventListener("click", onLogin);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function refresh() {
|
|
116
|
+
return fetch("/api/workbuddy/status")
|
|
117
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
118
|
+
.then(function (data) {
|
|
119
|
+
if (data) { status = data; render(); }
|
|
120
|
+
})
|
|
121
|
+
.catch(function () { /* leave last known state on screen */ });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onLogin() {
|
|
125
|
+
if (busy) return;
|
|
126
|
+
busy = true;
|
|
127
|
+
render();
|
|
128
|
+
fetch("/api/workbuddy/login", { method: "POST" })
|
|
129
|
+
.then(function (r) { return r.json(); })
|
|
130
|
+
.then(function (data) {
|
|
131
|
+
if (data && data.authUrl) {
|
|
132
|
+
window.open(data.authUrl, "_blank", "noopener,noreferrer");
|
|
133
|
+
}
|
|
134
|
+
var i = 0;
|
|
135
|
+
var tick = function () {
|
|
136
|
+
refresh().then(function () {
|
|
137
|
+
if ((status && status.authenticated) || i >= LOGIN_POLLS) {
|
|
138
|
+
busy = false;
|
|
139
|
+
render();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
i++;
|
|
143
|
+
setTimeout(tick, LOGIN_POLL_MS);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
tick();
|
|
147
|
+
})
|
|
148
|
+
.catch(function () {
|
|
149
|
+
busy = false;
|
|
150
|
+
render();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
refresh();
|
|
155
|
+
timer = setInterval(refresh, POLL_MS);
|
|
156
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-workbuddy
|
|
3
|
+
*
|
|
4
|
+
* WorkBuddy LLM provider adapter for DeepSeek Harness.
|
|
5
|
+
*
|
|
6
|
+
* Routes the `workbuddy` provider to the local workbuddy2api proxy
|
|
7
|
+
* (https://github.com/hawklithm/workbuddy2api), which converts CodeBuddy /
|
|
8
|
+
* WorkBuddy's proprietary protocol into standard OpenAI chat completions.
|
|
9
|
+
*
|
|
10
|
+
* The proxy authenticates with the locally stored CodeBuddy/WorkBuddy login
|
|
11
|
+
* session, so this adapter needs no API key: every request is sent without an
|
|
12
|
+
* `Authorization` header unless one is explicitly configured.
|
|
13
|
+
*
|
|
14
|
+
* The adapter is a plain fetch + SSE implementation over the OpenAI-compatible
|
|
15
|
+
* wire format; it emits the harness `StreamChunk` protocol. Model discovery
|
|
16
|
+
* queries the proxy's `/v1/models` endpoint (cached, short TTL) and falls back
|
|
17
|
+
* to a shipped static catalog when the proxy is not running.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import z from "@deepseek-ai/schemastery";
|
|
24
|
+
import {
|
|
25
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
26
|
+
CallId,
|
|
27
|
+
EMPTY_RESPONSE_CODE,
|
|
28
|
+
LlmAdapter,
|
|
29
|
+
LlmError,
|
|
30
|
+
ProviderRequestId,
|
|
31
|
+
QUOTA_EXCEEDED_CODE,
|
|
32
|
+
attributionHeaders,
|
|
33
|
+
isContextWindowExceededError,
|
|
34
|
+
isQuotaExceededError,
|
|
35
|
+
} from "@deepseek-ai/dsh-llm";
|
|
36
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
37
|
+
import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
|
|
38
|
+
|
|
39
|
+
/** Plugin identity (Cordis convention). */
|
|
40
|
+
export const name = "llm-workbuddy";
|
|
41
|
+
export const inject = ["llm", "webServer"];
|
|
42
|
+
|
|
43
|
+
/** The single provider route this plugin owns. */
|
|
44
|
+
export const PROVIDER = "workbuddy";
|
|
45
|
+
|
|
46
|
+
/** Settings namespace for the optional `llm-workbuddy:` user-settings section. */
|
|
47
|
+
const NS = settingsNamespace("llm-workbuddy");
|
|
48
|
+
|
|
49
|
+
/** Default local workbuddy2api proxy endpoint. */
|
|
50
|
+
export const DEFAULT_BASE_URL = "http://127.0.0.1:8787/v1";
|
|
51
|
+
|
|
52
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
53
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
54
|
+
const STREAM_IDLE_TIMEOUT_CODE = "LLM_STREAM_IDLE_TIMEOUT";
|
|
55
|
+
|
|
56
|
+
/** Default combined request/response context capacity. */
|
|
57
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
58
|
+
/** Default per-request output-token cap. */
|
|
59
|
+
const DEFAULT_MAX_TOKENS = 32_000;
|
|
60
|
+
|
|
61
|
+
/** Live `/v1/models` discovery cache window and request timeout. */
|
|
62
|
+
const DISCOVERY_TTL_MS = 30_000;
|
|
63
|
+
const DISCOVERY_TIMEOUT_MS = 2_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Static catalog shipped with the plugin — the conversational models of the
|
|
67
|
+
* CodeBuddy/WorkBuddy platform (from workbuddy2api's models_config.json).
|
|
68
|
+
* It is advisory and replaced by the live `/v1/models` answer whenever the
|
|
69
|
+
* proxy is reachable; entries not announced by the proxy are still listed.
|
|
70
|
+
*/
|
|
71
|
+
const DEFAULT_MODELS = [
|
|
72
|
+
{ id: "deepseek-v4-pro", name: "Deepseek-V4-Pro", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"] },
|
|
73
|
+
{ id: "deepseek-v4-flash", name: "Deepseek-V4-Flash", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"] },
|
|
74
|
+
{ id: "glm-5.2", name: "GLM-5.2", contextWindow: 1_000_000, maxTokens: 48_000, inputModalities: ["text", "image"] },
|
|
75
|
+
{ id: "glm-5.1", name: "GLM-5.1", contextWindow: 200_000, maxTokens: 48_000 },
|
|
76
|
+
{ id: "glm-5.0", name: "GLM-5.0", contextWindow: 200_000, maxTokens: 48_000 },
|
|
77
|
+
{ id: "glm-4.7", name: "GLM-4.7", contextWindow: 200_000, maxTokens: 48_000 },
|
|
78
|
+
{ id: "glm-4.6", name: "GLM-4.6", contextWindow: 168_000, maxTokens: 32_000 },
|
|
79
|
+
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo", contextWindow: 200_000, maxTokens: 64_000, inputModalities: ["text", "image"] },
|
|
80
|
+
{ id: "kimi-k3-1", name: "Kimi-K3", contextWindow: 1_000_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
|
|
81
|
+
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
|
|
82
|
+
{ id: "kimi-k2.6", name: "Kimi-K2.6", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
|
|
83
|
+
{ id: "kimi-k2.5", name: "Kimi-K2.5", contextWindow: 164_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
|
|
84
|
+
{ id: "kimi-k2-thinking", name: "Kimi-K2-Thinking", contextWindow: 164_000, maxTokens: 32_000 },
|
|
85
|
+
{ id: "minimax-m3", name: "MiniMax-M3", contextWindow: 512_000, maxTokens: 128_000, inputModalities: ["text", "image"] },
|
|
86
|
+
{ id: "minimax-m2.5", name: "MiniMax-M2.5", contextWindow: 200_000, maxTokens: 48_000 },
|
|
87
|
+
{ id: "hy3", name: "Hy3", contextWindow: 192_000, maxTokens: 64_000, inputModalities: ["text", "image"] },
|
|
88
|
+
{ id: "hunyuan-2.0-thinking", name: "Hunyuan-2.0-Thinking", contextWindow: 128_000, maxTokens: 24_000 },
|
|
89
|
+
{ id: "hunyuan-chat", name: "Hunyuan-Turbos", contextWindow: 200_000, maxTokens: 8_192 },
|
|
90
|
+
{ id: "auto", name: "Auto", contextWindow: 168_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
|
|
91
|
+
{ id: "default", name: "Default", contextWindow: 200_000, maxTokens: 24_000 },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// #region serialize
|
|
95
|
+
|
|
96
|
+
/** Join the text blocks of a message (used for user/tool-result content). */
|
|
97
|
+
function flattenText(blocks) {
|
|
98
|
+
return blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Serialize one assistant message (text + tool calls). */
|
|
102
|
+
function serializeAssistant(message) {
|
|
103
|
+
const text = flattenText(message.content);
|
|
104
|
+
const toolCalls = message.content.filter((block) => block.type === "tool-call").map((block) => ({
|
|
105
|
+
id: block.id,
|
|
106
|
+
type: "function",
|
|
107
|
+
function: { name: block.name, arguments: block.arguments },
|
|
108
|
+
}));
|
|
109
|
+
return {
|
|
110
|
+
role: "assistant",
|
|
111
|
+
content: text,
|
|
112
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serialize the harness conversation into OpenAI chat-completions wire
|
|
118
|
+
* messages. `tool-result` blocks become standalone `{role: 'tool'}` messages;
|
|
119
|
+
* image content is rejected (the initial version is text-only).
|
|
120
|
+
*/
|
|
121
|
+
function serializeMessages(messages) {
|
|
122
|
+
const wire = [];
|
|
123
|
+
for (const message of messages) {
|
|
124
|
+
if (message.content.some((block) => block.type === "image")) {
|
|
125
|
+
throw new LlmError("The WorkBuddy adapter does not support image content yet.", "UNSUPPORTED_CONTENT");
|
|
126
|
+
}
|
|
127
|
+
if (message.role === "system") {
|
|
128
|
+
wire.push({ role: "system", content: flattenText(message.content) });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (message.role === "assistant") {
|
|
132
|
+
wire.push(serializeAssistant(message));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
136
|
+
const text = flattenText(message.content);
|
|
137
|
+
if (text.length > 0 || toolResults.length === 0) wire.push({ role: "user", content: text });
|
|
138
|
+
for (const result of toolResults) {
|
|
139
|
+
wire.push({ role: "tool", tool_call_id: result.toolCallId, content: flattenText(result.content) || "(no output)" });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return wire;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build the full wire request. Always streaming with usage reporting on. */
|
|
146
|
+
function serializeRequest(options) {
|
|
147
|
+
const messages = [];
|
|
148
|
+
if (options.system !== undefined) messages.push({ role: "system", content: options.system });
|
|
149
|
+
messages.push(...serializeMessages(options.messages));
|
|
150
|
+
const tools = options.tools?.map((tool) => ({
|
|
151
|
+
type: "function",
|
|
152
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
153
|
+
}));
|
|
154
|
+
return {
|
|
155
|
+
model: options.model,
|
|
156
|
+
messages,
|
|
157
|
+
stream: true,
|
|
158
|
+
stream_options: { include_usage: true },
|
|
159
|
+
...(tools !== undefined && tools.length > 0 ? { tools } : {}),
|
|
160
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
161
|
+
...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
|
|
162
|
+
...(options.stop !== undefined ? { stop: options.stop } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// #endregion
|
|
167
|
+
|
|
168
|
+
// #region sse
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse one SSE event block (lines separated by \n, fields `field: value`).
|
|
172
|
+
* Returns the joined `data` payload, or `undefined` when the event carries no
|
|
173
|
+
* data field. Comment lines (starting with `:`) are transport activity.
|
|
174
|
+
*/
|
|
175
|
+
function parseEvent(raw) {
|
|
176
|
+
let data;
|
|
177
|
+
for (const line of raw.split("\n")) {
|
|
178
|
+
if (line.startsWith(":")) continue;
|
|
179
|
+
if (line.startsWith("data:")) {
|
|
180
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
181
|
+
data = data === undefined ? value : `${data}\n${value}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final
|
|
189
|
+
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
|
190
|
+
* without it (truncated response — the model call cannot be trusted).
|
|
191
|
+
*/
|
|
192
|
+
async function* parseSse(stream, onComment) {
|
|
193
|
+
const decoder = new TextDecoder();
|
|
194
|
+
const reader = stream.getReader();
|
|
195
|
+
let buffer = "";
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
const { done, value } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
201
|
+
let sep;
|
|
202
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
203
|
+
const raw = buffer.slice(0, sep);
|
|
204
|
+
buffer = buffer.slice(sep + 2);
|
|
205
|
+
const data = parseEvent(raw);
|
|
206
|
+
if (data !== undefined) {
|
|
207
|
+
if (onComment !== undefined) onComment();
|
|
208
|
+
yield data;
|
|
209
|
+
if (data === "[DONE]") return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
reader.releaseLock();
|
|
215
|
+
}
|
|
216
|
+
throw new LlmError("WorkBuddy SSE stream ended without [DONE]", "STREAM_CLOSED");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// #endregion
|
|
220
|
+
|
|
221
|
+
// #region translate
|
|
222
|
+
|
|
223
|
+
/** Map the wire finish_reason vocabulary to the harness FinishReason. */
|
|
224
|
+
function mapFinishReason(reason) {
|
|
225
|
+
switch (reason) {
|
|
226
|
+
case "stop": return { kind: "stop" };
|
|
227
|
+
case "tool_calls": return { kind: "tool-calls" };
|
|
228
|
+
case "length": return { kind: "max-tokens" };
|
|
229
|
+
default: return {
|
|
230
|
+
kind: "error",
|
|
231
|
+
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Map wire usage fields to the harness DISJOINT TokenUsage convention
|
|
238
|
+
* (cache reads are subtracted out of `inputTokens`).
|
|
239
|
+
*/
|
|
240
|
+
function mapUsage(usage) {
|
|
241
|
+
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens;
|
|
242
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
243
|
+
return {
|
|
244
|
+
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
|
|
245
|
+
outputTokens: usage.completion_tokens,
|
|
246
|
+
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
247
|
+
...(reasoning !== undefined ? { reasoningTokens: reasoning } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
252
|
+
function closeBlock(block) {
|
|
253
|
+
switch (block.kind) {
|
|
254
|
+
case "text": return { type: "text", text: block.text };
|
|
255
|
+
case "reasoning": return { type: "reasoning", text: block.text };
|
|
256
|
+
case "tool-call": return {
|
|
257
|
+
type: "tool-call",
|
|
258
|
+
id: CallId(block.callId ?? ""),
|
|
259
|
+
name: block.name ?? "",
|
|
260
|
+
arguments: block.text,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
|
|
267
|
+
* Handles standard OpenAI streaming: `delta.content`, `delta.reasoning_content`
|
|
268
|
+
* (DeepSeek-style thinking, surfaced by the proxy's DSML parsing) and
|
|
269
|
+
* `delta.tool_calls` keyed by `call.index`. `block-end`s, `usage`, and
|
|
270
|
+
* `finish` are all deferred to the `[DONE]` sentinel.
|
|
271
|
+
*/
|
|
272
|
+
async function* translate(payloads) {
|
|
273
|
+
let nextIndex = 0;
|
|
274
|
+
let textBlock;
|
|
275
|
+
let reasoningBlock;
|
|
276
|
+
const toolBlocks = new Map();
|
|
277
|
+
const order = [];
|
|
278
|
+
let pendingFinish;
|
|
279
|
+
let pendingUsage;
|
|
280
|
+
|
|
281
|
+
function open(kind) {
|
|
282
|
+
const block = { index: nextIndex++, kind, text: "" };
|
|
283
|
+
order.push(block);
|
|
284
|
+
return block;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for await (const payload of payloads) {
|
|
288
|
+
if (payload === "[DONE]") {
|
|
289
|
+
for (const block of order) yield { type: "block-end", index: block.index, block: closeBlock(block) };
|
|
290
|
+
if (pendingUsage) yield { type: "usage", usage: pendingUsage };
|
|
291
|
+
const reason = pendingFinish ?? { kind: "stop" };
|
|
292
|
+
yield {
|
|
293
|
+
type: "finish",
|
|
294
|
+
reason: reason.kind === "stop" && order.length === 0
|
|
295
|
+
? { kind: "error", failure: { message: "model returned a completed response with no content", code: EMPTY_RESPONSE_CODE } }
|
|
296
|
+
: reason,
|
|
297
|
+
};
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
let chunk;
|
|
301
|
+
try {
|
|
302
|
+
chunk = JSON.parse(payload);
|
|
303
|
+
} catch {
|
|
304
|
+
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
305
|
+
}
|
|
306
|
+
for (const choice of chunk.choices ?? []) {
|
|
307
|
+
const delta = choice.delta;
|
|
308
|
+
const reasoning = delta?.reasoning_content;
|
|
309
|
+
if (typeof reasoning === "string" && reasoning.length > 0) {
|
|
310
|
+
if (!reasoningBlock) {
|
|
311
|
+
reasoningBlock = open("reasoning");
|
|
312
|
+
yield { type: "block-start", index: reasoningBlock.index, blockType: "reasoning" };
|
|
313
|
+
}
|
|
314
|
+
reasoningBlock.text += reasoning;
|
|
315
|
+
yield { type: "reasoning-delta", index: reasoningBlock.index, text: reasoning };
|
|
316
|
+
}
|
|
317
|
+
const content = delta?.content;
|
|
318
|
+
if (typeof content === "string" && content.length > 0) {
|
|
319
|
+
if (!textBlock) {
|
|
320
|
+
textBlock = open("text");
|
|
321
|
+
yield { type: "block-start", index: textBlock.index, blockType: "text" };
|
|
322
|
+
}
|
|
323
|
+
textBlock.text += content;
|
|
324
|
+
yield { type: "text-delta", index: textBlock.index, text: content };
|
|
325
|
+
}
|
|
326
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
327
|
+
let block = toolBlocks.get(call.index);
|
|
328
|
+
if (!block) {
|
|
329
|
+
block = open("tool-call");
|
|
330
|
+
toolBlocks.set(call.index, block);
|
|
331
|
+
yield { type: "block-start", index: block.index, blockType: "tool-call" };
|
|
332
|
+
}
|
|
333
|
+
// The CodeBuddy upstream repeats `function.name: ""` (and omits `id`)
|
|
334
|
+
// on every argument chunk after the first. Treat empty strings as
|
|
335
|
+
// absent so the first captured id/name is never clobbered.
|
|
336
|
+
if (typeof call.id === "string" && call.id.length > 0) block.callId = call.id;
|
|
337
|
+
const toolName = call.function?.name;
|
|
338
|
+
if (typeof toolName === "string" && toolName.length > 0) block.name = toolName;
|
|
339
|
+
const fragment = call.function?.arguments ?? "";
|
|
340
|
+
block.text += fragment;
|
|
341
|
+
yield {
|
|
342
|
+
type: "tool-call-delta",
|
|
343
|
+
index: block.index,
|
|
344
|
+
id: CallId(block.callId ?? ""),
|
|
345
|
+
...(block.name !== undefined ? { name: block.name } : {}),
|
|
346
|
+
argumentsDelta: fragment,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
|
|
350
|
+
}
|
|
351
|
+
if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
|
|
352
|
+
}
|
|
353
|
+
throw new LlmError("WorkBuddy SSE payload stream ended without [DONE]", "STREAM_CLOSED");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// #endregion
|
|
357
|
+
|
|
358
|
+
// #region adapter
|
|
359
|
+
|
|
360
|
+
/** Display metadata for one catalog entry. */
|
|
361
|
+
function modelInfo(provider, model) {
|
|
362
|
+
return {
|
|
363
|
+
provider,
|
|
364
|
+
id: model.id,
|
|
365
|
+
name: model.name ?? model.id,
|
|
366
|
+
...(model.description === undefined ? {} : { description: model.description }),
|
|
367
|
+
inputModalities: model.inputModalities ?? ["text"],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Normalize a Retry-After header into milliseconds, when valid. */
|
|
372
|
+
function providerRetryAfterMs(value) {
|
|
373
|
+
if (value === null) return undefined;
|
|
374
|
+
if (/^\d+$/.test(value)) {
|
|
375
|
+
const delay = Number(value) * 1000;
|
|
376
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined;
|
|
377
|
+
}
|
|
378
|
+
const delay = Date.parse(value) - Date.now();
|
|
379
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Opaque provider request id from response headers, when present. */
|
|
383
|
+
function requestId(headers) {
|
|
384
|
+
const value = headers.get("x-request-id") ?? headers.get("x-deepseek-request-id");
|
|
385
|
+
return value === null || value.length === 0 ? undefined : ProviderRequestId(value);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Map an HTTP status plus provider error detail to a stable LlmError code. */
|
|
389
|
+
function httpErrorCode(status, error) {
|
|
390
|
+
if (status === 401 || status === 403) return "AUTH";
|
|
391
|
+
if (status === 413) return "INVALID_REQUEST";
|
|
392
|
+
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(" ");
|
|
393
|
+
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
|
|
394
|
+
if (status === 429) return "RATE_LIMIT";
|
|
395
|
+
if (status === 400) {
|
|
396
|
+
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
397
|
+
return "INVALID_REQUEST";
|
|
398
|
+
}
|
|
399
|
+
if (status >= 500) return "SERVER";
|
|
400
|
+
return `HTTP_${status}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The WorkBuddy adapter: fetch + SSE against the OpenAI-compatible
|
|
405
|
+
* workbuddy2api proxy, emitting harness StreamChunks. Connection facts arrive
|
|
406
|
+
* through a thunk resolved once per operation, so the registering plugin owns
|
|
407
|
+
* validation and layering.
|
|
408
|
+
*/
|
|
409
|
+
export class WorkBuddyAdapter extends LlmAdapter {
|
|
410
|
+
constructor(config) {
|
|
411
|
+
super();
|
|
412
|
+
this.config = config;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
providerInfo(provider) {
|
|
416
|
+
return { id: provider, name: "WorkBuddy" };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async listModels(provider) {
|
|
420
|
+
const connection = this.config.options();
|
|
421
|
+
if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
|
|
422
|
+
try {
|
|
423
|
+
const cached = this._cache;
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (cached !== undefined && now - cached.at < DISCOVERY_TTL_MS) return cached.models;
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
|
|
428
|
+
let payload;
|
|
429
|
+
try {
|
|
430
|
+
const response = await fetch(`${connection.baseURL}/models`, { signal: controller.signal });
|
|
431
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
432
|
+
payload = await response.json();
|
|
433
|
+
} finally {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
}
|
|
436
|
+
const byId = new Map(connection.models.map((model) => [model.id, model]));
|
|
437
|
+
// The workbuddy2api proxy answers `{"models": [...]}` with `display_name`
|
|
438
|
+
// fields; plain OpenAI endpoints answer `{"data": [...]}` with `name`.
|
|
439
|
+
const list = payload?.data ?? payload?.models ?? [];
|
|
440
|
+
const live = list
|
|
441
|
+
.map((entry) => ({
|
|
442
|
+
id: String(entry.id),
|
|
443
|
+
name: typeof entry.name === "string"
|
|
444
|
+
? entry.name
|
|
445
|
+
: typeof entry.display_name === "string"
|
|
446
|
+
? entry.display_name
|
|
447
|
+
: undefined,
|
|
448
|
+
}))
|
|
449
|
+
.filter((entry) => entry.id.length > 0);
|
|
450
|
+
const merged = [];
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
for (const entry of live) {
|
|
453
|
+
if (seen.has(entry.id)) continue;
|
|
454
|
+
seen.add(entry.id);
|
|
455
|
+
const catalog = byId.get(entry.id);
|
|
456
|
+
merged.push({
|
|
457
|
+
provider,
|
|
458
|
+
id: entry.id,
|
|
459
|
+
name: entry.name ?? catalog?.name ?? entry.id,
|
|
460
|
+
...(catalog?.description !== undefined ? { description: catalog.description } : {}),
|
|
461
|
+
inputModalities: catalog?.inputModalities ?? ["text"],
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// Catalog entries the proxy did not announce (e.g. unauthenticated or
|
|
465
|
+
// partial listing) stay selectable.
|
|
466
|
+
for (const model of connection.models) {
|
|
467
|
+
if (seen.has(model.id)) continue;
|
|
468
|
+
merged.push(modelInfo(provider, model));
|
|
469
|
+
}
|
|
470
|
+
const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
|
|
471
|
+
this._cache = { at: now, models: result };
|
|
472
|
+
return result;
|
|
473
|
+
} catch {
|
|
474
|
+
return connection.models.map((model) => modelInfo(provider, model));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
resolveModel(provider, model, _signal) {
|
|
479
|
+
const connection = this.config.options();
|
|
480
|
+
const configured = connection.models.find((entry) => entry.id === model);
|
|
481
|
+
return Promise.resolve({
|
|
482
|
+
...(configured === undefined
|
|
483
|
+
? { provider, id: model, name: model, inputModalities: ["text"] }
|
|
484
|
+
: modelInfo(provider, configured)),
|
|
485
|
+
context: { contextWindow: configured?.contextWindow ?? connection.defaultContextWindow },
|
|
486
|
+
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async *stream(options) {
|
|
491
|
+
const connection = this.config.options();
|
|
492
|
+
const consumer = new AbortController();
|
|
493
|
+
const watchdog = idleWatchdog(
|
|
494
|
+
options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]),
|
|
495
|
+
connection.streamIdleTimeoutMs,
|
|
496
|
+
STREAM_IDLE_TIMEOUT_CODE,
|
|
497
|
+
);
|
|
498
|
+
const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
|
|
499
|
+
let exhausted = false;
|
|
500
|
+
try {
|
|
501
|
+
while (true) {
|
|
502
|
+
const result = await watchdog.next(iterator);
|
|
503
|
+
if (result.done) {
|
|
504
|
+
exhausted = true;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
yield result.value;
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
|
511
|
+
throw new LlmError(`WorkBuddy stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
|
|
512
|
+
}
|
|
513
|
+
if (options.signal?.aborted) throw new LlmError("WorkBuddy request aborted by caller", "ABORTED", { cause: error });
|
|
514
|
+
if (error instanceof LlmError) throw error;
|
|
515
|
+
throw new LlmError(`WorkBuddy proxy stream from ${connection.baseURL} failed`, "TRANSPORT", { cause: error });
|
|
516
|
+
} finally {
|
|
517
|
+
consumer.abort("WorkBuddy stream consumer stopped");
|
|
518
|
+
if (!exhausted && iterator.return !== undefined) {
|
|
519
|
+
try {
|
|
520
|
+
await iterator.return();
|
|
521
|
+
} catch (_abortedTransportTeardown) {
|
|
522
|
+
// transport teardown after abort is expected
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async *request(options, signal, connection, onComment) {
|
|
529
|
+
const body = serializeRequest(options);
|
|
530
|
+
const headers = {
|
|
531
|
+
"content-type": "application/json",
|
|
532
|
+
"accept": "text/event-stream",
|
|
533
|
+
...attributionHeaders(),
|
|
534
|
+
...(connection.apiKey !== undefined && connection.apiKey.length > 0
|
|
535
|
+
? { authorization: `Bearer ${connection.apiKey}` }
|
|
536
|
+
: {}),
|
|
537
|
+
};
|
|
538
|
+
let response;
|
|
539
|
+
try {
|
|
540
|
+
response = await fetch(`${connection.baseURL}/chat/completions`, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers,
|
|
543
|
+
body: JSON.stringify(body),
|
|
544
|
+
signal,
|
|
545
|
+
});
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (signal.aborted) throw error;
|
|
548
|
+
throw new LlmError(`WorkBuddy proxy request to ${connection.baseURL} failed`, "TRANSPORT", { cause: error });
|
|
549
|
+
}
|
|
550
|
+
if (!response.ok) {
|
|
551
|
+
let message = `WorkBuddy proxy error (HTTP ${response.status})`;
|
|
552
|
+
let providerError;
|
|
553
|
+
try {
|
|
554
|
+
providerError = (await response.json()).error;
|
|
555
|
+
if (providerError?.message) message = providerError.message;
|
|
556
|
+
} catch {
|
|
557
|
+
// non-JSON error body; keep the generic message
|
|
558
|
+
}
|
|
559
|
+
const delay = providerRetryAfterMs(response.headers.get("retry-after"));
|
|
560
|
+
const id = requestId(response.headers);
|
|
561
|
+
throw new LlmError(message, httpErrorCode(response.status, providerError), {
|
|
562
|
+
status: response.status,
|
|
563
|
+
...(delay === undefined ? {} : { providerRetryAfterMs: delay }),
|
|
564
|
+
...(id === undefined ? {} : { requestId: id }),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (!response.body) throw new LlmError("WorkBuddy proxy returned no response body", "EMPTY_RESPONSE");
|
|
568
|
+
yield* translate(parseSse(response.body, onComment));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// #endregion
|
|
573
|
+
|
|
574
|
+
// #region config
|
|
575
|
+
|
|
576
|
+
const MODEL_MODALITIES = ["text", "image"];
|
|
577
|
+
|
|
578
|
+
const catalogModel = z.object({
|
|
579
|
+
id: z.string().required(),
|
|
580
|
+
name: z.string(),
|
|
581
|
+
description: z.string(),
|
|
582
|
+
contextWindow: z.number().step(1).min(1),
|
|
583
|
+
maxTokens: z.number().step(1).min(1),
|
|
584
|
+
inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(["text"]),
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
export const Config = z.object({
|
|
588
|
+
baseURL: z.string().default(DEFAULT_BASE_URL),
|
|
589
|
+
apiKey: z.string(),
|
|
590
|
+
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
|
|
591
|
+
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
|
592
|
+
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
|
593
|
+
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
594
|
+
discovery: z.boolean().default(true),
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
/** Resolve, validate, and detach the advisory model catalog. */
|
|
598
|
+
function resolveModels(models) {
|
|
599
|
+
const seen = new Set();
|
|
600
|
+
return (models ?? DEFAULT_MODELS).map((model) => {
|
|
601
|
+
if (model.id.length === 0) throw new Error("dsh-llm-workbuddy: catalog model ids must be non-empty");
|
|
602
|
+
if (model.name !== undefined && model.name.length === 0) throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" has an empty name`);
|
|
603
|
+
if (model.contextWindow !== undefined && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
|
|
604
|
+
throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" contextWindow must be a positive integer`);
|
|
605
|
+
}
|
|
606
|
+
if (model.maxTokens !== undefined && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
|
|
607
|
+
throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" maxTokens must be a positive integer`);
|
|
608
|
+
}
|
|
609
|
+
const inputModalities = model.inputModalities ?? ["text"];
|
|
610
|
+
if (inputModalities.length === 0) throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" inputModalities must not be empty`);
|
|
611
|
+
if (inputModalities.some((modality) => !MODEL_MODALITIES.includes(modality))) {
|
|
612
|
+
throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" inputModalities must contain only "text" and "image"`);
|
|
613
|
+
}
|
|
614
|
+
if (new Set(inputModalities).size !== inputModalities.length) {
|
|
615
|
+
throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" inputModalities must not contain duplicates`);
|
|
616
|
+
}
|
|
617
|
+
if (seen.has(model.id)) throw new Error(`dsh-llm-workbuddy: duplicate catalog model "${model.id}"`);
|
|
618
|
+
seen.add(model.id);
|
|
619
|
+
return {
|
|
620
|
+
id: model.id,
|
|
621
|
+
...(model.name === undefined ? {} : { name: model.name }),
|
|
622
|
+
...(model.description === undefined ? {} : { description: model.description }),
|
|
623
|
+
...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
|
|
624
|
+
...(model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),
|
|
625
|
+
inputModalities: [...inputModalities],
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The one explicit resolve step from raw config to validated connection
|
|
632
|
+
* facts. Judged at load (fail loud) and for each settings snapshot.
|
|
633
|
+
*/
|
|
634
|
+
export function resolveAdapterOptions(config) {
|
|
635
|
+
const baseURL = config.baseURL ?? DEFAULT_BASE_URL;
|
|
636
|
+
if (typeof baseURL !== "string" || baseURL.length === 0) throw new Error("dsh-llm-workbuddy: baseURL must be a non-empty string");
|
|
637
|
+
if (config.defaultContextWindow !== undefined && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
|
|
638
|
+
throw new Error("dsh-llm-workbuddy: defaultContextWindow must be a positive integer");
|
|
639
|
+
}
|
|
640
|
+
if (config.maxTokens !== undefined && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
|
|
641
|
+
throw new Error("dsh-llm-workbuddy: maxTokens must be a positive safe integer");
|
|
642
|
+
}
|
|
643
|
+
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
644
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
645
|
+
throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
baseURL,
|
|
649
|
+
apiKey: config.apiKey,
|
|
650
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
651
|
+
defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
652
|
+
models: resolveModels(config.models),
|
|
653
|
+
streamIdleTimeoutMs,
|
|
654
|
+
discovery: config.discovery ?? true,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// #endregion
|
|
659
|
+
|
|
660
|
+
// #region login status web API
|
|
661
|
+
|
|
662
|
+
/** Resolve the repo root that holds `login_workbuddy.py` and `.workbuddy/`. */
|
|
663
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
664
|
+
// dsh-llm-workbuddy/lib/index.js -> dsh-workbuddy/ (two levels up).
|
|
665
|
+
const REPO_ROOT = resolve(PLUGIN_DIR, "..", "..");
|
|
666
|
+
const SESSION_FILE = resolve(REPO_ROOT, ".workbuddy", "session.json");
|
|
667
|
+
const LOGIN_SCRIPT = resolve(REPO_ROOT, "login_workbuddy.py");
|
|
668
|
+
const WORKBUDDY_UV = resolve(REPO_ROOT, ".tools", "uv");
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Read the local WorkBuddy session file and derive its validity. Returns a
|
|
672
|
+
* normalized status object the Web widget polls. Never throws — missing or
|
|
673
|
+
* malformed state is reported as `authenticated: false` so the UI can prompt.
|
|
674
|
+
*/
|
|
675
|
+
function readSessionStatus() {
|
|
676
|
+
if (!existsSync(SESSION_FILE)) {
|
|
677
|
+
return { sessionFile: false, authenticated: false, expiresAt: null, account: null };
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const raw = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
|
|
681
|
+
const expiresAt = raw?.auth?.expiresAt ?? null;
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
const expired = expiresAt !== null && expiresAt <= now;
|
|
684
|
+
return {
|
|
685
|
+
sessionFile: true,
|
|
686
|
+
authenticated: !expired && Boolean(raw?.auth?.accessToken),
|
|
687
|
+
expiresAt,
|
|
688
|
+
account: raw?.account ?? null,
|
|
689
|
+
};
|
|
690
|
+
} catch {
|
|
691
|
+
return { sessionFile: true, authenticated: false, expiresAt: null, account: null };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Probe the local workbuddy2api proxy health endpoint. Returns
|
|
697
|
+
* `{ proxyUp, tokenValid }`; a missing/unreachable proxy is reported as
|
|
698
|
+
* `proxyUp: false` without throwing, so the widget can show a clear state.
|
|
699
|
+
*/
|
|
700
|
+
async function probeProxy(baseURL) {
|
|
701
|
+
const controller = new AbortController();
|
|
702
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
703
|
+
try {
|
|
704
|
+
const response = await fetch(`${baseURL.replace(/\/v1\/?$/, "")}/health`, {
|
|
705
|
+
signal: controller.signal,
|
|
706
|
+
});
|
|
707
|
+
if (!response.ok) return { proxyUp: true, tokenValid: false };
|
|
708
|
+
const body = await response.json().catch(() => ({}));
|
|
709
|
+
return { proxyUp: true, tokenValid: Boolean(body?.authenticated ?? body?.token_valid) };
|
|
710
|
+
} catch {
|
|
711
|
+
return { proxyUp: false, tokenValid: false };
|
|
712
|
+
} finally {
|
|
713
|
+
clearTimeout(timer);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Register `GET /api/workbuddy/status` and `POST /api/workbuddy/login` on the
|
|
719
|
+
* DSH web server. The login route spawns `login_workbuddy.py`, parses the
|
|
720
|
+
* device-flow `authUrl` it prints to stdout, and returns it so the browser
|
|
721
|
+
* can open it in a new tab. The widget then polls `/status` until the session
|
|
722
|
+
* file appears and reports `authenticated: true`.
|
|
723
|
+
*/
|
|
724
|
+
function registerWorkbuddyRoutes(ctx) {
|
|
725
|
+
if (ctx.webServer === undefined) return; // headless profile: no HTTP surface
|
|
726
|
+
const baseURL = config_baseURL();
|
|
727
|
+
ctx.webServer.register({
|
|
728
|
+
path: "/api/workbuddy/status",
|
|
729
|
+
exact: true,
|
|
730
|
+
methods: ["GET"],
|
|
731
|
+
async handler(_req, res) {
|
|
732
|
+
const session = readSessionStatus();
|
|
733
|
+
const proxy = await probeProxy(baseURL);
|
|
734
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
735
|
+
res.end(JSON.stringify({
|
|
736
|
+
...session,
|
|
737
|
+
...proxy,
|
|
738
|
+
loginScriptAvailable: existsSync(LOGIN_SCRIPT),
|
|
739
|
+
}));
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
ctx.webServer.register({
|
|
743
|
+
path: "/api/workbuddy/login",
|
|
744
|
+
exact: true,
|
|
745
|
+
methods: ["POST"],
|
|
746
|
+
async handler(_req, res) {
|
|
747
|
+
if (!existsSync(LOGIN_SCRIPT)) {
|
|
748
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
749
|
+
res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Existing session is fine; no need to re-run the flow.
|
|
753
|
+
const existing = readSessionStatus();
|
|
754
|
+
if (existing.authenticated) {
|
|
755
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
756
|
+
res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const args = ["run", "--python", "3.12", "python", "-u", LOGIN_SCRIPT, "--session-file", SESSION_FILE];
|
|
760
|
+
const child = spawn(WORKBUDDY_UV, args, { cwd: REPO_ROOT, env: { ...process.env, UV_PYTHON_INSTALL_DIR: resolve(REPO_ROOT, ".tools", "uv-python"), UV_CACHE_DIR: resolve(REPO_ROOT, ".tools", "uv-cache"), PYTHONUNBUFFERED: "1" } });
|
|
761
|
+
let stdout = "";
|
|
762
|
+
let authUrl = null;
|
|
763
|
+
child.stdout.on("data", (chunk) => {
|
|
764
|
+
stdout += chunk.toString();
|
|
765
|
+
const m = stdout.match(/https?:\/\/\S+/);
|
|
766
|
+
if (m && authUrl === null) authUrl = m[0];
|
|
767
|
+
});
|
|
768
|
+
child.stderr.on("data", () => {});
|
|
769
|
+
// The device flow blocks until login or timeout; we hand back the URL
|
|
770
|
+
// immediately and let the widget poll /status for completion.
|
|
771
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
772
|
+
res.end(JSON.stringify({ authUrl, pending: true }));
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Lazily resolve the configured proxy base URL for the health probe. */
|
|
778
|
+
let _baseURL = DEFAULT_BASE_URL;
|
|
779
|
+
function config_baseURL() {
|
|
780
|
+
return _baseURL;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// #endregion
|
|
784
|
+
|
|
785
|
+
// #region plugin
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Register a {@link WorkBuddyAdapter} for the `workbuddy` provider route on
|
|
789
|
+
* `ctx.llm`. Connection facts resolve per request instead of freezing at
|
|
790
|
+
* load: the plugin layers its `cordis.yml` entry config under the optional
|
|
791
|
+
* `llm-workbuddy` user-settings section (`ctx.settings`), so a changed base
|
|
792
|
+
* URL or catalog reaches the very next request without restarting anything.
|
|
793
|
+
*/
|
|
794
|
+
export function apply(ctx, config) {
|
|
795
|
+
_baseURL = resolveAdapterOptions(config).baseURL;
|
|
796
|
+
let current = () => config;
|
|
797
|
+
let lastRaw;
|
|
798
|
+
let lastGood;
|
|
799
|
+
const options = () => {
|
|
800
|
+
const raw = current();
|
|
801
|
+
if (raw === lastRaw && lastGood !== undefined) return lastGood;
|
|
802
|
+
try {
|
|
803
|
+
const next = resolveAdapterOptions(raw);
|
|
804
|
+
lastRaw = raw;
|
|
805
|
+
lastGood = next;
|
|
806
|
+
return next;
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (lastGood === undefined) throw error;
|
|
809
|
+
lastRaw = raw;
|
|
810
|
+
ctx.logger.error("dsh-llm-workbuddy: keeping the last good configuration after an invalid settings section");
|
|
811
|
+
ctx.logger.error(error);
|
|
812
|
+
return lastGood;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
options();
|
|
816
|
+
const adapter = new WorkBuddyAdapter({ options });
|
|
817
|
+
ctx.llm.registerConfigurableProviders([{
|
|
818
|
+
provider: PROVIDER,
|
|
819
|
+
displayName: "WorkBuddy",
|
|
820
|
+
settingsNs: NS,
|
|
821
|
+
settingsPath: [],
|
|
822
|
+
}]);
|
|
823
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
824
|
+
registerWorkbuddyRoutes(ctx);
|
|
825
|
+
installSettingsSection(ctx, NS, Config, config, {
|
|
826
|
+
setSource: (source) => {
|
|
827
|
+
current = source;
|
|
828
|
+
},
|
|
829
|
+
onChange: () => {
|
|
830
|
+
// no registration-level facts are derived from the source; nothing to re-judge
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// #endregion
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-llm-workbuddy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WorkBuddy (via the local workbuddy2api proxy) LLM provider adapter for DeepSeek Harness, with a Web login-status widget",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "dengkun.zhang",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"dsh",
|
|
12
|
+
"plugin",
|
|
13
|
+
"llm",
|
|
14
|
+
"workbuddy",
|
|
15
|
+
"codebuddy",
|
|
16
|
+
"provider"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": ""
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"default": "./lib/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./client": "./lib/client.js",
|
|
35
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
45
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
47
|
+
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
49
|
+
}
|
|
50
|
+
}
|