dsh-llm-workbuddy 0.1.3 → 0.1.5
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 +57 -9
- package/lib/client.js +61 -0
- package/lib/index.js +183 -75
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,33 +92,53 @@ CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile
|
|
|
92
92
|
|
|
93
93
|
## 安装代理(先决条件,第三方)
|
|
94
94
|
|
|
95
|
-
插件只提供模型路由与登录;真正的协议转换由 **workbuddy2api
|
|
96
|
-
|
|
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
|
-
>
|
|
116
|
+
> 之后任何时候想重启代理,就在 `workbuddy2api` 目录里重跑上面第 3 条命令。
|
|
117
|
+
> 若提示 `address already in use`,先停掉旧代理:
|
|
118
|
+
> `lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill`,再重跑。
|
|
119
|
+
|
|
120
|
+
### 本仓库本地开发(可选快捷方式)
|
|
114
121
|
|
|
115
|
-
|
|
122
|
+
仓库根目录提供 `start-workbuddy.sh`,它会用仓库固定的 `main` 分支源码 + 本地
|
|
123
|
+
`.tools/uv` 工具链 + 仓库内的 `.workbuddy/session.json` 启动代理。它**不随 npm
|
|
124
|
+
包发布**,仅面向本仓库开发时方便:
|
|
125
|
+
|
|
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,10 +207,36 @@ 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 时额外提示 `代理未运行`
|
|
192
216
|
|
|
217
|
+
> **关于 UI 挂载位置(临时做法说明)**
|
|
218
|
+
>
|
|
219
|
+
> 当前状态胶囊用 `position:fixed` 直接注入 `document.body`。DSH 官方的 UI
|
|
220
|
+
> 组合机制是 **slot 系统**(`@deepseek-ai/dsh-client-ui-slots`,`ctx.slots.register`
|
|
221
|
+
> 注册 React 组件进声明的 slot)。按官方「组合优先、不要假设/覆盖其他插件内部实现」
|
|
222
|
+
> 的原则,理想做法是注册进官方 slot,而非直接改产品外壳。
|
|
223
|
+
>
|
|
224
|
+
> 评估结论:**当前 shell 声明的 slot 里没有专门的「右下角状态位」**;最接近的
|
|
225
|
+
> `sidebar.footer.action`、`conversation.session.header.actions`、
|
|
226
|
+
> `conversation.input.overlay` 都是 **React 组件位**,迁移意味着要把本插件从
|
|
227
|
+
> 「零依赖原生 JS」改造成 **React + 构建流程**,并依赖 shell 暴露 `ctx.slots`
|
|
228
|
+
> 服务。因此现阶段保留 DOM 注入(机制允许),**待官方 shell 提供合适的状态胶囊
|
|
229
|
+
> slot 后再迁移**。以下是已知的迁移路径(供后续参考):
|
|
230
|
+
>
|
|
231
|
+
> ```js
|
|
232
|
+
> ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
233
|
+
> name: "sidebar.footer.action",
|
|
234
|
+
> id: "workbuddy-status",
|
|
235
|
+
> locale: NS,
|
|
236
|
+
> inject: () => ({ /* 轮询句柄 / 登录回调 */ })
|
|
237
|
+
> }, WorkBuddyStatusPill /* React 组件 */));
|
|
238
|
+
> ```
|
|
239
|
+
|
|
193
240
|
### 为什么不需要构建
|
|
194
241
|
|
|
195
242
|
DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
@@ -239,6 +286,7 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
|
239
286
|
| 右下角没有胶囊 | `dsh web` 没重启加载新 bundle → 重启 `dsh web`;或 `curl /plugins/dsh-llm-workbuddy/client.js` 应返回 200 |
|
|
240
287
|
| 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
|
|
241
288
|
| 胶囊红 + `代理未运行` | workbuddy2api 代理没起或挂了 → 按「安装代理」章节启动 |
|
|
289
|
+
| 胶囊显示登录成功但模型用不了 | 典型的"代理进程活着但指向旧路径/文件缺失"假象(`/health` 仍显示 ok)。点胶囊 **🔍 诊断**,看 `模型出字` 是否失败;按面板给出的重启命令重启代理 |
|
|
242
290
|
| 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装 |
|
|
243
291
|
| 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或 `expiresAt` 已过期 → 刷新页面或重跑登录 |
|
|
244
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 |
|
|
@@ -261,8 +309,8 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
|
261
309
|
|
|
262
310
|
| 路径 | 作用 |
|
|
263
311
|
|---|---|
|
|
264
|
-
| `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*`
|
|
265
|
-
| `lib/client.js` | 零依赖浏览器小部件(状态胶囊 +
|
|
312
|
+
| `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由(status / login / **diagnose**) |
|
|
313
|
+
| `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程 + **🔍 诊断**),被 `dsh.client` 注入 |
|
|
266
314
|
| `login_workbuddy.py` | 设备流登录脚本(随包发布,被后端路由用系统 `python3` spawn) |
|
|
267
315
|
| `cordis.patch.yml` | 本包的 Cordis bundle 挂载声明(`id: llm-workbuddy`) |
|
|
268
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
refresh();
|
|
155
216
|
timer = setInterval(refresh, POLL_MS);
|
|
156
217
|
}
|
package/lib/index.js
CHANGED
|
@@ -762,84 +762,192 @@ async function probeProxy(baseURL) {
|
|
|
762
762
|
}
|
|
763
763
|
|
|
764
764
|
/**
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
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
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
path
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
...proxy,
|
|
800
|
-
loginScriptAvailable: existsSync(loginScript),
|
|
801
|
-
}));
|
|
802
|
-
},
|
|
803
|
-
}));
|
|
804
|
-
ctx.effect(() => webServer.register({
|
|
805
|
-
kind: "exact",
|
|
806
|
-
path: "/api/workbuddy/login",
|
|
807
|
-
async handler(req, res) {
|
|
808
|
-
if (req.method !== "POST") {
|
|
809
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
810
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
if (!existsSync(loginScript)) {
|
|
814
|
-
res.writeHead(503, { "content-type": "application/json" });
|
|
815
|
-
res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
// Existing session is fine; no need to re-run the flow.
|
|
819
|
-
const existing = readSessionStatus(sessionFile);
|
|
820
|
-
if (existing.authenticated) {
|
|
848
|
+
// Delay registration until the webServer service exists. The plugin keeps
|
|
849
|
+
// `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
|
|
850
|
+
// load — but a synchronous `ctx.get("webServer")` probe at apply time races
|
|
851
|
+
// the webserver plugin's own mount and can observe the service before it is
|
|
852
|
+
// provided, silently dropping the routes. `ctx.inject` runs this callback
|
|
853
|
+
// only once webServer is available, and skips it entirely when the service
|
|
854
|
+
// never appears (headless), preserving the original intent.
|
|
855
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
856
|
+
const webServer = webCtx.webServer;
|
|
857
|
+
const baseURL = config_baseURL();
|
|
858
|
+
const { sessionFile, loginScript } = resolveLoginPaths(config);
|
|
859
|
+
// The register() call returns a disposer. Wrapping it in ctx.effect ties
|
|
860
|
+
// it to the plugin fiber so config hot-edit / reload cleans the routes up
|
|
861
|
+
// instead of leaking them (a leaked route makes a duplicate re-register
|
|
862
|
+
// throw). Method filtering must be done inside the handler — the route API
|
|
863
|
+
// only accepts `{ kind, path, handler }`.
|
|
864
|
+
webCtx.effect(() => webServer.register({
|
|
865
|
+
kind: "exact",
|
|
866
|
+
path: "/api/workbuddy/status",
|
|
867
|
+
async handler(req, res) {
|
|
868
|
+
if (req.method !== "GET") {
|
|
869
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
870
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const session = readSessionStatus(sessionFile);
|
|
874
|
+
const proxy = await probeProxy(baseURL);
|
|
821
875
|
res.writeHead(200, { "content-type": "application/json" });
|
|
822
|
-
res.end(JSON.stringify({
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
876
|
+
res.end(JSON.stringify({
|
|
877
|
+
...session,
|
|
878
|
+
...proxy,
|
|
879
|
+
loginScriptAvailable: existsSync(loginScript),
|
|
880
|
+
}));
|
|
881
|
+
},
|
|
882
|
+
}));
|
|
883
|
+
webCtx.effect(() => webServer.register({
|
|
884
|
+
kind: "exact",
|
|
885
|
+
path: "/api/workbuddy/login",
|
|
886
|
+
async handler(req, res) {
|
|
887
|
+
if (req.method !== "POST") {
|
|
888
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
889
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!existsSync(loginScript)) {
|
|
893
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
894
|
+
res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
// Existing session is fine; no need to re-run the flow.
|
|
898
|
+
const existing = readSessionStatus(sessionFile);
|
|
899
|
+
if (existing.authenticated) {
|
|
900
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
901
|
+
res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// login_workbuddy.py is dependency-free (stdlib only), so we invoke it
|
|
905
|
+
// directly with the system Python — no uv runtime required.
|
|
906
|
+
const args = ["-u", loginScript, "--session-file", sessionFile];
|
|
907
|
+
const child = spawn("python3", args, { env: { ...process.env, PYTHONUNBUFFERED: "1" } });
|
|
908
|
+
let stdout = "";
|
|
909
|
+
let authUrl = null;
|
|
910
|
+
child.stdout.on("data", (chunk) => {
|
|
911
|
+
stdout += chunk.toString();
|
|
912
|
+
const m = stdout.match(/https?:\/\/\S+/);
|
|
913
|
+
if (m && authUrl === null) authUrl = m[0];
|
|
914
|
+
});
|
|
915
|
+
child.stderr.on("data", () => {});
|
|
916
|
+
// The device flow blocks until login or timeout; we hand back the URL
|
|
917
|
+
// immediately and let the widget poll /status for completion.
|
|
918
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
919
|
+
res.end(JSON.stringify({ authUrl, pending: true }));
|
|
920
|
+
},
|
|
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
|
+
}));
|
|
950
|
+
});
|
|
843
951
|
}
|
|
844
952
|
|
|
845
953
|
/** Lazily resolve the configured proxy base URL for the health probe. */
|
package/package.json
CHANGED