dsh-llm-workbuddy 0.1.4 → 0.1.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dengkun Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -92,33 +92,53 @@ CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile
92
92
 
93
93
  ## 安装代理(先决条件,第三方)
94
94
 
95
- 插件只提供模型路由与登录;真正的协议转换由 **workbuddy2api** 完成。请单独安装并
96
- 启动它(以 127.0.0.1:8787 为例):
95
+ 插件只提供模型路由与登录;真正的协议转换由 **workbuddy2api**(第三方开源项目)完成。
96
+ 它**不在本插件包里**,需要单独安装并启动在 `127.0.0.1:8787`。分两种情况:
97
+
98
+ ### 全新机器 / 标准安装(任何能跑 Python 的机器)
99
+
100
+ 别人或你自己在**新机器**上用 npm 版插件时,按以下 3 步装代理:
97
101
 
98
102
  ```sh
99
- # 1. 安装 uv(本机 Python 工具)
103
+ # 1. 安装 uv(本机 Python 工具,若已装可跳过)
100
104
  curl -LsSf https://astral.sh/uv/install.sh | sh
101
105
 
102
106
  # 2. 拉取 workbuddy2api 主分支源码
103
107
  git clone https://github.com/hawklithm/workbuddy2api.git
104
108
  cd workbuddy2api
105
109
 
106
- # 3. 启动代理(先完成下方「登录」后再请求)
110
+ # 3. 启动代理(监听 127.0.0.1:8787;先完成下方「登录」后再真正调用模型)
107
111
  uv run python -u -m codebuddy_proxy --desensitize \
108
112
  --session-file ~/.codebuddy-session.json \
109
113
  --log-file ~/.codebuddy-proxy.jsonl
110
- # 监听 http://127.0.0.1:8787
111
114
  ```
112
115
 
113
- > 仓库根目录也有 `start-workbuddy.sh` 脚本(面向仓库本地开发),发布版插件不依赖它。
116
+ > 之后任何时候想重启代理,就在 `workbuddy2api` 目录里重跑上面第 3 条命令。
117
+ > 若提示 `address already in use`,先停掉旧代理:
118
+ > `lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill`,再重跑。
119
+
120
+ ### 本仓库本地开发(可选快捷方式)
121
+
122
+ 仓库根目录提供 `start-workbuddy.sh`,它会用仓库固定的 `main` 分支源码 + 本地
123
+ `.tools/uv` 工具链 + 仓库内的 `.workbuddy/session.json` 启动代理。它**不随 npm
124
+ 包发布**,仅面向本仓库开发时方便:
114
125
 
115
- 验证:
126
+ ```sh
127
+ cd /path/to/dsh-workbuddy
128
+ ./start-workbuddy.sh
129
+ ```
130
+
131
+ ### 验证代理是否真的能用
116
132
 
117
133
  ```sh
118
134
  curl http://127.0.0.1:8787/health # {"status":"ok","authenticated":true,...}
119
135
  curl http://127.0.0.1:8787/v1/models # 模型列表(含 glm-5.2 / deepseek-v4-pro ...)
120
136
  ```
121
137
 
138
+ > ⚠️ `/health` 只证明**代理进程活着**,不能证明模型能真正出字(代理进程指向旧
139
+ > 路径/文件缺失时 `/health` 仍返回 `authenticated: true`,但所有模型请求 500)。
140
+ > 想确认"模型真能用",用右下角胶囊的 **🔍 诊断** 按钮,它会真实发一次模型请求。
141
+
122
142
  > 登录态过期时 `authenticated` 变回 `false`,重跑登录即可,代理无需重启
123
143
  > (会话按请求读取)。
124
144
 
@@ -174,6 +194,7 @@ Web 登录后终端脚本也读得到同一份会话。
174
194
  |---|---|
175
195
  | `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
176
196
  | `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
197
+ | `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
177
198
 
178
199
  > 会话文件与登录脚本路径的解析顺序:
179
200
  > 1. 配置里显式指定的 `sessionFile` / `loginScript`;
@@ -186,6 +207,9 @@ Web 登录后终端脚本也读得到同一份会话。
186
207
  - 启动时在 `document.body` 末尾挂一个 `position:fixed` 的胶囊(右下角)。
187
208
  - 每 **5 秒** `GET /api/workbuddy/status`;点「登录」后加快到每 **2 秒**轮询、
188
209
  最多 30 次,直到 `authenticated:true`。
210
+ - 胶囊里有个 **🔍 诊断** 按钮:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
211
+ 显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
212
+ 发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
189
213
  - 状态映射:
190
214
  - `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
191
215
  - 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
@@ -262,6 +286,7 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
262
286
  | 右下角没有胶囊 | `dsh web` 没重启加载新 bundle → 重启 `dsh web`;或 `curl /plugins/dsh-llm-workbuddy/client.js` 应返回 200 |
263
287
  | 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
264
288
  | 胶囊红 + `代理未运行` | workbuddy2api 代理没起或挂了 → 按「安装代理」章节启动 |
289
+ | 胶囊显示登录成功但模型用不了 | 典型的"代理进程活着但指向旧路径/文件缺失"假象(`/health` 仍显示 ok)。点胶囊 **🔍 诊断**,看 `模型出字` 是否失败;按面板给出的重启命令重启代理 |
265
290
  | 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装 |
266
291
  | 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或 `expiresAt` 已过期 → 刷新页面或重跑登录 |
267
292
  | 启动 `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 |
@@ -284,8 +309,8 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
284
309
 
285
310
  | 路径 | 作用 |
286
311
  |---|---|
287
- | `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由注册 |
288
- | `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程),被 `dsh.client` 注入 |
312
+ | `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由(status / login / **diagnose**) |
313
+ | `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程 + **🔍 诊断**),被 `dsh.client` 注入 |
289
314
  | `login_workbuddy.py` | 设备流登录脚本(随包发布,被后端路由用系统 `python3` spawn) |
290
315
  | `cordis.patch.yml` | 本包的 Cordis bundle 挂载声明(`id: llm-workbuddy`) |
291
316
  | `package.json` | 包元数据、`dsh.client` 浏览器入口声明、`llm-workbuddy` peer 依赖 |
package/lib/client.js CHANGED
@@ -105,11 +105,15 @@ function runWidget() {
105
105
  html += '<button class="wb-btn" id="wb-login"' + disabled + ">" +
106
106
  (busy ? "登录中…" : "登录") + "</button>";
107
107
  }
108
+ // 诊断按钮始终显示,便于随时自查真实健康状态。
109
+ html += '<button class="wb-btn wb-diagnose" id="wb-diagnose" title="一键诊断">🔍</button>';
108
110
  if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
109
111
  html += "</div>";
110
112
  host.innerHTML = html;
111
113
  var btn = host.querySelector("#wb-login");
112
114
  if (btn) btn.addEventListener("click", onLogin);
115
+ var dg = host.querySelector("#wb-diagnose");
116
+ if (dg) dg.addEventListener("click", onDiagnose);
113
117
  }
114
118
 
115
119
  function refresh() {
@@ -151,6 +155,63 @@ function runWidget() {
151
155
  });
152
156
  }
153
157
 
158
+ /** 一键诊断:真实探测代理健康,必要时给出可复制的重启命令。 */
159
+ function onDiagnose() {
160
+ var panel = document.getElementById("wb-diagnose-panel");
161
+ if (panel) { panel.remove(); return; }
162
+ var el = document.createElement("div");
163
+ el.id = "wb-diagnose-panel";
164
+ el.style.cssText =
165
+ "position:fixed;right:12px;bottom:44px;z-index:2147483647;width:340px;max-height:340px;overflow:auto;" +
166
+ "background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;" +
167
+ "padding:10px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.5);";
168
+ el.innerHTML = '<div class="wb-dg-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 诊断中…</div>' +
169
+ '<div class="wb-dg-body" style="white-space:pre-wrap;word-break:break-word;color:#94a3b8">正在探测…</div>';
170
+ document.body.appendChild(el);
171
+
172
+ fetch("/api/workbuddy/diagnose", { method: "POST" })
173
+ .then(function (r) { return r.json(); })
174
+ .then(function (d) {
175
+ if (!d) throw new Error("空响应");
176
+ var title = d.ok ? "✅ 一切正常" : "⚠️ 发现问题";
177
+ var lines = [];
178
+ lines.push("登录状态: " + (d.session && d.session.authenticated ? "有效" : "无效"));
179
+ lines.push("会话文件: " + (d.session && d.session.file ? "存在" : "缺失"));
180
+ lines.push("代理进程: " + (d.health && d.health.proxyUp ? "在运行" : "未运行"));
181
+ lines.push("登录令牌: " + (d.health && d.health.tokenValid ? "有效" : "无效"));
182
+ lines.push("模型出字: " + (d.chat && d.chat.chatWorking ? "正常" : "失败"));
183
+ if (d.chat && d.chat.chatError) lines.push("出字错误: " + d.chat.chatError);
184
+ var html = '<div class="wb-dg-title" style="font-weight:700;margin-bottom:6px">' + title + '</div>' +
185
+ '<div class="wb-dg-body" style="white-space:pre-wrap;word-break:break-word">' + lines.map(esc).join("\n") + "</div>";
186
+ if (!d.ok && d.restartCommand) {
187
+ html += '<div style="margin-top:8px;font-weight:700">重启命令(复制到终端执行):</div>' +
188
+ '<pre style="background:#1e293b;border-radius:6px;padding:8px;font-size:11px;overflow:auto;white-space:pre-wrap">' +
189
+ esc(d.restartCommand) + "</pre>" +
190
+ '<button class="wb-btn" id="wb-copy-cmd">复制重启命令</button>';
191
+ }
192
+ el.innerHTML = html;
193
+ var copy = document.getElementById("wb-copy-cmd");
194
+ if (copy) {
195
+ copy.addEventListener("click", function () {
196
+ navigator.clipboard.writeText(d.restartCommand).then(function () {
197
+ copy.textContent = "已复制 ✓";
198
+ setTimeout(function () { copy.textContent = "复制重启命令"; }, 1500);
199
+ });
200
+ });
201
+ }
202
+ })
203
+ .catch(function (err) {
204
+ el.innerHTML = '<div class="wb-dg-title" style="font-weight:700;color:#f87171">诊断失败</div>' +
205
+ '<div class="wb-dg-body">' + esc(String(err && err.message || err)) + "</div>";
206
+ });
207
+ }
208
+
209
+ function esc(s) {
210
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
211
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
212
+ });
213
+ }
214
+
154
215
  refresh();
155
216
  timer = setInterval(refresh, POLL_MS);
156
217
  }
package/lib/index.js CHANGED
@@ -762,11 +762,87 @@ async function probeProxy(baseURL) {
762
762
  }
763
763
 
764
764
  /**
765
- * Register `GET /api/workbuddy/status` and `POST /api/workbuddy/login` on the
766
- * DSH web server. The login route spawns `login_workbuddy.py`, parses the
767
- * device-flow `authUrl` it prints to stdout, and returns it so the browser
768
- * can open it in a new tab. The widget then polls `/status` until the session
769
- * file appears and reports `authenticated: true`.
765
+ * Probe whether the proxy can actually complete a chat completion, not just
766
+ * answer `/health`. `/health` reflects the proxy process alone it stays
767
+ * `authenticated: true` even when the process points at a deleted/old path
768
+ * and every model request fails with `[Errno 2] No such file or directory`.
769
+ * Sending one minimal request is the only probe that catches that class of
770
+ * "capsule shows green but models are all down" false positive.
771
+ * @returns `{ chatWorking, chatError? }` where `chatWorking` is true only
772
+ * when the proxy returned a non-streamed completion.
773
+ */
774
+ async function probeChatWorking(baseURL) {
775
+ const controller = new AbortController();
776
+ const timer = setTimeout(() => controller.abort(), 15000);
777
+ try {
778
+ const endpoint = `${baseURL.replace(/\/v1\/?$/, "")}/v1/chat/completions`;
779
+ const response = await fetch(endpoint, {
780
+ method: "POST",
781
+ headers: { "content-type": "application/json" },
782
+ body: JSON.stringify({
783
+ model: "hy3",
784
+ messages: [{ role: "user", content: "ping" }],
785
+ max_tokens: 4,
786
+ stream: false,
787
+ }),
788
+ signal: controller.signal,
789
+ });
790
+ if (!response.ok) {
791
+ const text = await response.text().catch(() => "");
792
+ return { chatWorking: false, chatError: `HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ""}` };
793
+ }
794
+ const body = await response.json().catch(() => null);
795
+ const content = body?.choices?.[0]?.message?.content;
796
+ if (typeof content === "string" && content.length > 0) {
797
+ return { chatWorking: true };
798
+ }
799
+ return { chatWorking: false, chatError: "代理返回了空响应" };
800
+ } catch (error) {
801
+ return { chatWorking: false, chatError: String(error?.message ?? error) };
802
+ } finally {
803
+ clearTimeout(timer);
804
+ }
805
+ }
806
+
807
+ /**
808
+ * The restart command shown by the diagnose route. It adapts to two layouts:
809
+ * - Local monorepo dev: the plugin sits inside a `dsh-workbuddy/` checkout with
810
+ * `start-workbuddy.sh` two levels up — use the workspace-local script.
811
+ * - Standard install (any other machine): no `start-workbuddy.sh`, so show the
812
+ * upstream README command for running the workbuddy2api proxy via uv.
813
+ */
814
+ function diagnoseRestartCommand() {
815
+ const workspaceRoot = resolve(PLUGIN_DIR, "..", "..");
816
+ const localScript = resolve(workspaceRoot, "start-workbuddy.sh");
817
+ if (existsSync(localScript)) {
818
+ return [
819
+ "# 检测到本机 monorepo 布局(dsh-workbuddy/),用仓库脚本重启代理",
820
+ "# 停掉旧代理(若 8787 被占用)",
821
+ "lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
822
+ "",
823
+ "# 重启代理(脚本路径自动定位到本仓库)",
824
+ `cd ${workspaceRoot} && ./start-workbuddy.sh`,
825
+ ].join("\n");
826
+ }
827
+ return [
828
+ "# 用 workbuddy2api 代理(标准安装)重启:",
829
+ "# 停掉旧代理(若 8787 被占用)",
830
+ "lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
831
+ "",
832
+ "# 拉取并运行代理(详见 README「安装代理」)",
833
+ "git clone https://github.com/hawklithm/workbuddy2api.git && cd workbuddy2api",
834
+ "uv run python -u -m codebuddy_proxy --desensitize \\",
835
+ " --session-file ~/.codebuddy-session.json --log-file ~/.codebuddy-proxy.jsonl",
836
+ ].join("\n");
837
+ }
838
+
839
+ /**
840
+ * Register `GET /api/workbuddy/status`, `POST /api/workbuddy/login`, and
841
+ * `POST /api/workbuddy/diagnose` on the DSH web server. The login route spawns
842
+ * `login_workbuddy.py`, parses the device-flow `authUrl` it prints to stdout,
843
+ * and returns it so the browser can open it in a new tab. The widget then
844
+ * polls `/status` until the session file appears and reports
845
+ * `authenticated: true`.
770
846
  */
771
847
  function registerWorkbuddyRoutes(ctx, config) {
772
848
  // Delay registration until the webServer service exists. The plugin keeps
@@ -843,6 +919,34 @@ function registerWorkbuddyRoutes(ctx, config) {
843
919
  res.end(JSON.stringify({ authUrl, pending: true }));
844
920
  },
845
921
  }));
922
+ webCtx.effect(() => webServer.register({
923
+ kind: "exact",
924
+ path: "/api/workbuddy/diagnose",
925
+ async handler(req, res) {
926
+ if (req.method !== "POST") {
927
+ res.writeHead(405, { "content-type": "application/json" });
928
+ res.end(JSON.stringify({ error: "method not allowed" }));
929
+ return;
930
+ }
931
+ // Real health: probe /health AND actually complete a chat. /health
932
+ // alone stays green even when the proxy points at a deleted path and
933
+ // every model request 500s — the exact "capsule green but models dead"
934
+ // false positive the diagnose button exists to surface.
935
+ const session = readSessionStatus(sessionFile);
936
+ const health = await probeProxy(baseURL);
937
+ const chat = await probeChatWorking(baseURL);
938
+ const ok = Boolean(health.proxyUp && health.tokenValid && session.authenticated && chat.chatWorking);
939
+ res.writeHead(200, { "content-type": "application/json" });
940
+ res.end(JSON.stringify({
941
+ ok,
942
+ session: { file: session.sessionFile, authenticated: session.authenticated, expiresAt: session.expiresAt },
943
+ health,
944
+ chat,
945
+ loginScriptAvailable: existsSync(loginScript),
946
+ restartCommand: ok ? null : diagnoseRestartCommand(),
947
+ }));
948
+ },
949
+ }));
846
950
  });
847
951
  }
848
952
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "WorkBuddy (via the local workbuddy2api proxy) LLM provider adapter for DeepSeek Harness, with a Web login-status widget",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,7 +17,11 @@
17
17
  ],
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": ""
20
+ "url": "git+https://github.com/zdk119746/dsh-llm-workbuddy.git"
21
+ },
22
+ "homepage": "https://github.com/zdk119746/dsh-llm-workbuddy#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/zdk119746/dsh-llm-workbuddy/issues"
21
25
  },
22
26
  "dsh": {
23
27
  "bundle": {