dsh-llm-workbuddy 0.1.9 → 0.1.12
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 +8 -2
- package/lib/client.js +98 -0
- package/lib/index.js +453 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,7 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
199
199
|
| `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
|
|
200
200
|
| `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
|
|
201
201
|
| `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
|
|
202
|
+
| `GET /api/workbuddy/usage` | **用量统计**:读取本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`),返回 `{ today, byModel, total }`(今日/按模型/累计的 input/output tokens 与请求次数);非 GET 返回 405 |
|
|
202
203
|
|
|
203
204
|
> 会话文件与登录脚本路径的解析顺序:
|
|
204
205
|
> 1. 配置里显式指定的 `sessionFile` / `loginScript`;
|
|
@@ -214,6 +215,9 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
214
215
|
- 胶囊里有个 **🔍 诊断** 按钮:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
|
|
215
216
|
显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
|
|
216
217
|
发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
|
|
218
|
+
- 胶囊里有个 **📊 用量** 按钮:点它 `GET /api/workbuddy/usage`,弹出一个面板
|
|
219
|
+
显示**今日/累计 token 用量**与**按模型明细**(数据来自本地台账
|
|
220
|
+
`$DSH_HOME/llm-workbuddy/usage.jsonl`)。
|
|
217
221
|
- 状态映射:
|
|
218
222
|
- `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
|
|
219
223
|
- 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
|
|
@@ -280,8 +284,10 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
|
280
284
|
|
|
281
285
|
模型列表默认取插件内置目录;代理可达时改为实时拉取 `/v1/models`(支持
|
|
282
286
|
`{"models": [...]}` / `{"data": [...]}` 两种返回),未列出的模型 id 仍可原样
|
|
283
|
-
|
|
284
|
-
|
|
287
|
+
传递。**内置目录充当白名单**:代理返回的已下架/不可用模型(如 glm-4.6v、
|
|
288
|
+
glm-5.0、minimax-m2.5 等,上游返回 `service info not found`)会被过滤,不显示
|
|
289
|
+
在 UI 中。每个模型的推理等级(`reasoningEffort`)默认来自内置目录,代理可达时
|
|
290
|
+
优先沿用目录值(代理 `/v1/models` 当前只上报 `High`,不区分模型)。
|
|
285
291
|
|
|
286
292
|
---
|
|
287
293
|
|
package/lib/client.js
CHANGED
|
@@ -107,6 +107,18 @@ function runWidget() {
|
|
|
107
107
|
}
|
|
108
108
|
// 诊断按钮始终显示,便于随时自查真实健康状态。
|
|
109
109
|
html += '<button class="wb-btn wb-diagnose" id="wb-diagnose" title="一键诊断">🔍</button>';
|
|
110
|
+
// 用量按钮:查看今日/累计 token 用量与模型明细。
|
|
111
|
+
html += '<button class="wb-btn wb-usage" id="wb-usage" title="用量统计">📊</button>';
|
|
112
|
+
// 签到按钮:✅ 今日已处理 / 🎁 今日未处理(点击立即领取;10 点后也会自动领取)。
|
|
113
|
+
var ck = status.checkin;
|
|
114
|
+
if (ck) {
|
|
115
|
+
var ckMsg = ck.lastResult && ck.lastResult.message ? "\n" + ck.lastResult.message : "";
|
|
116
|
+
var ckTitle = ck.handledToday ? "今日已处理签到" + ckMsg : "今日还没签到(每天 10 点后自动领取)\n点击立即领取";
|
|
117
|
+
var ckIcon = ck.handledToday && ck.lastResult && ck.lastResult.ok ? "✅" : "🎁";
|
|
118
|
+
html += '<button class="wb-btn wb-checkin" id="wb-checkin" title="' + esc(ckTitle) + '">' + ckIcon + "</button>";
|
|
119
|
+
}
|
|
120
|
+
// 刷新模型按钮:从代理重读模型列表(跟随 WorkBuddy 应用更新)并让模型选择器立即重载。
|
|
121
|
+
html += '<button class="wb-btn wb-models" id="wb-models" title="刷新模型列表">🔄</button>';
|
|
110
122
|
if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
|
|
111
123
|
html += "</div>";
|
|
112
124
|
host.innerHTML = html;
|
|
@@ -114,6 +126,12 @@ function runWidget() {
|
|
|
114
126
|
if (btn) btn.addEventListener("click", onLogin);
|
|
115
127
|
var dg = host.querySelector("#wb-diagnose");
|
|
116
128
|
if (dg) dg.addEventListener("click", onDiagnose);
|
|
129
|
+
var us = host.querySelector("#wb-usage");
|
|
130
|
+
if (us) us.addEventListener("click", onUsage);
|
|
131
|
+
var ckBtn = host.querySelector("#wb-checkin");
|
|
132
|
+
if (ckBtn) ckBtn.addEventListener("click", onCheckin);
|
|
133
|
+
var mdBtn = host.querySelector("#wb-models");
|
|
134
|
+
if (mdBtn) mdBtn.addEventListener("click", onRefreshModels);
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
function refresh() {
|
|
@@ -206,12 +224,92 @@ function runWidget() {
|
|
|
206
224
|
});
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
/** 用量统计:读取 /api/workbuddy/usage 并展示今日/累计/模型明细。 */
|
|
228
|
+
function onUsage() {
|
|
229
|
+
var panel = document.getElementById("wb-usage-panel");
|
|
230
|
+
if (panel) { panel.remove(); return; }
|
|
231
|
+
var el = document.createElement("div");
|
|
232
|
+
el.id = "wb-usage-panel";
|
|
233
|
+
el.style.cssText =
|
|
234
|
+
"position:fixed;right:12px;bottom:44px;z-index:2147483647;width:340px;max-height:340px;overflow:auto;" +
|
|
235
|
+
"background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;" +
|
|
236
|
+
"padding:10px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.5);";
|
|
237
|
+
el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 用量统计</div>' +
|
|
238
|
+
'<div class="wb-usage-body" style="color:#94a3b8">加载中…</div>';
|
|
239
|
+
document.body.appendChild(el);
|
|
240
|
+
|
|
241
|
+
fetch("/api/workbuddy/usage")
|
|
242
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
243
|
+
.then(function (d) {
|
|
244
|
+
if (!d) throw new Error("空响应");
|
|
245
|
+
var lines = [];
|
|
246
|
+
lines.push("今日:输入 " + d.today.inputTokens + " / 输出 " + d.today.outputTokens + " tokens(" + d.today.requests + " 次请求)");
|
|
247
|
+
lines.push("累计:输入 " + d.total.inputTokens + " / 输出 " + d.total.outputTokens + " tokens(" + d.total.requests + " 次请求)");
|
|
248
|
+
lines.push("");
|
|
249
|
+
lines.push("按模型明细:");
|
|
250
|
+
if (d.byModel.length === 0) lines.push(" 暂无数据");
|
|
251
|
+
for (var i = 0; i < d.byModel.length; i += 1) {
|
|
252
|
+
var m = d.byModel[i];
|
|
253
|
+
lines.push(" " + m.model + ":输入 " + m.inputTokens + " / 输出 " + m.outputTokens + "(" + m.requests + " 次)");
|
|
254
|
+
}
|
|
255
|
+
el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 用量统计</div>' +
|
|
256
|
+
'<div class="wb-usage-body" style="white-space:pre-wrap;word-break:break-word">' + esc(lines.join("\n")) + "</div>" +
|
|
257
|
+
'<div style="margin-top:8px;color:#64748b;font-size:11px">数据来源:本地 token 用量台账(' + esc(d.total.requests) + " 次累计)</div>";
|
|
258
|
+
})
|
|
259
|
+
.catch(function (err) {
|
|
260
|
+
el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;color:#f87171">用量加载失败</div>' +
|
|
261
|
+
'<div class="wb-usage-body">' + esc(String(err && err.message || err)) + "</div>";
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
209
265
|
function esc(s) {
|
|
210
266
|
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
211
267
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
212
268
|
});
|
|
213
269
|
}
|
|
214
270
|
|
|
271
|
+
/** 手动领取今日签到(幂等:官方对已签到返回业务拒绝,不会出错)。 */
|
|
272
|
+
function onCheckin() {
|
|
273
|
+
var btn = host.querySelector("#wb-checkin");
|
|
274
|
+
if (!btn || btn.dataset.busy) return;
|
|
275
|
+
btn.dataset.busy = "1";
|
|
276
|
+
btn.textContent = "⏳";
|
|
277
|
+
fetch("/api/workbuddy/checkin", { method: "POST" })
|
|
278
|
+
.then(function (r) { return r.json(); })
|
|
279
|
+
.then(function (d) {
|
|
280
|
+
btn.textContent = d && d.ok ? "✅" : "🎁";
|
|
281
|
+
btn.title = (d && d.message) || "签到结果未知";
|
|
282
|
+
refresh();
|
|
283
|
+
})
|
|
284
|
+
.catch(function () {
|
|
285
|
+
btn.textContent = "🎁";
|
|
286
|
+
btn.title = "签到请求失败(代理未运行?)";
|
|
287
|
+
})
|
|
288
|
+
.then(function () { delete btn.dataset.busy; });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 刷新模型列表:清插件发现缓存 + 触发模型选择器立即重载。 */
|
|
292
|
+
function onRefreshModels() {
|
|
293
|
+
var btn = host.querySelector("#wb-models");
|
|
294
|
+
if (!btn || btn.dataset.busy) return;
|
|
295
|
+
btn.dataset.busy = "1";
|
|
296
|
+
btn.textContent = "…";
|
|
297
|
+
fetch("/api/workbuddy/refresh-models", { method: "POST" })
|
|
298
|
+
.then(function (r) { return r.json(); })
|
|
299
|
+
.then(function (d) {
|
|
300
|
+
btn.textContent = d && typeof d.count === "number" ? d.count + "✓" : "✓";
|
|
301
|
+
btn.title = d && d.announced
|
|
302
|
+
? "已刷新:" + d.count + " 个模型(模型选择器已更新)"
|
|
303
|
+
: "已刷新模型缓存(事件通知失败,重开设置页可见新列表)";
|
|
304
|
+
setTimeout(function () { btn.textContent = "🔄"; }, 2000);
|
|
305
|
+
})
|
|
306
|
+
.catch(function () {
|
|
307
|
+
btn.textContent = "🔄";
|
|
308
|
+
btn.title = "刷新失败(代理未运行?)";
|
|
309
|
+
})
|
|
310
|
+
.then(function () { delete btn.dataset.busy; });
|
|
311
|
+
}
|
|
312
|
+
|
|
215
313
|
refresh();
|
|
216
314
|
timer = setInterval(refresh, POLL_MS);
|
|
217
315
|
}
|
package/lib/index.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* to a shipped static catalog when the proxy is not running.
|
|
18
18
|
*/
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
-
import { dirname, resolve } from "node:path";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import z from "@deepseek-ai/schemastery";
|
|
24
24
|
import {
|
|
@@ -62,6 +62,12 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
|
62
62
|
/** Default per-request output-token cap. */
|
|
63
63
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
64
64
|
|
|
65
|
+
/** Usage ledger: one JSONL line per completed request, under DSH_HOME. */
|
|
66
|
+
const USAGE_DIR_NAME = "llm-workbuddy";
|
|
67
|
+
const USAGE_FILE_NAME = "usage.jsonl";
|
|
68
|
+
/** Cap on how many historical lines the ledger keeps (avoid unbounded growth). */
|
|
69
|
+
const USAGE_MAX_LINES = 20_000;
|
|
70
|
+
|
|
65
71
|
/** Live `/v1/models` discovery cache window and request timeout. */
|
|
66
72
|
const DISCOVERY_TTL_MS = 30_000;
|
|
67
73
|
const DISCOVERY_TIMEOUT_MS = 2_000;
|
|
@@ -75,19 +81,15 @@ const DISCOVERY_TIMEOUT_MS = 2_000;
|
|
|
75
81
|
const DEFAULT_MODELS = [
|
|
76
82
|
{ id: "deepseek-v4-pro", name: "Deepseek-V4-Pro", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
|
|
77
83
|
{ id: "deepseek-v4-flash", name: "Deepseek-V4-Flash", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
|
|
84
|
+
{ id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2", contextWindow: 96_000, maxTokens: 32_000, reasoningEffort: "medium" },
|
|
78
85
|
{ id: "glm-5.2", name: "GLM-5.2", contextWindow: 1_000_000, maxTokens: 48_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
79
86
|
{ id: "glm-5.1", name: "GLM-5.1", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
|
|
80
|
-
{ id: "glm-5.0", name: "GLM-5.0", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
|
|
81
|
-
{ id: "glm-4.7", name: "GLM-4.7", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
|
|
82
|
-
{ id: "glm-4.6", name: "GLM-4.6", contextWindow: 168_000, maxTokens: 32_000, reasoningEffort: "high" },
|
|
83
87
|
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo", contextWindow: 200_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
84
88
|
{ id: "kimi-k3-1", name: "Kimi-K3", contextWindow: 1_000_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
85
89
|
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
86
90
|
{ id: "kimi-k2.6", name: "Kimi-K2.6", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
87
91
|
{ id: "kimi-k2.5", name: "Kimi-K2.5", contextWindow: 164_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
88
|
-
{ id: "kimi-k2-thinking", name: "Kimi-K2-Thinking", contextWindow: 164_000, maxTokens: 32_000, reasoningEffort: "medium" },
|
|
89
92
|
{ id: "minimax-m3", name: "MiniMax-M3", contextWindow: 512_000, maxTokens: 128_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
|
|
90
|
-
{ id: "minimax-m2.5", name: "MiniMax-M2.5", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
|
|
91
93
|
{ id: "hy3", name: "Hy3", contextWindow: 192_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
|
|
92
94
|
{ id: "hunyuan-2.0-thinking", name: "Hunyuan-2.0-Thinking", contextWindow: 128_000, maxTokens: 24_000, reasoningEffort: "medium" },
|
|
93
95
|
{ id: "hunyuan-chat", name: "Hunyuan-Turbos", contextWindow: 200_000, maxTokens: 8_192 },
|
|
@@ -143,6 +145,17 @@ function liveReasoningEffort(entry, catalogEffort) {
|
|
|
143
145
|
return lower === "low" || lower === "medium" || lower === "high" ? lower : undefined;
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Extract validated input modalities from a live `/v1/models` entry
|
|
150
|
+
* (`input_modalities`, codex format). Unknown or missing values degrade to
|
|
151
|
+
* text-only, matching the official catalog's conservative default.
|
|
152
|
+
*/
|
|
153
|
+
function liveInputModalities(entry) {
|
|
154
|
+
const mods = Array.isArray(entry?.input_modalities) ? entry.input_modalities : [];
|
|
155
|
+
const valid = mods.filter((modality) => modality === "text" || modality === "image");
|
|
156
|
+
return valid.length > 0 ? [...new Set(valid)] : ["text"];
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
|
|
147
160
|
// #region serialize
|
|
148
161
|
|
|
@@ -183,7 +196,10 @@ async function serializeMessages(messages, attachments, signal) {
|
|
|
183
196
|
if (attachments === undefined) {
|
|
184
197
|
throw new LlmError("WorkBuddy image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
185
198
|
}
|
|
186
|
-
|
|
199
|
+
// Keep images small enough for the upstream platform: the proxy relays the
|
|
200
|
+
// base64 body verbatim, and oversized payloads are dropped/ignored upstream
|
|
201
|
+
// (models reply "no image attached"). Mirror the official adapter budgets.
|
|
202
|
+
const policy = { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 };
|
|
187
203
|
const ordered = [...refs.values()];
|
|
188
204
|
const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, policy, signal)));
|
|
189
205
|
for (let index = 0; index < ordered.length; index += 1) {
|
|
@@ -346,6 +362,94 @@ function mapUsage(usage) {
|
|
|
346
362
|
};
|
|
347
363
|
}
|
|
348
364
|
|
|
365
|
+
// #region usage ledger
|
|
366
|
+
|
|
367
|
+
/** Resolve the usage ledger file under DSH_HOME (falls back to ~/.dsh). */
|
|
368
|
+
function usageLedgerPath() {
|
|
369
|
+
const configured = process.env.DSH_HOME;
|
|
370
|
+
if (configured !== undefined && configured.trim().length > 0) {
|
|
371
|
+
return join(configured, USAGE_DIR_NAME, USAGE_FILE_NAME);
|
|
372
|
+
}
|
|
373
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
374
|
+
return join(home, ".dsh", USAGE_DIR_NAME, USAGE_FILE_NAME);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Append one completed request's token usage to the ledger (best-effort). */
|
|
378
|
+
function recordUsage(model, usage) {
|
|
379
|
+
if (usage === undefined) return;
|
|
380
|
+
try {
|
|
381
|
+
const file = usageLedgerPath();
|
|
382
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
383
|
+
const line = JSON.stringify({
|
|
384
|
+
ts: Date.now(),
|
|
385
|
+
model,
|
|
386
|
+
input: usage.inputTokens ?? 0,
|
|
387
|
+
output: usage.outputTokens ?? 0,
|
|
388
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
389
|
+
reasoning: usage.reasoningTokens ?? 0,
|
|
390
|
+
});
|
|
391
|
+
appendFileSync(file, `${line}\n`);
|
|
392
|
+
// Trim the ledger when it exceeds the cap: keep only the newest lines.
|
|
393
|
+
const size = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).length : 0;
|
|
394
|
+
if (size > USAGE_MAX_LINES) {
|
|
395
|
+
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-USAGE_MAX_LINES);
|
|
396
|
+
const fd = openSync(file, "w");
|
|
397
|
+
try { writeSync(fd, `${lines.join("\n")}\n`); } finally { closeSync(fd); }
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
// usage recording must never break the request path
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Read the ledger and aggregate into daily/model breakdowns. */
|
|
405
|
+
function readUsageLedger() {
|
|
406
|
+
const file = usageLedgerPath();
|
|
407
|
+
const today = new Date();
|
|
408
|
+
today.setHours(0, 0, 0, 0);
|
|
409
|
+
const todayMs = today.getTime();
|
|
410
|
+
const byModel = new Map();
|
|
411
|
+
let todayInput = 0;
|
|
412
|
+
let todayOutput = 0;
|
|
413
|
+
let todayRequests = 0;
|
|
414
|
+
let totalInput = 0;
|
|
415
|
+
let totalOutput = 0;
|
|
416
|
+
let totalRequests = 0;
|
|
417
|
+
if (!existsSync(file)) {
|
|
418
|
+
return { today: { inputTokens: 0, outputTokens: 0, requests: 0 }, byModel: [], total: { inputTokens: 0, outputTokens: 0, requests: 0 } };
|
|
419
|
+
}
|
|
420
|
+
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
421
|
+
for (const line of lines) {
|
|
422
|
+
let entry;
|
|
423
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
424
|
+
const input = entry.input ?? 0;
|
|
425
|
+
const output = entry.output ?? 0;
|
|
426
|
+
totalInput += input;
|
|
427
|
+
totalOutput += output;
|
|
428
|
+
totalRequests += 1;
|
|
429
|
+
if ((entry.ts ?? 0) >= todayMs) {
|
|
430
|
+
todayInput += input;
|
|
431
|
+
todayOutput += output;
|
|
432
|
+
todayRequests += 1;
|
|
433
|
+
}
|
|
434
|
+
const model = String(entry.model ?? "unknown");
|
|
435
|
+
const agg = byModel.get(model) ?? { inputTokens: 0, outputTokens: 0, requests: 0 };
|
|
436
|
+
agg.inputTokens += input;
|
|
437
|
+
agg.outputTokens += output;
|
|
438
|
+
agg.requests += 1;
|
|
439
|
+
byModel.set(model, agg);
|
|
440
|
+
}
|
|
441
|
+
const byModelList = [...byModel.entries()]
|
|
442
|
+
.map(([model, agg]) => ({ model, ...agg }))
|
|
443
|
+
.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
|
|
444
|
+
return {
|
|
445
|
+
today: { inputTokens: todayInput, outputTokens: todayOutput, requests: todayRequests },
|
|
446
|
+
byModel: byModelList,
|
|
447
|
+
total: { inputTokens: totalInput, outputTokens: totalOutput, requests: totalRequests },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// #endregion
|
|
452
|
+
|
|
349
453
|
/** Assemble the final ContentBlock for one open block. */
|
|
350
454
|
function closeBlock(block) {
|
|
351
455
|
switch (block.kind) {
|
|
@@ -444,7 +548,9 @@ async function* translate(payloads) {
|
|
|
444
548
|
argumentsDelta: fragment,
|
|
445
549
|
};
|
|
446
550
|
}
|
|
447
|
-
|
|
551
|
+
// Hy (Hunyuan) models emit `"finish_reason": ""` on every intermediate
|
|
552
|
+
// chunk; only treat a non-empty vocabulary value as the real finish.
|
|
553
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) pendingFinish = mapFinishReason(choice.finish_reason);
|
|
448
554
|
}
|
|
449
555
|
if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
|
|
450
556
|
}
|
|
@@ -516,6 +622,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
516
622
|
return { id: provider, name: "WorkBuddy" };
|
|
517
623
|
}
|
|
518
624
|
|
|
625
|
+
/** Drop the discovery cache so the next listModels re-fetches from the proxy. */
|
|
626
|
+
refreshModels() {
|
|
627
|
+
this._cache = undefined;
|
|
628
|
+
this._liveMeta = undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
519
631
|
async listModels(provider) {
|
|
520
632
|
const connection = this.config.options();
|
|
521
633
|
if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -545,24 +657,45 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
545
657
|
: typeof entry.display_name === "string"
|
|
546
658
|
? entry.display_name
|
|
547
659
|
: undefined,
|
|
660
|
+
description: typeof entry.description === "string" && entry.description.length > 0
|
|
661
|
+
? entry.description
|
|
662
|
+
: undefined,
|
|
663
|
+
modalities: liveInputModalities(entry),
|
|
664
|
+
contextWindow: Number.isInteger(entry.context_window) && entry.context_window > 0
|
|
665
|
+
? entry.context_window
|
|
666
|
+
: undefined,
|
|
667
|
+
maxTokens: Number.isInteger(entry.max_context_window) && entry.max_context_window > 0
|
|
668
|
+
? entry.max_context_window
|
|
669
|
+
: undefined,
|
|
548
670
|
}))
|
|
549
671
|
.filter((entry) => entry.id.length > 0);
|
|
550
672
|
const merged = [];
|
|
551
673
|
const seen = new Set();
|
|
674
|
+
const liveMeta = new Map();
|
|
552
675
|
for (const entry of live) {
|
|
553
676
|
if (seen.has(entry.id)) continue;
|
|
554
677
|
seen.add(entry.id);
|
|
555
678
|
const catalog = byId.get(entry.id);
|
|
679
|
+
// The static catalog doubles as a whitelist. The proxy still announces
|
|
680
|
+
// retired/legacy models (deepseek-v3-1, glm-4.6, glm-4.6v, kimi-k2,
|
|
681
|
+
// kimi-k2-thinking, minimax-m2.5, hunyuan-image-v3.0, ...) that the
|
|
682
|
+
// upstream rejects with `service info not found`. Drop any live entry
|
|
683
|
+
// that is not in the catalog so those never reach the UI; the catalog
|
|
684
|
+
// is the curated set that matches what the official client shows.
|
|
685
|
+
if (catalog === undefined) continue;
|
|
556
686
|
const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
|
|
557
687
|
const reasoning = modelReasoningInfo(effort);
|
|
558
688
|
merged.push({
|
|
559
689
|
provider,
|
|
560
690
|
id: entry.id,
|
|
561
691
|
name: entry.name ?? catalog?.name ?? entry.id,
|
|
562
|
-
...(catalog?.description !== undefined
|
|
563
|
-
|
|
692
|
+
...(catalog?.description !== undefined || entry.description !== undefined
|
|
693
|
+
? { description: catalog?.description ?? entry.description }
|
|
694
|
+
: {}),
|
|
695
|
+
inputModalities: catalog?.inputModalities ?? entry.modalities,
|
|
564
696
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
565
697
|
});
|
|
698
|
+
liveMeta.set(entry.id, { contextWindow: entry.contextWindow, maxTokens: entry.maxTokens });
|
|
566
699
|
}
|
|
567
700
|
// Catalog entries the proxy did not announce (e.g. unauthenticated or
|
|
568
701
|
// partial listing) stay selectable.
|
|
@@ -572,6 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
572
705
|
}
|
|
573
706
|
const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
|
|
574
707
|
this._cache = { at: now, models: result };
|
|
708
|
+
this._liveMeta = liveMeta;
|
|
575
709
|
return result;
|
|
576
710
|
} catch {
|
|
577
711
|
return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -581,13 +715,18 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
581
715
|
resolveModel(provider, model, _signal) {
|
|
582
716
|
const connection = this.config.options();
|
|
583
717
|
const configured = connection.models.find((entry) => entry.id === model);
|
|
718
|
+
// Models discovered live (proxy-announced, not in the configured catalog)
|
|
719
|
+
// still get their platform-declared capacity from the last discovery.
|
|
720
|
+
const live = this._liveMeta?.get(model);
|
|
584
721
|
const base = configured === undefined
|
|
585
722
|
? { provider, id: model, name: model, inputModalities: ["text"] }
|
|
586
723
|
: modelInfo(provider, configured);
|
|
587
724
|
return Promise.resolve({
|
|
588
725
|
...base,
|
|
589
|
-
context: {
|
|
590
|
-
|
|
726
|
+
context: {
|
|
727
|
+
contextWindow: configured?.contextWindow ?? live?.contextWindow ?? connection.defaultContextWindow,
|
|
728
|
+
},
|
|
729
|
+
defaultMaxTokens: configured?.maxTokens ?? live?.maxTokens ?? connection.maxTokens,
|
|
591
730
|
});
|
|
592
731
|
}
|
|
593
732
|
|
|
@@ -601,13 +740,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
601
740
|
);
|
|
602
741
|
const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
|
|
603
742
|
let exhausted = false;
|
|
743
|
+
let usage;
|
|
604
744
|
try {
|
|
605
745
|
while (true) {
|
|
606
746
|
const result = await watchdog.next(iterator);
|
|
607
747
|
if (result.done) {
|
|
608
748
|
exhausted = true;
|
|
749
|
+
if (usage !== undefined) recordUsage(options.model, usage);
|
|
609
750
|
return;
|
|
610
751
|
}
|
|
752
|
+
if (result.value?.type === "usage") usage = result.value.usage;
|
|
611
753
|
yield result.value;
|
|
612
754
|
}
|
|
613
755
|
} catch (error) {
|
|
@@ -698,6 +840,10 @@ export const Config = z.object({
|
|
|
698
840
|
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
|
699
841
|
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
700
842
|
discovery: z.boolean().default(true),
|
|
843
|
+
// Daily checkin: after `checkinAfterHour` local time, claim once per day if
|
|
844
|
+
// the official activity says today is unclaimed. Disable to check in manually.
|
|
845
|
+
autoCheckin: z.boolean().default(true),
|
|
846
|
+
checkinAfterHour: z.number().step(1).min(0).max(23).default(10),
|
|
701
847
|
// Optional path overrides for the login helper. When unset, the plugin uses
|
|
702
848
|
// the login_workbuddy.py bundled with this package and the default session
|
|
703
849
|
// file location (~/.codebuddy-session.json).
|
|
@@ -760,6 +906,10 @@ export function resolveAdapterOptions(config) {
|
|
|
760
906
|
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
761
907
|
throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
762
908
|
}
|
|
909
|
+
const checkinAfterHour = config.checkinAfterHour ?? 10;
|
|
910
|
+
if (!Number.isInteger(checkinAfterHour) || checkinAfterHour < 0 || checkinAfterHour > 23) {
|
|
911
|
+
throw new Error("dsh-llm-workbuddy: checkinAfterHour must be an integer between 0 and 23");
|
|
912
|
+
}
|
|
763
913
|
return {
|
|
764
914
|
baseURL,
|
|
765
915
|
apiKey: config.apiKey,
|
|
@@ -768,6 +918,8 @@ export function resolveAdapterOptions(config) {
|
|
|
768
918
|
models: resolveModels(config.models),
|
|
769
919
|
streamIdleTimeoutMs,
|
|
770
920
|
discovery: config.discovery ?? true,
|
|
921
|
+
autoCheckin: config.autoCheckin ?? true,
|
|
922
|
+
checkinAfterHour,
|
|
771
923
|
loginScript: config.loginScript,
|
|
772
924
|
sessionFile: config.sessionFile,
|
|
773
925
|
};
|
|
@@ -775,6 +927,176 @@ export function resolveAdapterOptions(config) {
|
|
|
775
927
|
|
|
776
928
|
// #endregion
|
|
777
929
|
|
|
930
|
+
// #region daily checkin
|
|
931
|
+
|
|
932
|
+
/** Checkin state file under DSH_HOME (same ledger dir as usage). */
|
|
933
|
+
const CHECKIN_FILE_NAME = "checkin.json";
|
|
934
|
+
/** How often the auto-claim scheduler wakes up. */
|
|
935
|
+
const CHECKIN_INTERVAL_MS = 30 * 60_000;
|
|
936
|
+
/** Request timeout for the proxy checkin endpoints. */
|
|
937
|
+
const CHECKIN_TIMEOUT_MS = 10_000;
|
|
938
|
+
|
|
939
|
+
/** Resolve the checkin state file path (same convention as usageLedgerPath). */
|
|
940
|
+
function checkinStatePath() {
|
|
941
|
+
const configured = process.env.DSH_HOME;
|
|
942
|
+
if (configured !== undefined && configured.trim().length > 0) {
|
|
943
|
+
return join(configured, USAGE_DIR_NAME, CHECKIN_FILE_NAME);
|
|
944
|
+
}
|
|
945
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
946
|
+
return join(home, ".dsh", USAGE_DIR_NAME, CHECKIN_FILE_NAME);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Read the persisted checkin state. Never throws. */
|
|
950
|
+
function readCheckinState() {
|
|
951
|
+
try {
|
|
952
|
+
return JSON.parse(readFileSync(checkinStatePath(), "utf-8"));
|
|
953
|
+
} catch {
|
|
954
|
+
return {};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Persist the checkin state (best-effort; failure must not break the caller). */
|
|
959
|
+
function writeCheckinState(state) {
|
|
960
|
+
try {
|
|
961
|
+
const file = checkinStatePath();
|
|
962
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
963
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
964
|
+
} catch {
|
|
965
|
+
// persistence is advisory
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Local calendar date (`YYYY-MM-DD`) for "did we already handle today". */
|
|
970
|
+
function localDateStr(date = new Date()) {
|
|
971
|
+
const y = date.getFullYear();
|
|
972
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
973
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
974
|
+
return `${y}-${m}-${d}`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Call a proxy checkin endpoint. `method` defaults to GET (status probe);
|
|
979
|
+
* claiming passes "POST". Returns the parsed body or an error shape.
|
|
980
|
+
*/
|
|
981
|
+
async function proxyCheckinCall(baseURL, path, method = "GET") {
|
|
982
|
+
const controller = new AbortController();
|
|
983
|
+
const timer = setTimeout(() => controller.abort(), CHECKIN_TIMEOUT_MS);
|
|
984
|
+
try {
|
|
985
|
+
const response = await fetch(`${baseURL.replace(/\/$/, "")}${path}`, {
|
|
986
|
+
method,
|
|
987
|
+
...(method === "POST" ? { headers: { "content-type": "application/json" }, body: "{}" } : {}),
|
|
988
|
+
signal: controller.signal,
|
|
989
|
+
});
|
|
990
|
+
const body = await response.json().catch(() => null);
|
|
991
|
+
if (!response.ok) {
|
|
992
|
+
return { error: `HTTP ${response.status} (${method} ${path})`, ...(body ? { body } : {}) };
|
|
993
|
+
}
|
|
994
|
+
return body ?? { error: "empty response" };
|
|
995
|
+
} catch (error) {
|
|
996
|
+
return { error: String(error?.cause?.message ?? error?.message ?? error) };
|
|
997
|
+
} finally {
|
|
998
|
+
clearTimeout(timer);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Whether an upstream claim rejection means "already claimed today". */
|
|
1003
|
+
function alreadyClaimedMessage(msg) {
|
|
1004
|
+
return typeof msg === "string" && /(已签到|已经签到|already)/i.test(msg);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Run one auto-claim pass. Idempotent per local calendar day:
|
|
1009
|
+
* - before `checkinAfterHour` (default 10:00) local time: no-op;
|
|
1010
|
+
* - already handled today (persisted state): no-op;
|
|
1011
|
+
* - official status says already checked in: mark handled, done;
|
|
1012
|
+
* - activity not open (no status data): mark handled so we stop retrying today;
|
|
1013
|
+
* - otherwise claim, persisting the reward (credit, streak) on success.
|
|
1014
|
+
* Concurrent invocations collapse into the first caller via `inFlight`.
|
|
1015
|
+
* @returns the resulting state snapshot, or undefined when skipped/in-flight.
|
|
1016
|
+
*/
|
|
1017
|
+
async function runCheckinOnce(baseURL, config, logger) {
|
|
1018
|
+
if (!config.autoCheckin) return undefined;
|
|
1019
|
+
const now = new Date();
|
|
1020
|
+
if (now.getHours() < config.checkinAfterHour) return undefined;
|
|
1021
|
+
const today = localDateStr(now);
|
|
1022
|
+
const state = readCheckinState();
|
|
1023
|
+
if (state.handledDate === today) return state;
|
|
1024
|
+
if (runCheckinOnce._inFlight) return undefined;
|
|
1025
|
+
runCheckinOnce._inFlight = true;
|
|
1026
|
+
try {
|
|
1027
|
+
const status = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1028
|
+
const data = status?.upstream?.data;
|
|
1029
|
+
if (!data) {
|
|
1030
|
+
// Activity closed / proxy unreachable / not authenticated: stop for today.
|
|
1031
|
+
const next = {
|
|
1032
|
+
...state,
|
|
1033
|
+
handledDate: today,
|
|
1034
|
+
lastResult: { ok: false, message: status?.error ?? status?.upstream?.msg ?? "签到活动不可用", at: now.getTime() },
|
|
1035
|
+
};
|
|
1036
|
+
writeCheckinState(next);
|
|
1037
|
+
logger?.debug?.(`dsh-llm-workbuddy: checkin skipped today (${next.lastResult.message})`);
|
|
1038
|
+
return next;
|
|
1039
|
+
}
|
|
1040
|
+
if (data.today_checked_in) {
|
|
1041
|
+
const next = {
|
|
1042
|
+
...state,
|
|
1043
|
+
handledDate: today,
|
|
1044
|
+
lastResult: { ok: true, message: "今日已签到(官方确认)", streakDays: data.streak_days, at: now.getTime() },
|
|
1045
|
+
};
|
|
1046
|
+
writeCheckinState(next);
|
|
1047
|
+
return next;
|
|
1048
|
+
}
|
|
1049
|
+
const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
|
|
1050
|
+
const payload = claim?.upstream?.data;
|
|
1051
|
+
// "Already claimed" rejections count as handled (the goal is covered).
|
|
1052
|
+
const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
|
|
1053
|
+
const failureDetail = [claim?.upstream?.msg, claim?.error, claim?.body?.detail]
|
|
1054
|
+
.filter(Boolean).join(" ") || "签到失败";
|
|
1055
|
+
const next = {
|
|
1056
|
+
...state,
|
|
1057
|
+
handledDate: today,
|
|
1058
|
+
lastResult: {
|
|
1059
|
+
ok,
|
|
1060
|
+
message: ok
|
|
1061
|
+
? payload?.credit !== undefined
|
|
1062
|
+
? `自动签到成功:+${payload.credit} 积分`
|
|
1063
|
+
: "今日已签到"
|
|
1064
|
+
: failureDetail,
|
|
1065
|
+
...(payload?.credit !== undefined ? { credit: payload.credit } : {}),
|
|
1066
|
+
...(payload?.streak_days !== undefined ? { streakDays: payload.streak_days } : {}),
|
|
1067
|
+
at: now.getTime(),
|
|
1068
|
+
},
|
|
1069
|
+
};
|
|
1070
|
+
writeCheckinState(next);
|
|
1071
|
+
logger?.info?.(`dsh-llm-workbuddy: ${next.lastResult.message}`);
|
|
1072
|
+
return next;
|
|
1073
|
+
} finally {
|
|
1074
|
+
runCheckinOnce._inFlight = false;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Start the auto-claim scheduler: one immediate pass plus a low-frequency
|
|
1080
|
+
* interval. The interval survives plugin-lifetime only (disposed with the
|
|
1081
|
+
* fiber), and every pass re-reads the live config so settings hot-edit
|
|
1082
|
+
* (autoCheckin / checkinAfterHour) applies to the very next pass.
|
|
1083
|
+
*/
|
|
1084
|
+
function startCheckinScheduler(ctx, options) {
|
|
1085
|
+
const tick = () => {
|
|
1086
|
+
const connection = options();
|
|
1087
|
+
runCheckinOnce(connection.baseURL, connection, ctx.logger).catch((error) => {
|
|
1088
|
+
ctx.logger.warn("dsh-llm-workbuddy: auto checkin pass failed");
|
|
1089
|
+
ctx.logger.warn(error);
|
|
1090
|
+
});
|
|
1091
|
+
};
|
|
1092
|
+
tick();
|
|
1093
|
+
const timer = setInterval(tick, CHECKIN_INTERVAL_MS);
|
|
1094
|
+
timer.unref?.();
|
|
1095
|
+
return () => clearInterval(timer);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// #endregion
|
|
1099
|
+
|
|
778
1100
|
// #region login status web API
|
|
779
1101
|
|
|
780
1102
|
/**
|
|
@@ -944,14 +1266,18 @@ function diagnoseRestartCommand() {
|
|
|
944
1266
|
}
|
|
945
1267
|
|
|
946
1268
|
/**
|
|
947
|
-
* Register
|
|
948
|
-
* `
|
|
949
|
-
* `
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* `
|
|
1269
|
+
* Register the WorkBuddy web API routes on the DSH web server:
|
|
1270
|
+
* - `GET /api/workbuddy/status` login/proxy health + checkin digest;
|
|
1271
|
+
* - `POST /api/workbuddy/login` device-flow login kick-off;
|
|
1272
|
+
* - `POST /api/workbuddy/diagnose` real end-to-end health probe;
|
|
1273
|
+
* - `GET /api/workbuddy/usage` token usage ledger;
|
|
1274
|
+
* - `POST /api/workbuddy/refresh-models` drop the discovery cache, re-read the
|
|
1275
|
+
* proxy model list (which tracks the official WorkBuddy app), and announce
|
|
1276
|
+
* `llm/adapters-updated` so open model pickers reload immediately;
|
|
1277
|
+
* - `GET /api/workbuddy/checkin` today's checkin state (official query);
|
|
1278
|
+
* - `POST /api/workbuddy/checkin` claim now (idempotent upstream).
|
|
953
1279
|
*/
|
|
954
|
-
function registerWorkbuddyRoutes(ctx, config) {
|
|
1280
|
+
function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
955
1281
|
// Delay registration until the webServer service exists. The plugin keeps
|
|
956
1282
|
// `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
|
|
957
1283
|
// load — but a synchronous `ctx.get("webServer")` probe at apply time races
|
|
@@ -979,11 +1305,20 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
979
1305
|
}
|
|
980
1306
|
const session = readSessionStatus(sessionFile);
|
|
981
1307
|
const proxy = await probeProxy(baseURL);
|
|
1308
|
+
// Checkin digest comes from the persisted state only — never block the
|
|
1309
|
+
// 5s status poll with an upstream call.
|
|
1310
|
+
const checkinState = readCheckinState();
|
|
1311
|
+
const today = localDateStr();
|
|
982
1312
|
res.writeHead(200, { "content-type": "application/json" });
|
|
983
1313
|
res.end(JSON.stringify({
|
|
984
1314
|
...session,
|
|
985
1315
|
...proxy,
|
|
986
1316
|
loginScriptAvailable: existsSync(loginScript),
|
|
1317
|
+
checkin: {
|
|
1318
|
+
autoEnabled: config?.autoCheckin ?? true,
|
|
1319
|
+
handledToday: checkinState.handledDate === today,
|
|
1320
|
+
lastResult: checkinState.lastResult ?? null,
|
|
1321
|
+
},
|
|
987
1322
|
}));
|
|
988
1323
|
},
|
|
989
1324
|
}));
|
|
@@ -1054,6 +1389,101 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
1054
1389
|
}));
|
|
1055
1390
|
},
|
|
1056
1391
|
}));
|
|
1392
|
+
webCtx.effect(() => webServer.register({
|
|
1393
|
+
kind: "exact",
|
|
1394
|
+
path: "/api/workbuddy/usage",
|
|
1395
|
+
async handler(req, res) {
|
|
1396
|
+
if (req.method !== "GET") {
|
|
1397
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1398
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1402
|
+
res.end(JSON.stringify(readUsageLedger()));
|
|
1403
|
+
},
|
|
1404
|
+
}));
|
|
1405
|
+
webCtx.effect(() => webServer.register({
|
|
1406
|
+
kind: "exact",
|
|
1407
|
+
path: "/api/workbuddy/refresh-models",
|
|
1408
|
+
async handler(req, res) {
|
|
1409
|
+
if (req.method !== "POST") {
|
|
1410
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1411
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
// Drop the discovery cache so listModels re-reads the proxy, then
|
|
1415
|
+
// re-commit the (unchanged) configurable-provider entries: commit is
|
|
1416
|
+
// the one mutation point that publishes `llm/adapters-updated`, which
|
|
1417
|
+
// makes every open model picker reload immediately.
|
|
1418
|
+
adapter.refreshModels();
|
|
1419
|
+
let announced = true;
|
|
1420
|
+
try {
|
|
1421
|
+
providersHandle?.replace?.([{ provider: PROVIDER, displayName: "WorkBuddy", settingsNs: NS, settingsPath: [] }]);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
announced = false;
|
|
1424
|
+
ctx.logger.warn("dsh-llm-workbuddy: failed to announce adapters-updated");
|
|
1425
|
+
ctx.logger.warn(error);
|
|
1426
|
+
}
|
|
1427
|
+
const models = await adapter.listModels(PROVIDER).catch(() => []);
|
|
1428
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1429
|
+
res.end(JSON.stringify({ ok: true, announced, count: models.length, models }));
|
|
1430
|
+
},
|
|
1431
|
+
}));
|
|
1432
|
+
webCtx.effect(() => webServer.register({
|
|
1433
|
+
kind: "exact",
|
|
1434
|
+
path: "/api/workbuddy/checkin",
|
|
1435
|
+
async handler(req, res) {
|
|
1436
|
+
// GET: today's persisted state plus a fresh official status probe.
|
|
1437
|
+
if (req.method === "GET") {
|
|
1438
|
+
const state = readCheckinState();
|
|
1439
|
+
const upstream = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1440
|
+
const data = upstream?.upstream?.data ?? null;
|
|
1441
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1442
|
+
res.end(JSON.stringify({
|
|
1443
|
+
handledToday: state.handledDate === localDateStr(),
|
|
1444
|
+
lastResult: state.lastResult ?? null,
|
|
1445
|
+
official: data === null ? null : {
|
|
1446
|
+
active: Boolean(data.active),
|
|
1447
|
+
todayCheckedIn: Boolean(data.today_checked_in),
|
|
1448
|
+
streakDays: data.streak_days ?? null,
|
|
1449
|
+
todayCredit: data.today_credit ?? null,
|
|
1450
|
+
streakBonusDays: data.streak_bonus_days ?? null,
|
|
1451
|
+
streakBonusCredit: data.streak_bonus_credit ?? null,
|
|
1452
|
+
},
|
|
1453
|
+
...(upstream?.error ? { error: upstream.error } : {}),
|
|
1454
|
+
}));
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
// POST: claim now. Idempotent — upstream reports "already checked in"
|
|
1458
|
+
// as a business result rather than an error.
|
|
1459
|
+
if (req.method !== "POST") {
|
|
1460
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1461
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
|
|
1465
|
+
const payload = claim?.upstream?.data;
|
|
1466
|
+
const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
|
|
1467
|
+
const result = {
|
|
1468
|
+
ok,
|
|
1469
|
+
message: ok
|
|
1470
|
+
? payload?.credit !== undefined
|
|
1471
|
+
? `签到成功:+${payload.credit} 积分`
|
|
1472
|
+
: "今日已签到"
|
|
1473
|
+
: claim?.upstream?.msg ?? claim?.error ?? "签到失败",
|
|
1474
|
+
...(payload ?? {}),
|
|
1475
|
+
};
|
|
1476
|
+
if (ok || claim?.upstream?.msg) {
|
|
1477
|
+
writeCheckinState({
|
|
1478
|
+
...readCheckinState(),
|
|
1479
|
+
handledDate: ok ? localDateStr() : readCheckinState().handledDate,
|
|
1480
|
+
lastResult: { ok, message: result.message, at: Date.now() },
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1484
|
+
res.end(JSON.stringify(result));
|
|
1485
|
+
},
|
|
1486
|
+
}));
|
|
1057
1487
|
});
|
|
1058
1488
|
}
|
|
1059
1489
|
|
|
@@ -1100,14 +1530,15 @@ export function apply(ctx, config) {
|
|
|
1100
1530
|
options,
|
|
1101
1531
|
resolveAttachments: () => ctx.get("attachments"),
|
|
1102
1532
|
});
|
|
1103
|
-
ctx.llm.registerConfigurableProviders([{
|
|
1533
|
+
const providersHandle = ctx.llm.registerConfigurableProviders([{
|
|
1104
1534
|
provider: PROVIDER,
|
|
1105
1535
|
displayName: "WorkBuddy",
|
|
1106
1536
|
settingsNs: NS,
|
|
1107
1537
|
settingsPath: [],
|
|
1108
1538
|
}]);
|
|
1109
1539
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
1110
|
-
registerWorkbuddyRoutes(ctx, config);
|
|
1540
|
+
registerWorkbuddyRoutes(ctx, config, adapter, providersHandle);
|
|
1541
|
+
ctx.effect(() => startCheckinScheduler(ctx, options));
|
|
1111
1542
|
installSettingsSection(ctx, NS, Config, config, {
|
|
1112
1543
|
setSource: (source) => {
|
|
1113
1544
|
current = source;
|
package/package.json
CHANGED