dsh-llm-workbuddy 0.1.11 → 0.1.13
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/CHANGELOG.md +42 -0
- package/README.md +21 -0
- package/lib/client.js +112 -0
- package/lib/index.js +460 -20
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
本文件记录每个版本的改动。版本号遵循语义化版本(MAJOR.MINOR.PATCH),
|
|
4
|
+
每次发版请同步 `package.json` 的 `version` 并打一个 `git tag`(如 `v0.1.13`),
|
|
5
|
+
在 GitHub 创建 Release 时本文件即为更新说明来源。
|
|
6
|
+
|
|
7
|
+
## [0.1.13] - 2026-08-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- 用量面板新增**积分消耗**统计。WorkBuddy 上游在每次对话的 usage 中返回
|
|
11
|
+
`credit` 字段(如 `deepseek-v4-pro` → `0.02`),插件此前丢弃了该字段;
|
|
12
|
+
现将其写入本地台账,并在 📊 用量面板展示今日/累计/按模型的积分消耗。
|
|
13
|
+
|
|
14
|
+
### Notes
|
|
15
|
+
- 代理未暴露账户余额接口,因此只能统计**已消耗**积分,无法显示剩余余额。
|
|
16
|
+
- 免费/折扣档模型上游回报 `credit: 0`,面板会提示"当前模型积分为 0"。
|
|
17
|
+
|
|
18
|
+
## [0.1.12] - 2026-08-28
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- 恢复模型目录即白名单的过滤逻辑,模型选择器不再显示已下架/不可用的
|
|
22
|
+
老模型(`deepseek-v3-1`、`glm-4.6`、`kimi-k2`、`minimax-m2.5`、
|
|
23
|
+
`kimi-k2-thinking`、`hunyuan-image-v3.0` 等)。
|
|
24
|
+
|
|
25
|
+
### Added(随此前未提交批次一并发布)
|
|
26
|
+
- 本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`)与
|
|
27
|
+
`GET /api/workbuddy/usage` 接口,📊 用量面板展示今日/累计/按模型明细。
|
|
28
|
+
- 一键诊断端点 `POST /api/workbuddy/diagnose`,真实发请求探测模型能否出字。
|
|
29
|
+
- 请求图片压缩,确保上游接受多模态输入。
|
|
30
|
+
|
|
31
|
+
## [0.1.11] 及更早
|
|
32
|
+
|
|
33
|
+
- `0.1.11`:适配代理动态模型列表(product.json 透传)。
|
|
34
|
+
- `0.1.10`:过滤已下架模型(glm-5.0/4.7/4.6、minimax-m2.5 等)。
|
|
35
|
+
- `0.1.9` 及之前:见 git 提交历史(`git log`)。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<!-- 历史版本锚点(便于生成 Release 时对比区间) -->
|
|
40
|
+
[0.1.13]: https://github.com/zdk119746/dsh-llm-workbuddy/compare/v0.1.12...v0.1.13
|
|
41
|
+
[0.1.12]: https://github.com/zdk119746/dsh-llm-workbuddy/compare/v0.1.11...v0.1.12
|
|
42
|
+
[0.1.11]: https://github.com/zdk119746/dsh-llm-workbuddy/releases/tag/v0.1.11
|
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/dsh-llm-workbuddy)
|
|
4
4
|
[](https://github.com/zdk119746/dsh-llm-workbuddy/blob/main/LICENSE)
|
|
5
|
+
[](./CHANGELOG.md)
|
|
5
6
|
|
|
6
7
|
在 DeepSeek Harness 中使用你的 **WorkBuddy / CodeBuddy** 账号模型的 LLM 适配器插件。
|
|
7
8
|
|
|
@@ -199,6 +200,7 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
199
200
|
| `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
|
|
200
201
|
| `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
|
|
201
202
|
| `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
|
|
203
|
+
| `GET /api/workbuddy/usage` | **用量统计**:读取本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`),返回 `{ today, byModel, total }`(今日/按模型/累计的 input/output tokens、请求次数,以及**积分消耗 `credit`**);非 GET 返回 405 |
|
|
202
204
|
|
|
203
205
|
> 会话文件与登录脚本路径的解析顺序:
|
|
204
206
|
> 1. 配置里显式指定的 `sessionFile` / `loginScript`;
|
|
@@ -214,6 +216,10 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
214
216
|
- 胶囊里有个 **🔍 诊断** 按钮:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
|
|
215
217
|
显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
|
|
216
218
|
发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
|
|
219
|
+
- 胶囊里有个 **📊 用量** 按钮:点它 `GET /api/workbuddy/usage`,弹出一个面板
|
|
220
|
+
显示**今日/累计 token 用量**、**积分消耗**(上游每次返回的 `credit` 累加)与
|
|
221
|
+
**按模型明细**(数据来自本地台账 `$DSH_HOME/llm-workbuddy/usage.jsonl`)。
|
|
222
|
+
注:代理不暴露余额/剩余积分接口,只能统计**已消耗**积分,无法显示账户剩余。
|
|
217
223
|
- 状态映射:
|
|
218
224
|
- `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
|
|
219
225
|
- 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
|
|
@@ -331,3 +337,18 @@ glm-5.0、minimax-m2.5 等,上游返回 `service info not found`)会被过
|
|
|
331
337
|
> 第三方 workbuddy2api 代理**不在包内**,需单独安装(见「安装代理」章节)。
|
|
332
338
|
> 仓库根目录的 `start-workbuddy.sh` / `login-workbuddy.sh` / `.workbuddy/` 是本地
|
|
333
339
|
> 开发用的配套文件,不随 npm 包发布。
|
|
340
|
+
|
|
341
|
+
## 更新日志 / Changelog
|
|
342
|
+
|
|
343
|
+
每次发版的改动记录见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
344
|
+
|
|
345
|
+
### 发版流程(确保 GitHub 有更新说明)
|
|
346
|
+
|
|
347
|
+
1. 改完代码后,在 `package.json` 里 `version` 自增(语义化版本)。
|
|
348
|
+
2. 在 `CHANGELOG.md` 顶部补一段本次版本(`## [x.y.z] - 日期`)的 Added / Fixed / Changed。
|
|
349
|
+
3. 提交并打 tag:`git commit -am "release: x.y.z"` 然后 `git tag vx.y.z`。
|
|
350
|
+
4. `git push origin main --tags`,到 GitHub 用该 tag 创建 **Release**——
|
|
351
|
+
Release 的描述直接引用 CHANGELOG 对应段落,仓库页面就有了「改了啥」的说明。
|
|
352
|
+
|
|
353
|
+
> 注意:本机 `dsh web` 通过符号链接直接加载本地目录,因此**本地改动能立刻重启生效**,
|
|
354
|
+
> GitHub 上的版本只影响通过 `dsh plugin add dsh-llm-workbuddy` 安装的其他用户。
|
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,106 @@ 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 hasCredit = (d.total.credit || 0) > 0;
|
|
246
|
+
var lines = [];
|
|
247
|
+
lines.push("今日:输入 " + d.today.inputTokens + " / 输出 " + d.today.outputTokens + " tokens(" + d.today.requests + " 次请求)");
|
|
248
|
+
lines.push("累计:输入 " + d.total.inputTokens + " / 输出 " + d.total.outputTokens + " tokens(" + d.total.requests + " 次请求)");
|
|
249
|
+
if (hasCredit) {
|
|
250
|
+
lines.push("今日积分消耗:" + fmtCredit(d.today.credit) + " | 累计:" + fmtCredit(d.total.credit));
|
|
251
|
+
}
|
|
252
|
+
lines.push("");
|
|
253
|
+
lines.push("按模型明细:");
|
|
254
|
+
if (d.byModel.length === 0) lines.push(" 暂无数据");
|
|
255
|
+
for (var i = 0; i < d.byModel.length; i += 1) {
|
|
256
|
+
var m = d.byModel[i];
|
|
257
|
+
var extra = (m.credit || 0) > 0 ? ",积分 " + fmtCredit(m.credit) : "";
|
|
258
|
+
lines.push(" " + m.model + ":输入 " + m.inputTokens + " / 输出 " + m.outputTokens + "(" + m.requests + " 次" + extra + ")");
|
|
259
|
+
}
|
|
260
|
+
var note = hasCredit
|
|
261
|
+
? "数据来源:本地用量台账(含上游每次返回的积分消耗;代理不暴露余额接口,无法显示剩余积分)"
|
|
262
|
+
: "数据来源:本地 token 用量台账(" + d.total.requests + " 次累计);当前模型为免费/折扣档,上游回报积分为 0";
|
|
263
|
+
el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 用量统计</div>' +
|
|
264
|
+
'<div class="wb-usage-body" style="white-space:pre-wrap;word-break:break-word">' + esc(lines.join("\n")) + "</div>" +
|
|
265
|
+
'<div style="margin-top:8px;color:#64748b;font-size:11px">' + esc(note) + "</div>";
|
|
266
|
+
})
|
|
267
|
+
.catch(function (err) {
|
|
268
|
+
el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;color:#f87171">用量加载失败</div>' +
|
|
269
|
+
'<div class="wb-usage-body">' + esc(String(err && err.message || err)) + "</div>";
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
209
273
|
function esc(s) {
|
|
210
274
|
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
211
275
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
212
276
|
});
|
|
213
277
|
}
|
|
214
278
|
|
|
279
|
+
/** 格式化积分消耗:保留 2 位小数,去掉无意义的尾零。 */
|
|
280
|
+
function fmtCredit(n) {
|
|
281
|
+
var v = Number(n) || 0;
|
|
282
|
+
return (Math.round(v * 100) / 100).toString();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 手动领取今日签到(幂等:官方对已签到返回业务拒绝,不会出错)。 */
|
|
286
|
+
function onCheckin() {
|
|
287
|
+
var btn = host.querySelector("#wb-checkin");
|
|
288
|
+
if (!btn || btn.dataset.busy) return;
|
|
289
|
+
btn.dataset.busy = "1";
|
|
290
|
+
btn.textContent = "⏳";
|
|
291
|
+
fetch("/api/workbuddy/checkin", { method: "POST" })
|
|
292
|
+
.then(function (r) { return r.json(); })
|
|
293
|
+
.then(function (d) {
|
|
294
|
+
btn.textContent = d && d.ok ? "✅" : "🎁";
|
|
295
|
+
btn.title = (d && d.message) || "签到结果未知";
|
|
296
|
+
refresh();
|
|
297
|
+
})
|
|
298
|
+
.catch(function () {
|
|
299
|
+
btn.textContent = "🎁";
|
|
300
|
+
btn.title = "签到请求失败(代理未运行?)";
|
|
301
|
+
})
|
|
302
|
+
.then(function () { delete btn.dataset.busy; });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 刷新模型列表:清插件发现缓存 + 触发模型选择器立即重载。 */
|
|
306
|
+
function onRefreshModels() {
|
|
307
|
+
var btn = host.querySelector("#wb-models");
|
|
308
|
+
if (!btn || btn.dataset.busy) return;
|
|
309
|
+
btn.dataset.busy = "1";
|
|
310
|
+
btn.textContent = "…";
|
|
311
|
+
fetch("/api/workbuddy/refresh-models", { method: "POST" })
|
|
312
|
+
.then(function (r) { return r.json(); })
|
|
313
|
+
.then(function (d) {
|
|
314
|
+
btn.textContent = d && typeof d.count === "number" ? d.count + "✓" : "✓";
|
|
315
|
+
btn.title = d && d.announced
|
|
316
|
+
? "已刷新:" + d.count + " 个模型(模型选择器已更新)"
|
|
317
|
+
: "已刷新模型缓存(事件通知失败,重开设置页可见新列表)";
|
|
318
|
+
setTimeout(function () { btn.textContent = "🔄"; }, 2000);
|
|
319
|
+
})
|
|
320
|
+
.catch(function () {
|
|
321
|
+
btn.textContent = "🔄";
|
|
322
|
+
btn.title = "刷新失败(代理未运行?)";
|
|
323
|
+
})
|
|
324
|
+
.then(function () { delete btn.dataset.busy; });
|
|
325
|
+
}
|
|
326
|
+
|
|
215
327
|
refresh();
|
|
216
328
|
timer = setInterval(refresh, POLL_MS);
|
|
217
329
|
}
|
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;
|
|
@@ -139,6 +145,17 @@ function liveReasoningEffort(entry, catalogEffort) {
|
|
|
139
145
|
return lower === "low" || lower === "medium" || lower === "high" ? lower : undefined;
|
|
140
146
|
}
|
|
141
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
|
+
|
|
142
159
|
|
|
143
160
|
// #region serialize
|
|
144
161
|
|
|
@@ -337,14 +354,115 @@ function mapFinishReason(reason) {
|
|
|
337
354
|
function mapUsage(usage) {
|
|
338
355
|
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens;
|
|
339
356
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
357
|
+
// The upstream reports per-request `credit` consumption in the usage block
|
|
358
|
+
// (e.g. deepseek-v4-pro → 0.02). Free/discounted models report 0, so we
|
|
359
|
+
// only keep the field when it is a positive number — that is the real
|
|
360
|
+
// 积分 spend we want to surface (the proxy exposes no balance endpoint).
|
|
361
|
+
const credit = typeof usage.credit === "number" && usage.credit > 0 ? usage.credit : undefined;
|
|
340
362
|
return {
|
|
341
363
|
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
|
|
342
364
|
outputTokens: usage.completion_tokens,
|
|
343
365
|
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
344
366
|
...(reasoning !== undefined ? { reasoningTokens: reasoning } : {}),
|
|
367
|
+
...(credit !== undefined ? { credit } : {}),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// #region usage ledger
|
|
372
|
+
|
|
373
|
+
/** Resolve the usage ledger file under DSH_HOME (falls back to ~/.dsh). */
|
|
374
|
+
function usageLedgerPath() {
|
|
375
|
+
const configured = process.env.DSH_HOME;
|
|
376
|
+
if (configured !== undefined && configured.trim().length > 0) {
|
|
377
|
+
return join(configured, USAGE_DIR_NAME, USAGE_FILE_NAME);
|
|
378
|
+
}
|
|
379
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
380
|
+
return join(home, ".dsh", USAGE_DIR_NAME, USAGE_FILE_NAME);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Append one completed request's token usage to the ledger (best-effort). */
|
|
384
|
+
function recordUsage(model, usage) {
|
|
385
|
+
if (usage === undefined) return;
|
|
386
|
+
try {
|
|
387
|
+
const file = usageLedgerPath();
|
|
388
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
389
|
+
const line = JSON.stringify({
|
|
390
|
+
ts: Date.now(),
|
|
391
|
+
model,
|
|
392
|
+
input: usage.inputTokens ?? 0,
|
|
393
|
+
output: usage.outputTokens ?? 0,
|
|
394
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
395
|
+
reasoning: usage.reasoningTokens ?? 0,
|
|
396
|
+
credit: usage.credit ?? 0,
|
|
397
|
+
});
|
|
398
|
+
appendFileSync(file, `${line}\n`);
|
|
399
|
+
// Trim the ledger when it exceeds the cap: keep only the newest lines.
|
|
400
|
+
const size = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).length : 0;
|
|
401
|
+
if (size > USAGE_MAX_LINES) {
|
|
402
|
+
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-USAGE_MAX_LINES);
|
|
403
|
+
const fd = openSync(file, "w");
|
|
404
|
+
try { writeSync(fd, `${lines.join("\n")}\n`); } finally { closeSync(fd); }
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// usage recording must never break the request path
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Read the ledger and aggregate into daily/model breakdowns. */
|
|
412
|
+
function readUsageLedger() {
|
|
413
|
+
const file = usageLedgerPath();
|
|
414
|
+
const today = new Date();
|
|
415
|
+
today.setHours(0, 0, 0, 0);
|
|
416
|
+
const todayMs = today.getTime();
|
|
417
|
+
const byModel = new Map();
|
|
418
|
+
let todayInput = 0;
|
|
419
|
+
let todayOutput = 0;
|
|
420
|
+
let todayCredit = 0;
|
|
421
|
+
let todayRequests = 0;
|
|
422
|
+
let totalInput = 0;
|
|
423
|
+
let totalOutput = 0;
|
|
424
|
+
let totalCredit = 0;
|
|
425
|
+
let totalRequests = 0;
|
|
426
|
+
if (!existsSync(file)) {
|
|
427
|
+
return { today: { inputTokens: 0, outputTokens: 0, credit: 0, requests: 0 }, byModel: [], total: { inputTokens: 0, outputTokens: 0, credit: 0, requests: 0 } };
|
|
428
|
+
}
|
|
429
|
+
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
let entry;
|
|
432
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
433
|
+
const input = entry.input ?? 0;
|
|
434
|
+
const output = entry.output ?? 0;
|
|
435
|
+
const credit = entry.credit ?? 0;
|
|
436
|
+
totalInput += input;
|
|
437
|
+
totalOutput += output;
|
|
438
|
+
totalCredit += credit;
|
|
439
|
+
totalRequests += 1;
|
|
440
|
+
if ((entry.ts ?? 0) >= todayMs) {
|
|
441
|
+
todayInput += input;
|
|
442
|
+
todayOutput += output;
|
|
443
|
+
todayCredit += credit;
|
|
444
|
+
todayRequests += 1;
|
|
445
|
+
}
|
|
446
|
+
const model = String(entry.model ?? "unknown");
|
|
447
|
+
const agg = byModel.get(model) ?? { inputTokens: 0, outputTokens: 0, credit: 0, requests: 0 };
|
|
448
|
+
agg.inputTokens += input;
|
|
449
|
+
agg.outputTokens += output;
|
|
450
|
+
agg.credit += credit;
|
|
451
|
+
agg.requests += 1;
|
|
452
|
+
byModel.set(model, agg);
|
|
453
|
+
}
|
|
454
|
+
const byModelList = [...byModel.entries()]
|
|
455
|
+
.map(([model, agg]) => ({ model, ...agg }))
|
|
456
|
+
.sort((a, b) => b.credit - a.credit || b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
|
|
457
|
+
return {
|
|
458
|
+
today: { inputTokens: todayInput, outputTokens: todayOutput, credit: todayCredit, requests: todayRequests },
|
|
459
|
+
byModel: byModelList,
|
|
460
|
+
total: { inputTokens: totalInput, outputTokens: totalOutput, credit: totalCredit, requests: totalRequests },
|
|
345
461
|
};
|
|
346
462
|
}
|
|
347
463
|
|
|
464
|
+
// #endregion
|
|
465
|
+
|
|
348
466
|
/** Assemble the final ContentBlock for one open block. */
|
|
349
467
|
function closeBlock(block) {
|
|
350
468
|
switch (block.kind) {
|
|
@@ -443,7 +561,9 @@ async function* translate(payloads) {
|
|
|
443
561
|
argumentsDelta: fragment,
|
|
444
562
|
};
|
|
445
563
|
}
|
|
446
|
-
|
|
564
|
+
// Hy (Hunyuan) models emit `"finish_reason": ""` on every intermediate
|
|
565
|
+
// chunk; only treat a non-empty vocabulary value as the real finish.
|
|
566
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) pendingFinish = mapFinishReason(choice.finish_reason);
|
|
447
567
|
}
|
|
448
568
|
if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
|
|
449
569
|
}
|
|
@@ -515,6 +635,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
515
635
|
return { id: provider, name: "WorkBuddy" };
|
|
516
636
|
}
|
|
517
637
|
|
|
638
|
+
/** Drop the discovery cache so the next listModels re-fetches from the proxy. */
|
|
639
|
+
refreshModels() {
|
|
640
|
+
this._cache = undefined;
|
|
641
|
+
this._liveMeta = undefined;
|
|
642
|
+
}
|
|
643
|
+
|
|
518
644
|
async listModels(provider) {
|
|
519
645
|
const connection = this.config.options();
|
|
520
646
|
if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -544,18 +670,31 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
544
670
|
: typeof entry.display_name === "string"
|
|
545
671
|
? entry.display_name
|
|
546
672
|
: undefined,
|
|
673
|
+
description: typeof entry.description === "string" && entry.description.length > 0
|
|
674
|
+
? entry.description
|
|
675
|
+
: undefined,
|
|
676
|
+
modalities: liveInputModalities(entry),
|
|
677
|
+
contextWindow: Number.isInteger(entry.context_window) && entry.context_window > 0
|
|
678
|
+
? entry.context_window
|
|
679
|
+
: undefined,
|
|
680
|
+
maxTokens: Number.isInteger(entry.max_context_window) && entry.max_context_window > 0
|
|
681
|
+
? entry.max_context_window
|
|
682
|
+
: undefined,
|
|
547
683
|
}))
|
|
548
684
|
.filter((entry) => entry.id.length > 0);
|
|
549
685
|
const merged = [];
|
|
550
686
|
const seen = new Set();
|
|
687
|
+
const liveMeta = new Map();
|
|
551
688
|
for (const entry of live) {
|
|
552
689
|
if (seen.has(entry.id)) continue;
|
|
553
690
|
seen.add(entry.id);
|
|
554
691
|
const catalog = byId.get(entry.id);
|
|
555
|
-
//
|
|
556
|
-
// retired models (glm-4.
|
|
557
|
-
//
|
|
558
|
-
//
|
|
692
|
+
// The static catalog doubles as a whitelist. The proxy still announces
|
|
693
|
+
// retired/legacy models (deepseek-v3-1, glm-4.6, glm-4.6v, kimi-k2,
|
|
694
|
+
// kimi-k2-thinking, minimax-m2.5, hunyuan-image-v3.0, ...) that the
|
|
695
|
+
// upstream rejects with `service info not found`. Drop any live entry
|
|
696
|
+
// that is not in the catalog so those never reach the UI; the catalog
|
|
697
|
+
// is the curated set that matches what the official client shows.
|
|
559
698
|
if (catalog === undefined) continue;
|
|
560
699
|
const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
|
|
561
700
|
const reasoning = modelReasoningInfo(effort);
|
|
@@ -563,10 +702,13 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
563
702
|
provider,
|
|
564
703
|
id: entry.id,
|
|
565
704
|
name: entry.name ?? catalog?.name ?? entry.id,
|
|
566
|
-
...(catalog?.description !== undefined
|
|
567
|
-
|
|
705
|
+
...(catalog?.description !== undefined || entry.description !== undefined
|
|
706
|
+
? { description: catalog?.description ?? entry.description }
|
|
707
|
+
: {}),
|
|
708
|
+
inputModalities: catalog?.inputModalities ?? entry.modalities,
|
|
568
709
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
569
710
|
});
|
|
711
|
+
liveMeta.set(entry.id, { contextWindow: entry.contextWindow, maxTokens: entry.maxTokens });
|
|
570
712
|
}
|
|
571
713
|
// Catalog entries the proxy did not announce (e.g. unauthenticated or
|
|
572
714
|
// partial listing) stay selectable.
|
|
@@ -576,6 +718,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
576
718
|
}
|
|
577
719
|
const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
|
|
578
720
|
this._cache = { at: now, models: result };
|
|
721
|
+
this._liveMeta = liveMeta;
|
|
579
722
|
return result;
|
|
580
723
|
} catch {
|
|
581
724
|
return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -585,13 +728,18 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
585
728
|
resolveModel(provider, model, _signal) {
|
|
586
729
|
const connection = this.config.options();
|
|
587
730
|
const configured = connection.models.find((entry) => entry.id === model);
|
|
731
|
+
// Models discovered live (proxy-announced, not in the configured catalog)
|
|
732
|
+
// still get their platform-declared capacity from the last discovery.
|
|
733
|
+
const live = this._liveMeta?.get(model);
|
|
588
734
|
const base = configured === undefined
|
|
589
735
|
? { provider, id: model, name: model, inputModalities: ["text"] }
|
|
590
736
|
: modelInfo(provider, configured);
|
|
591
737
|
return Promise.resolve({
|
|
592
738
|
...base,
|
|
593
|
-
context: {
|
|
594
|
-
|
|
739
|
+
context: {
|
|
740
|
+
contextWindow: configured?.contextWindow ?? live?.contextWindow ?? connection.defaultContextWindow,
|
|
741
|
+
},
|
|
742
|
+
defaultMaxTokens: configured?.maxTokens ?? live?.maxTokens ?? connection.maxTokens,
|
|
595
743
|
});
|
|
596
744
|
}
|
|
597
745
|
|
|
@@ -605,13 +753,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
605
753
|
);
|
|
606
754
|
const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
|
|
607
755
|
let exhausted = false;
|
|
756
|
+
let usage;
|
|
608
757
|
try {
|
|
609
758
|
while (true) {
|
|
610
759
|
const result = await watchdog.next(iterator);
|
|
611
760
|
if (result.done) {
|
|
612
761
|
exhausted = true;
|
|
762
|
+
if (usage !== undefined) recordUsage(options.model, usage);
|
|
613
763
|
return;
|
|
614
764
|
}
|
|
765
|
+
if (result.value?.type === "usage") usage = result.value.usage;
|
|
615
766
|
yield result.value;
|
|
616
767
|
}
|
|
617
768
|
} catch (error) {
|
|
@@ -702,6 +853,10 @@ export const Config = z.object({
|
|
|
702
853
|
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
|
703
854
|
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
704
855
|
discovery: z.boolean().default(true),
|
|
856
|
+
// Daily checkin: after `checkinAfterHour` local time, claim once per day if
|
|
857
|
+
// the official activity says today is unclaimed. Disable to check in manually.
|
|
858
|
+
autoCheckin: z.boolean().default(true),
|
|
859
|
+
checkinAfterHour: z.number().step(1).min(0).max(23).default(10),
|
|
705
860
|
// Optional path overrides for the login helper. When unset, the plugin uses
|
|
706
861
|
// the login_workbuddy.py bundled with this package and the default session
|
|
707
862
|
// file location (~/.codebuddy-session.json).
|
|
@@ -764,6 +919,10 @@ export function resolveAdapterOptions(config) {
|
|
|
764
919
|
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
765
920
|
throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
766
921
|
}
|
|
922
|
+
const checkinAfterHour = config.checkinAfterHour ?? 10;
|
|
923
|
+
if (!Number.isInteger(checkinAfterHour) || checkinAfterHour < 0 || checkinAfterHour > 23) {
|
|
924
|
+
throw new Error("dsh-llm-workbuddy: checkinAfterHour must be an integer between 0 and 23");
|
|
925
|
+
}
|
|
767
926
|
return {
|
|
768
927
|
baseURL,
|
|
769
928
|
apiKey: config.apiKey,
|
|
@@ -772,6 +931,8 @@ export function resolveAdapterOptions(config) {
|
|
|
772
931
|
models: resolveModels(config.models),
|
|
773
932
|
streamIdleTimeoutMs,
|
|
774
933
|
discovery: config.discovery ?? true,
|
|
934
|
+
autoCheckin: config.autoCheckin ?? true,
|
|
935
|
+
checkinAfterHour,
|
|
775
936
|
loginScript: config.loginScript,
|
|
776
937
|
sessionFile: config.sessionFile,
|
|
777
938
|
};
|
|
@@ -779,6 +940,176 @@ export function resolveAdapterOptions(config) {
|
|
|
779
940
|
|
|
780
941
|
// #endregion
|
|
781
942
|
|
|
943
|
+
// #region daily checkin
|
|
944
|
+
|
|
945
|
+
/** Checkin state file under DSH_HOME (same ledger dir as usage). */
|
|
946
|
+
const CHECKIN_FILE_NAME = "checkin.json";
|
|
947
|
+
/** How often the auto-claim scheduler wakes up. */
|
|
948
|
+
const CHECKIN_INTERVAL_MS = 30 * 60_000;
|
|
949
|
+
/** Request timeout for the proxy checkin endpoints. */
|
|
950
|
+
const CHECKIN_TIMEOUT_MS = 10_000;
|
|
951
|
+
|
|
952
|
+
/** Resolve the checkin state file path (same convention as usageLedgerPath). */
|
|
953
|
+
function checkinStatePath() {
|
|
954
|
+
const configured = process.env.DSH_HOME;
|
|
955
|
+
if (configured !== undefined && configured.trim().length > 0) {
|
|
956
|
+
return join(configured, USAGE_DIR_NAME, CHECKIN_FILE_NAME);
|
|
957
|
+
}
|
|
958
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
959
|
+
return join(home, ".dsh", USAGE_DIR_NAME, CHECKIN_FILE_NAME);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Read the persisted checkin state. Never throws. */
|
|
963
|
+
function readCheckinState() {
|
|
964
|
+
try {
|
|
965
|
+
return JSON.parse(readFileSync(checkinStatePath(), "utf-8"));
|
|
966
|
+
} catch {
|
|
967
|
+
return {};
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Persist the checkin state (best-effort; failure must not break the caller). */
|
|
972
|
+
function writeCheckinState(state) {
|
|
973
|
+
try {
|
|
974
|
+
const file = checkinStatePath();
|
|
975
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
976
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
977
|
+
} catch {
|
|
978
|
+
// persistence is advisory
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Local calendar date (`YYYY-MM-DD`) for "did we already handle today". */
|
|
983
|
+
function localDateStr(date = new Date()) {
|
|
984
|
+
const y = date.getFullYear();
|
|
985
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
986
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
987
|
+
return `${y}-${m}-${d}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Call a proxy checkin endpoint. `method` defaults to GET (status probe);
|
|
992
|
+
* claiming passes "POST". Returns the parsed body or an error shape.
|
|
993
|
+
*/
|
|
994
|
+
async function proxyCheckinCall(baseURL, path, method = "GET") {
|
|
995
|
+
const controller = new AbortController();
|
|
996
|
+
const timer = setTimeout(() => controller.abort(), CHECKIN_TIMEOUT_MS);
|
|
997
|
+
try {
|
|
998
|
+
const response = await fetch(`${baseURL.replace(/\/$/, "")}${path}`, {
|
|
999
|
+
method,
|
|
1000
|
+
...(method === "POST" ? { headers: { "content-type": "application/json" }, body: "{}" } : {}),
|
|
1001
|
+
signal: controller.signal,
|
|
1002
|
+
});
|
|
1003
|
+
const body = await response.json().catch(() => null);
|
|
1004
|
+
if (!response.ok) {
|
|
1005
|
+
return { error: `HTTP ${response.status} (${method} ${path})`, ...(body ? { body } : {}) };
|
|
1006
|
+
}
|
|
1007
|
+
return body ?? { error: "empty response" };
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return { error: String(error?.cause?.message ?? error?.message ?? error) };
|
|
1010
|
+
} finally {
|
|
1011
|
+
clearTimeout(timer);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Whether an upstream claim rejection means "already claimed today". */
|
|
1016
|
+
function alreadyClaimedMessage(msg) {
|
|
1017
|
+
return typeof msg === "string" && /(已签到|已经签到|already)/i.test(msg);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Run one auto-claim pass. Idempotent per local calendar day:
|
|
1022
|
+
* - before `checkinAfterHour` (default 10:00) local time: no-op;
|
|
1023
|
+
* - already handled today (persisted state): no-op;
|
|
1024
|
+
* - official status says already checked in: mark handled, done;
|
|
1025
|
+
* - activity not open (no status data): mark handled so we stop retrying today;
|
|
1026
|
+
* - otherwise claim, persisting the reward (credit, streak) on success.
|
|
1027
|
+
* Concurrent invocations collapse into the first caller via `inFlight`.
|
|
1028
|
+
* @returns the resulting state snapshot, or undefined when skipped/in-flight.
|
|
1029
|
+
*/
|
|
1030
|
+
async function runCheckinOnce(baseURL, config, logger) {
|
|
1031
|
+
if (!config.autoCheckin) return undefined;
|
|
1032
|
+
const now = new Date();
|
|
1033
|
+
if (now.getHours() < config.checkinAfterHour) return undefined;
|
|
1034
|
+
const today = localDateStr(now);
|
|
1035
|
+
const state = readCheckinState();
|
|
1036
|
+
if (state.handledDate === today) return state;
|
|
1037
|
+
if (runCheckinOnce._inFlight) return undefined;
|
|
1038
|
+
runCheckinOnce._inFlight = true;
|
|
1039
|
+
try {
|
|
1040
|
+
const status = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1041
|
+
const data = status?.upstream?.data;
|
|
1042
|
+
if (!data) {
|
|
1043
|
+
// Activity closed / proxy unreachable / not authenticated: stop for today.
|
|
1044
|
+
const next = {
|
|
1045
|
+
...state,
|
|
1046
|
+
handledDate: today,
|
|
1047
|
+
lastResult: { ok: false, message: status?.error ?? status?.upstream?.msg ?? "签到活动不可用", at: now.getTime() },
|
|
1048
|
+
};
|
|
1049
|
+
writeCheckinState(next);
|
|
1050
|
+
logger?.debug?.(`dsh-llm-workbuddy: checkin skipped today (${next.lastResult.message})`);
|
|
1051
|
+
return next;
|
|
1052
|
+
}
|
|
1053
|
+
if (data.today_checked_in) {
|
|
1054
|
+
const next = {
|
|
1055
|
+
...state,
|
|
1056
|
+
handledDate: today,
|
|
1057
|
+
lastResult: { ok: true, message: "今日已签到(官方确认)", streakDays: data.streak_days, at: now.getTime() },
|
|
1058
|
+
};
|
|
1059
|
+
writeCheckinState(next);
|
|
1060
|
+
return next;
|
|
1061
|
+
}
|
|
1062
|
+
const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
|
|
1063
|
+
const payload = claim?.upstream?.data;
|
|
1064
|
+
// "Already claimed" rejections count as handled (the goal is covered).
|
|
1065
|
+
const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
|
|
1066
|
+
const failureDetail = [claim?.upstream?.msg, claim?.error, claim?.body?.detail]
|
|
1067
|
+
.filter(Boolean).join(" ") || "签到失败";
|
|
1068
|
+
const next = {
|
|
1069
|
+
...state,
|
|
1070
|
+
handledDate: today,
|
|
1071
|
+
lastResult: {
|
|
1072
|
+
ok,
|
|
1073
|
+
message: ok
|
|
1074
|
+
? payload?.credit !== undefined
|
|
1075
|
+
? `自动签到成功:+${payload.credit} 积分`
|
|
1076
|
+
: "今日已签到"
|
|
1077
|
+
: failureDetail,
|
|
1078
|
+
...(payload?.credit !== undefined ? { credit: payload.credit } : {}),
|
|
1079
|
+
...(payload?.streak_days !== undefined ? { streakDays: payload.streak_days } : {}),
|
|
1080
|
+
at: now.getTime(),
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
writeCheckinState(next);
|
|
1084
|
+
logger?.info?.(`dsh-llm-workbuddy: ${next.lastResult.message}`);
|
|
1085
|
+
return next;
|
|
1086
|
+
} finally {
|
|
1087
|
+
runCheckinOnce._inFlight = false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Start the auto-claim scheduler: one immediate pass plus a low-frequency
|
|
1093
|
+
* interval. The interval survives plugin-lifetime only (disposed with the
|
|
1094
|
+
* fiber), and every pass re-reads the live config so settings hot-edit
|
|
1095
|
+
* (autoCheckin / checkinAfterHour) applies to the very next pass.
|
|
1096
|
+
*/
|
|
1097
|
+
function startCheckinScheduler(ctx, options) {
|
|
1098
|
+
const tick = () => {
|
|
1099
|
+
const connection = options();
|
|
1100
|
+
runCheckinOnce(connection.baseURL, connection, ctx.logger).catch((error) => {
|
|
1101
|
+
ctx.logger.warn("dsh-llm-workbuddy: auto checkin pass failed");
|
|
1102
|
+
ctx.logger.warn(error);
|
|
1103
|
+
});
|
|
1104
|
+
};
|
|
1105
|
+
tick();
|
|
1106
|
+
const timer = setInterval(tick, CHECKIN_INTERVAL_MS);
|
|
1107
|
+
timer.unref?.();
|
|
1108
|
+
return () => clearInterval(timer);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// #endregion
|
|
1112
|
+
|
|
782
1113
|
// #region login status web API
|
|
783
1114
|
|
|
784
1115
|
/**
|
|
@@ -948,14 +1279,18 @@ function diagnoseRestartCommand() {
|
|
|
948
1279
|
}
|
|
949
1280
|
|
|
950
1281
|
/**
|
|
951
|
-
* Register
|
|
952
|
-
* `
|
|
953
|
-
* `
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
* `
|
|
1282
|
+
* Register the WorkBuddy web API routes on the DSH web server:
|
|
1283
|
+
* - `GET /api/workbuddy/status` login/proxy health + checkin digest;
|
|
1284
|
+
* - `POST /api/workbuddy/login` device-flow login kick-off;
|
|
1285
|
+
* - `POST /api/workbuddy/diagnose` real end-to-end health probe;
|
|
1286
|
+
* - `GET /api/workbuddy/usage` token usage ledger;
|
|
1287
|
+
* - `POST /api/workbuddy/refresh-models` drop the discovery cache, re-read the
|
|
1288
|
+
* proxy model list (which tracks the official WorkBuddy app), and announce
|
|
1289
|
+
* `llm/adapters-updated` so open model pickers reload immediately;
|
|
1290
|
+
* - `GET /api/workbuddy/checkin` today's checkin state (official query);
|
|
1291
|
+
* - `POST /api/workbuddy/checkin` claim now (idempotent upstream).
|
|
957
1292
|
*/
|
|
958
|
-
function registerWorkbuddyRoutes(ctx, config) {
|
|
1293
|
+
function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
959
1294
|
// Delay registration until the webServer service exists. The plugin keeps
|
|
960
1295
|
// `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
|
|
961
1296
|
// load — but a synchronous `ctx.get("webServer")` probe at apply time races
|
|
@@ -983,11 +1318,20 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
983
1318
|
}
|
|
984
1319
|
const session = readSessionStatus(sessionFile);
|
|
985
1320
|
const proxy = await probeProxy(baseURL);
|
|
1321
|
+
// Checkin digest comes from the persisted state only — never block the
|
|
1322
|
+
// 5s status poll with an upstream call.
|
|
1323
|
+
const checkinState = readCheckinState();
|
|
1324
|
+
const today = localDateStr();
|
|
986
1325
|
res.writeHead(200, { "content-type": "application/json" });
|
|
987
1326
|
res.end(JSON.stringify({
|
|
988
1327
|
...session,
|
|
989
1328
|
...proxy,
|
|
990
1329
|
loginScriptAvailable: existsSync(loginScript),
|
|
1330
|
+
checkin: {
|
|
1331
|
+
autoEnabled: config?.autoCheckin ?? true,
|
|
1332
|
+
handledToday: checkinState.handledDate === today,
|
|
1333
|
+
lastResult: checkinState.lastResult ?? null,
|
|
1334
|
+
},
|
|
991
1335
|
}));
|
|
992
1336
|
},
|
|
993
1337
|
}));
|
|
@@ -1058,6 +1402,101 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
1058
1402
|
}));
|
|
1059
1403
|
},
|
|
1060
1404
|
}));
|
|
1405
|
+
webCtx.effect(() => webServer.register({
|
|
1406
|
+
kind: "exact",
|
|
1407
|
+
path: "/api/workbuddy/usage",
|
|
1408
|
+
async handler(req, res) {
|
|
1409
|
+
if (req.method !== "GET") {
|
|
1410
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1411
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1415
|
+
res.end(JSON.stringify(readUsageLedger()));
|
|
1416
|
+
},
|
|
1417
|
+
}));
|
|
1418
|
+
webCtx.effect(() => webServer.register({
|
|
1419
|
+
kind: "exact",
|
|
1420
|
+
path: "/api/workbuddy/refresh-models",
|
|
1421
|
+
async handler(req, res) {
|
|
1422
|
+
if (req.method !== "POST") {
|
|
1423
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1424
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
// Drop the discovery cache so listModels re-reads the proxy, then
|
|
1428
|
+
// re-commit the (unchanged) configurable-provider entries: commit is
|
|
1429
|
+
// the one mutation point that publishes `llm/adapters-updated`, which
|
|
1430
|
+
// makes every open model picker reload immediately.
|
|
1431
|
+
adapter.refreshModels();
|
|
1432
|
+
let announced = true;
|
|
1433
|
+
try {
|
|
1434
|
+
providersHandle?.replace?.([{ provider: PROVIDER, displayName: "WorkBuddy", settingsNs: NS, settingsPath: [] }]);
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
announced = false;
|
|
1437
|
+
ctx.logger.warn("dsh-llm-workbuddy: failed to announce adapters-updated");
|
|
1438
|
+
ctx.logger.warn(error);
|
|
1439
|
+
}
|
|
1440
|
+
const models = await adapter.listModels(PROVIDER).catch(() => []);
|
|
1441
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1442
|
+
res.end(JSON.stringify({ ok: true, announced, count: models.length, models }));
|
|
1443
|
+
},
|
|
1444
|
+
}));
|
|
1445
|
+
webCtx.effect(() => webServer.register({
|
|
1446
|
+
kind: "exact",
|
|
1447
|
+
path: "/api/workbuddy/checkin",
|
|
1448
|
+
async handler(req, res) {
|
|
1449
|
+
// GET: today's persisted state plus a fresh official status probe.
|
|
1450
|
+
if (req.method === "GET") {
|
|
1451
|
+
const state = readCheckinState();
|
|
1452
|
+
const upstream = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1453
|
+
const data = upstream?.upstream?.data ?? null;
|
|
1454
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1455
|
+
res.end(JSON.stringify({
|
|
1456
|
+
handledToday: state.handledDate === localDateStr(),
|
|
1457
|
+
lastResult: state.lastResult ?? null,
|
|
1458
|
+
official: data === null ? null : {
|
|
1459
|
+
active: Boolean(data.active),
|
|
1460
|
+
todayCheckedIn: Boolean(data.today_checked_in),
|
|
1461
|
+
streakDays: data.streak_days ?? null,
|
|
1462
|
+
todayCredit: data.today_credit ?? null,
|
|
1463
|
+
streakBonusDays: data.streak_bonus_days ?? null,
|
|
1464
|
+
streakBonusCredit: data.streak_bonus_credit ?? null,
|
|
1465
|
+
},
|
|
1466
|
+
...(upstream?.error ? { error: upstream.error } : {}),
|
|
1467
|
+
}));
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
// POST: claim now. Idempotent — upstream reports "already checked in"
|
|
1471
|
+
// as a business result rather than an error.
|
|
1472
|
+
if (req.method !== "POST") {
|
|
1473
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
1474
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
|
|
1478
|
+
const payload = claim?.upstream?.data;
|
|
1479
|
+
const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
|
|
1480
|
+
const result = {
|
|
1481
|
+
ok,
|
|
1482
|
+
message: ok
|
|
1483
|
+
? payload?.credit !== undefined
|
|
1484
|
+
? `签到成功:+${payload.credit} 积分`
|
|
1485
|
+
: "今日已签到"
|
|
1486
|
+
: claim?.upstream?.msg ?? claim?.error ?? "签到失败",
|
|
1487
|
+
...(payload ?? {}),
|
|
1488
|
+
};
|
|
1489
|
+
if (ok || claim?.upstream?.msg) {
|
|
1490
|
+
writeCheckinState({
|
|
1491
|
+
...readCheckinState(),
|
|
1492
|
+
handledDate: ok ? localDateStr() : readCheckinState().handledDate,
|
|
1493
|
+
lastResult: { ok, message: result.message, at: Date.now() },
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1497
|
+
res.end(JSON.stringify(result));
|
|
1498
|
+
},
|
|
1499
|
+
}));
|
|
1061
1500
|
});
|
|
1062
1501
|
}
|
|
1063
1502
|
|
|
@@ -1104,14 +1543,15 @@ export function apply(ctx, config) {
|
|
|
1104
1543
|
options,
|
|
1105
1544
|
resolveAttachments: () => ctx.get("attachments"),
|
|
1106
1545
|
});
|
|
1107
|
-
ctx.llm.registerConfigurableProviders([{
|
|
1546
|
+
const providersHandle = ctx.llm.registerConfigurableProviders([{
|
|
1108
1547
|
provider: PROVIDER,
|
|
1109
1548
|
displayName: "WorkBuddy",
|
|
1110
1549
|
settingsNs: NS,
|
|
1111
1550
|
settingsPath: [],
|
|
1112
1551
|
}]);
|
|
1113
1552
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
1114
|
-
registerWorkbuddyRoutes(ctx, config);
|
|
1553
|
+
registerWorkbuddyRoutes(ctx, config, adapter, providersHandle);
|
|
1554
|
+
ctx.effect(() => startCheckinScheduler(ctx, options));
|
|
1115
1555
|
installSettingsSection(ctx, NS, Config, config, {
|
|
1116
1556
|
setSource: (source) => {
|
|
1117
1557
|
current = source;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-llm-workbuddy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"lib",
|
|
45
45
|
"login_workbuddy.py",
|
|
46
46
|
"cordis.patch.yml",
|
|
47
|
-
"README.md"
|
|
47
|
+
"README.md",
|
|
48
|
+
"CHANGELOG.md"
|
|
48
49
|
],
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"@deepseek-ai/cordis": "^4.0.1",
|