dsh-llm-workbuddy 0.1.11 → 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 +4 -0
- package/lib/client.js +98 -0
- package/lib/index.js +447 -20
- 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 时额外提示 `代理未运行`
|
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;
|
|
@@ -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
|
|
|
@@ -345,6 +362,94 @@ function mapUsage(usage) {
|
|
|
345
362
|
};
|
|
346
363
|
}
|
|
347
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
|
+
|
|
348
453
|
/** Assemble the final ContentBlock for one open block. */
|
|
349
454
|
function closeBlock(block) {
|
|
350
455
|
switch (block.kind) {
|
|
@@ -443,7 +548,9 @@ async function* translate(payloads) {
|
|
|
443
548
|
argumentsDelta: fragment,
|
|
444
549
|
};
|
|
445
550
|
}
|
|
446
|
-
|
|
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);
|
|
447
554
|
}
|
|
448
555
|
if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
|
|
449
556
|
}
|
|
@@ -515,6 +622,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
515
622
|
return { id: provider, name: "WorkBuddy" };
|
|
516
623
|
}
|
|
517
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
|
+
|
|
518
631
|
async listModels(provider) {
|
|
519
632
|
const connection = this.config.options();
|
|
520
633
|
if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -544,18 +657,31 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
544
657
|
: typeof entry.display_name === "string"
|
|
545
658
|
? entry.display_name
|
|
546
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,
|
|
547
670
|
}))
|
|
548
671
|
.filter((entry) => entry.id.length > 0);
|
|
549
672
|
const merged = [];
|
|
550
673
|
const seen = new Set();
|
|
674
|
+
const liveMeta = new Map();
|
|
551
675
|
for (const entry of live) {
|
|
552
676
|
if (seen.has(entry.id)) continue;
|
|
553
677
|
seen.add(entry.id);
|
|
554
678
|
const catalog = byId.get(entry.id);
|
|
555
|
-
//
|
|
556
|
-
// retired models (glm-4.
|
|
557
|
-
//
|
|
558
|
-
//
|
|
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.
|
|
559
685
|
if (catalog === undefined) continue;
|
|
560
686
|
const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
|
|
561
687
|
const reasoning = modelReasoningInfo(effort);
|
|
@@ -563,10 +689,13 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
563
689
|
provider,
|
|
564
690
|
id: entry.id,
|
|
565
691
|
name: entry.name ?? catalog?.name ?? entry.id,
|
|
566
|
-
...(catalog?.description !== undefined
|
|
567
|
-
|
|
692
|
+
...(catalog?.description !== undefined || entry.description !== undefined
|
|
693
|
+
? { description: catalog?.description ?? entry.description }
|
|
694
|
+
: {}),
|
|
695
|
+
inputModalities: catalog?.inputModalities ?? entry.modalities,
|
|
568
696
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
569
697
|
});
|
|
698
|
+
liveMeta.set(entry.id, { contextWindow: entry.contextWindow, maxTokens: entry.maxTokens });
|
|
570
699
|
}
|
|
571
700
|
// Catalog entries the proxy did not announce (e.g. unauthenticated or
|
|
572
701
|
// partial listing) stay selectable.
|
|
@@ -576,6 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
576
705
|
}
|
|
577
706
|
const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
|
|
578
707
|
this._cache = { at: now, models: result };
|
|
708
|
+
this._liveMeta = liveMeta;
|
|
579
709
|
return result;
|
|
580
710
|
} catch {
|
|
581
711
|
return connection.models.map((model) => modelInfo(provider, model));
|
|
@@ -585,13 +715,18 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
585
715
|
resolveModel(provider, model, _signal) {
|
|
586
716
|
const connection = this.config.options();
|
|
587
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);
|
|
588
721
|
const base = configured === undefined
|
|
589
722
|
? { provider, id: model, name: model, inputModalities: ["text"] }
|
|
590
723
|
: modelInfo(provider, configured);
|
|
591
724
|
return Promise.resolve({
|
|
592
725
|
...base,
|
|
593
|
-
context: {
|
|
594
|
-
|
|
726
|
+
context: {
|
|
727
|
+
contextWindow: configured?.contextWindow ?? live?.contextWindow ?? connection.defaultContextWindow,
|
|
728
|
+
},
|
|
729
|
+
defaultMaxTokens: configured?.maxTokens ?? live?.maxTokens ?? connection.maxTokens,
|
|
595
730
|
});
|
|
596
731
|
}
|
|
597
732
|
|
|
@@ -605,13 +740,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
|
|
|
605
740
|
);
|
|
606
741
|
const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
|
|
607
742
|
let exhausted = false;
|
|
743
|
+
let usage;
|
|
608
744
|
try {
|
|
609
745
|
while (true) {
|
|
610
746
|
const result = await watchdog.next(iterator);
|
|
611
747
|
if (result.done) {
|
|
612
748
|
exhausted = true;
|
|
749
|
+
if (usage !== undefined) recordUsage(options.model, usage);
|
|
613
750
|
return;
|
|
614
751
|
}
|
|
752
|
+
if (result.value?.type === "usage") usage = result.value.usage;
|
|
615
753
|
yield result.value;
|
|
616
754
|
}
|
|
617
755
|
} catch (error) {
|
|
@@ -702,6 +840,10 @@ export const Config = z.object({
|
|
|
702
840
|
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
|
703
841
|
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
704
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),
|
|
705
847
|
// Optional path overrides for the login helper. When unset, the plugin uses
|
|
706
848
|
// the login_workbuddy.py bundled with this package and the default session
|
|
707
849
|
// file location (~/.codebuddy-session.json).
|
|
@@ -764,6 +906,10 @@ export function resolveAdapterOptions(config) {
|
|
|
764
906
|
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
765
907
|
throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
766
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
|
+
}
|
|
767
913
|
return {
|
|
768
914
|
baseURL,
|
|
769
915
|
apiKey: config.apiKey,
|
|
@@ -772,6 +918,8 @@ export function resolveAdapterOptions(config) {
|
|
|
772
918
|
models: resolveModels(config.models),
|
|
773
919
|
streamIdleTimeoutMs,
|
|
774
920
|
discovery: config.discovery ?? true,
|
|
921
|
+
autoCheckin: config.autoCheckin ?? true,
|
|
922
|
+
checkinAfterHour,
|
|
775
923
|
loginScript: config.loginScript,
|
|
776
924
|
sessionFile: config.sessionFile,
|
|
777
925
|
};
|
|
@@ -779,6 +927,176 @@ export function resolveAdapterOptions(config) {
|
|
|
779
927
|
|
|
780
928
|
// #endregion
|
|
781
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
|
+
|
|
782
1100
|
// #region login status web API
|
|
783
1101
|
|
|
784
1102
|
/**
|
|
@@ -948,14 +1266,18 @@ function diagnoseRestartCommand() {
|
|
|
948
1266
|
}
|
|
949
1267
|
|
|
950
1268
|
/**
|
|
951
|
-
* Register
|
|
952
|
-
* `
|
|
953
|
-
* `
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
* `
|
|
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).
|
|
957
1279
|
*/
|
|
958
|
-
function registerWorkbuddyRoutes(ctx, config) {
|
|
1280
|
+
function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
959
1281
|
// Delay registration until the webServer service exists. The plugin keeps
|
|
960
1282
|
// `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
|
|
961
1283
|
// load — but a synchronous `ctx.get("webServer")` probe at apply time races
|
|
@@ -983,11 +1305,20 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
983
1305
|
}
|
|
984
1306
|
const session = readSessionStatus(sessionFile);
|
|
985
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();
|
|
986
1312
|
res.writeHead(200, { "content-type": "application/json" });
|
|
987
1313
|
res.end(JSON.stringify({
|
|
988
1314
|
...session,
|
|
989
1315
|
...proxy,
|
|
990
1316
|
loginScriptAvailable: existsSync(loginScript),
|
|
1317
|
+
checkin: {
|
|
1318
|
+
autoEnabled: config?.autoCheckin ?? true,
|
|
1319
|
+
handledToday: checkinState.handledDate === today,
|
|
1320
|
+
lastResult: checkinState.lastResult ?? null,
|
|
1321
|
+
},
|
|
991
1322
|
}));
|
|
992
1323
|
},
|
|
993
1324
|
}));
|
|
@@ -1058,6 +1389,101 @@ function registerWorkbuddyRoutes(ctx, config) {
|
|
|
1058
1389
|
}));
|
|
1059
1390
|
},
|
|
1060
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
|
+
}));
|
|
1061
1487
|
});
|
|
1062
1488
|
}
|
|
1063
1489
|
|
|
@@ -1104,14 +1530,15 @@ export function apply(ctx, config) {
|
|
|
1104
1530
|
options,
|
|
1105
1531
|
resolveAttachments: () => ctx.get("attachments"),
|
|
1106
1532
|
});
|
|
1107
|
-
ctx.llm.registerConfigurableProviders([{
|
|
1533
|
+
const providersHandle = ctx.llm.registerConfigurableProviders([{
|
|
1108
1534
|
provider: PROVIDER,
|
|
1109
1535
|
displayName: "WorkBuddy",
|
|
1110
1536
|
settingsNs: NS,
|
|
1111
1537
|
settingsPath: [],
|
|
1112
1538
|
}]);
|
|
1113
1539
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
1114
|
-
registerWorkbuddyRoutes(ctx, config);
|
|
1540
|
+
registerWorkbuddyRoutes(ctx, config, adapter, providersHandle);
|
|
1541
|
+
ctx.effect(() => startCheckinScheduler(ctx, options));
|
|
1115
1542
|
installSettingsSection(ctx, NS, Config, config, {
|
|
1116
1543
|
setSource: (source) => {
|
|
1117
1544
|
current = source;
|
package/package.json
CHANGED