cortico-world-workbuddy 0.3.0 → 0.3.4
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 +127 -2
- package/README.md +71 -54
- package/SECURITY.md +29 -16
- package/describe.json +2 -2
- package/dist/webui.js +58 -72
- package/package.json +3 -3
- package/scripts/clean-legacy-injection.mjs +77 -0
- package/scripts/clean-workbuddy.mjs +101 -0
- package/scripts/contract-regression.mjs +221 -0
- package/scripts/contract.mjs +45 -17
- package/scripts/reinstall.ps1 +15 -4
- package/scripts/selfcheck.mjs +1 -1
- package/scripts/verify-ui.mjs +19 -33
- package/src/ENV_PROMPT.md +4 -10
- package/src/config.js +5 -16
- package/src/credentials.js +11 -3
- package/src/definition.js +3 -3
- package/src/promo.js +64 -12
- package/src/upstream.js +31 -63
- package/src/usage.js +27 -21
- package/src/wbproc.js +0 -7
- package/src/webui/client.js +62 -84
- package/src/world.js +96 -139
package/src/world.js
CHANGED
|
@@ -7,12 +7,12 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { WB_CONFIG_GROUP } from './config.js';
|
|
10
|
-
import {
|
|
10
|
+
import { WB_BASE, WbUpstream, WbUpstreamError } from './upstream.js';
|
|
11
11
|
import { discoverCredentials, maskToken, resolveTokensWithKey } from './credentials.js';
|
|
12
12
|
import { detectWorkBuddy } from './wbproc.js';
|
|
13
|
-
import { attachPromos,
|
|
13
|
+
import { attachPromos, discountText, priceViewOf, promoActiveAt } from './promo.js';
|
|
14
14
|
import { UsageLedger, summarize } from './usage.js';
|
|
15
|
-
import {
|
|
15
|
+
import { classifyModel, preferredSafeModel, safeGroupsOf } from './safety.js';
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
@@ -22,13 +22,9 @@ const CATALOG_FILE = 'workbuddy-catalog.json';
|
|
|
22
22
|
const L10N = {
|
|
23
23
|
zh: {
|
|
24
24
|
noAuth: '未发现本机 WorkBuddy 登录态。请先登录 WorkBuddy 客户端,或在「账号」面板手动粘贴 access token。',
|
|
25
|
-
catalogEmpty: '模型目录为空,请在「模型目录」面板点「从 WorkBuddy 刷新」。',
|
|
26
|
-
noModel: '尚未选择模型。请在「模型目录」面板选一个模型。',
|
|
27
25
|
},
|
|
28
26
|
en: {
|
|
29
27
|
noAuth: 'No local WorkBuddy sign-in found. Sign in the WorkBuddy desktop app, or paste an access token in the Account panel.',
|
|
30
|
-
catalogEmpty: 'Model catalog is empty. Click "Refresh from WorkBuddy" in the Models panel.',
|
|
31
|
-
noModel: 'No model selected. Pick one in the Models panel.',
|
|
32
28
|
},
|
|
33
29
|
};
|
|
34
30
|
|
|
@@ -71,7 +67,7 @@ export const WB_TOOL_DECLS = [
|
|
|
71
67
|
{
|
|
72
68
|
name: 'wb_chat',
|
|
73
69
|
description:
|
|
74
|
-
'Ask the selected WorkBuddy model a single-turn question and return its answer. This spends the user\'s own WorkBuddy credits at the model\'s multiplier; keep prompts concise. Use the model parameter only to override the selected model for one call.',
|
|
70
|
+
'Ask the selected WorkBuddy model a single-turn question and return its answer. This spends the user\'s own WorkBuddy credits at the model\'s multiplier; keep prompts concise. Use the model parameter only to override the selected model for one call. Requires the WorkBuddy desktop app to be running.',
|
|
75
71
|
tags: ['write'],
|
|
76
72
|
parameters: {
|
|
77
73
|
type: 'object',
|
|
@@ -119,7 +115,6 @@ export const TOKEN_AUTO = 'wb-auto';
|
|
|
119
115
|
* 调到 `setToken` / `clearToken`(会写框架的密钥库)。
|
|
120
116
|
*/
|
|
121
117
|
export const PANEL_METHODS = {
|
|
122
|
-
priority: ['state', 'set', 'process'],
|
|
123
118
|
usage: ['state', 'refresh', 'clear'],
|
|
124
119
|
models: ['state', 'refresh', 'select'],
|
|
125
120
|
account: ['state', 'rediscover', 'credits', 'setToken', 'clearToken'],
|
|
@@ -151,11 +146,9 @@ export class WorkBuddyWorld {
|
|
|
151
146
|
this.proc = { running: false, pids: [], mainPid: null, via: 'unknown', detail: '', at: 0 };
|
|
152
147
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
153
148
|
this._procTimer = null;
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*/
|
|
158
|
-
/** @type {ReturnType<typeof setInterval> | null} */
|
|
149
|
+
/** 启动后 1.2 秒的二次确认定时器(防桌面端枚举慢半拍)。 */
|
|
150
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
151
|
+
this._procConfirm = null;
|
|
159
152
|
/**
|
|
160
153
|
* 目录里带进来的促销 / 档位(/v3/config 的 modelPromotions + modelTiers)。
|
|
161
154
|
* 倍率不能只看模型的 credits:夜间折扣、限时免费都挂在这些条目上。
|
|
@@ -181,11 +174,11 @@ export class WorkBuddyWorld {
|
|
|
181
174
|
}
|
|
182
175
|
|
|
183
176
|
baseUrl() {
|
|
184
|
-
return this.cfg.baseUrl ||
|
|
177
|
+
return this.cfg.baseUrl || WB_BASE;
|
|
185
178
|
}
|
|
186
179
|
|
|
187
180
|
upstream() {
|
|
188
|
-
return new WbUpstream({ baseUrl: this.baseUrl(),
|
|
181
|
+
return new WbUpstream({ baseUrl: this.baseUrl(), log: this.log });
|
|
189
182
|
}
|
|
190
183
|
|
|
191
184
|
catalogPath() {
|
|
@@ -193,7 +186,7 @@ export class WorkBuddyWorld {
|
|
|
193
186
|
}
|
|
194
187
|
|
|
195
188
|
// -------------------------------------------------------------------------
|
|
196
|
-
// WorkBuddy
|
|
189
|
+
// WorkBuddy 主进程检测(灯态、面板只读状态与 wb_* 工具共用)
|
|
197
190
|
// -------------------------------------------------------------------------
|
|
198
191
|
|
|
199
192
|
/**
|
|
@@ -212,7 +205,7 @@ export class WorkBuddyWorld {
|
|
|
212
205
|
at: state.at ?? Date.now(),
|
|
213
206
|
};
|
|
214
207
|
// 「在跑 → 没在跑」这种翻转要多看一眼:tasklist 在系统忙时偶尔漏一行,
|
|
215
|
-
//
|
|
208
|
+
// 一次误判就会让灯态闪成「未检测到主进程」。确认一次再落定,避免抖动。
|
|
216
209
|
if (!next.running && this.proc?.running && next.via !== 'error') {
|
|
217
210
|
await new Promise((r) => setTimeout(r, 300).unref?.());
|
|
218
211
|
const again = await detectWorkBuddy(true);
|
|
@@ -239,51 +232,6 @@ export class WorkBuddyWorld {
|
|
|
239
232
|
return this.proc;
|
|
240
233
|
}
|
|
241
234
|
|
|
242
|
-
/** 「中转站优先」是否打开。默认关。 */
|
|
243
|
-
get preferOn() {
|
|
244
|
-
return this.cfg.prefer === true;
|
|
245
|
-
}
|
|
246
|
-
/** 未开优先、或主进程没在跑时,真正能走中转站吗? */
|
|
247
|
-
get canRelay() {
|
|
248
|
-
return this.preferOn && this.proc.running;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* 开关的唯一写入口:World 详情页「是否启用Workbuddy」勾选框打这里。
|
|
253
|
-
* @param {boolean} on
|
|
254
|
-
*/
|
|
255
|
-
async setPrefer(on) {
|
|
256
|
-
const value = on === true;
|
|
257
|
-
this.cfg.prefer = value;
|
|
258
|
-
try {
|
|
259
|
-
this.ctx.persist({ prefer: value });
|
|
260
|
-
} catch (err) {
|
|
261
|
-
this.log.warn?.(`workbuddy: 保存「中转站优先」失败:${err?.message ?? err}`);
|
|
262
|
-
}
|
|
263
|
-
// 用缓存采样即可:定时器每 6s 刷一次,点开关不该卡在 tasklist 上。
|
|
264
|
-
await this.syncProc();
|
|
265
|
-
const state = this.priorityState();
|
|
266
|
-
this.broadcast({ kind: 'priority', ...state });
|
|
267
|
-
return state;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** 「中转站优先」面板用的状态对象。 */
|
|
271
|
-
priorityState() {
|
|
272
|
-
return {
|
|
273
|
-
/** @type {boolean} */
|
|
274
|
-
prefer: this.preferOn,
|
|
275
|
-
/** @type {boolean} */
|
|
276
|
-
running: !!this.proc.running,
|
|
277
|
-
mainPid: this.proc.mainPid,
|
|
278
|
-
via: this.proc.via,
|
|
279
|
-
model: this.cfg.model || '',
|
|
280
|
-
region: this.cfg.region,
|
|
281
|
-
authed: !!this.tokens,
|
|
282
|
-
catalogSize: this.catalog.length,
|
|
283
|
-
checkedAt: this.proc.at,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
235
|
// -------------------------------------------------------------------------
|
|
288
236
|
// 凭据:手动粘贴(密钥库)优先,其次本机发现
|
|
289
237
|
// -------------------------------------------------------------------------
|
|
@@ -300,6 +248,7 @@ export class WorkBuddyWorld {
|
|
|
300
248
|
if (manual === TOKEN_AUTO) manual = null;
|
|
301
249
|
if (manual && !signedOut) {
|
|
302
250
|
this.tokens = { accessToken: manual, source: 'manual' };
|
|
251
|
+
this.lastError = '';
|
|
303
252
|
return this.tokens;
|
|
304
253
|
}
|
|
305
254
|
// 用户显式登出后,不再偷偷从数据目录里把凭据读回来——否则「清除」等于没生效。
|
|
@@ -315,6 +264,7 @@ export class WorkBuddyWorld {
|
|
|
315
264
|
const resolved = await resolveTokensWithKey(found.credential);
|
|
316
265
|
if (resolved.accessToken) {
|
|
317
266
|
this.tokens = resolved;
|
|
267
|
+
this.lastError = '';
|
|
318
268
|
return this.tokens;
|
|
319
269
|
}
|
|
320
270
|
this.lastError = found.encrypted
|
|
@@ -359,7 +309,6 @@ export class WorkBuddyWorld {
|
|
|
359
309
|
JSON.stringify(
|
|
360
310
|
{
|
|
361
311
|
at: Date.now(),
|
|
362
|
-
region: this.cfg.region,
|
|
363
312
|
source: this.catalogSource,
|
|
364
313
|
models: this.catalog,
|
|
365
314
|
promos: this.promos,
|
|
@@ -429,19 +378,18 @@ export class WorkBuddyWorld {
|
|
|
429
378
|
|
|
430
379
|
envPromptVars() {
|
|
431
380
|
if (!this.cfg.envPrompt) return null;
|
|
381
|
+
this.rollTodayIfNeeded(); // 跨过午夜后提示词里的「今日积分」不能还累计昨天
|
|
432
382
|
const price = this.priceOf(this.cfg.model);
|
|
433
383
|
const promoBit =
|
|
434
384
|
price && price.promoId !== undefined && typeof price.effective === 'number'
|
|
435
385
|
? `(限时 ${discountText(this.promos.find((p) => p.id === price.promoId)) || '优惠'}${price.windowText ? ` ${price.windowText}` : ''})`
|
|
436
386
|
: '';
|
|
437
387
|
return {
|
|
438
|
-
'workbuddy.region': this.cfg.region === 'global' ? 'international' : 'domestic (CN)',
|
|
439
388
|
'workbuddy.model': this.cfg.model || '(none selected)',
|
|
440
389
|
'workbuddy.multiplier':
|
|
441
390
|
price && typeof price.effective === 'number' ? `x${price.effective}${promoBit}` : '(unknown)',
|
|
442
391
|
'workbuddy.catalogSize': String(this.catalog.length),
|
|
443
392
|
'workbuddy.authed': this.tokens ? 'yes' : 'no',
|
|
444
|
-
'workbuddy.priority': this.preferOn ? 'on' : 'off',
|
|
445
393
|
'workbuddy.process': this.proc.running ? 'running' : 'stopped',
|
|
446
394
|
'workbuddy.safeOnly': this.cfg.safeOnly === false ? 'off' : 'on',
|
|
447
395
|
'workbuddy.todayCredit': String(Math.round(this.today.credit * 100) / 100),
|
|
@@ -457,15 +405,15 @@ export class WorkBuddyWorld {
|
|
|
457
405
|
},
|
|
458
406
|
{
|
|
459
407
|
...WB_TOOL_DECLS[1],
|
|
460
|
-
handler: async (args) => this.toolSelectModel(String(args
|
|
408
|
+
handler: async (args) => this.toolSelectModel(String(args?.model ?? '')),
|
|
461
409
|
},
|
|
462
410
|
{
|
|
463
411
|
...WB_TOOL_DECLS[2],
|
|
464
412
|
handler: async (args) =>
|
|
465
413
|
this.toolChat({
|
|
466
|
-
prompt: String(args
|
|
467
|
-
model: typeof args
|
|
468
|
-
system: typeof args
|
|
414
|
+
prompt: String(args?.prompt ?? ''),
|
|
415
|
+
model: typeof args?.model === 'string' ? args.model : undefined,
|
|
416
|
+
system: typeof args?.system === 'string' ? args.system : undefined,
|
|
469
417
|
}),
|
|
470
418
|
},
|
|
471
419
|
{
|
|
@@ -479,7 +427,7 @@ export class WorkBuddyWorld {
|
|
|
479
427
|
this.host = host;
|
|
480
428
|
this.started = false;
|
|
481
429
|
// 整个启动流程包在 try/finally 里:任何一步抛错(网络、文件、宿主事件)都不能让
|
|
482
|
-
// `started` 永远停在 false
|
|
430
|
+
// `started` 永远停在 false——那会让两盏灯一直显示「正在读取登录态…」。
|
|
483
431
|
try {
|
|
484
432
|
this.loadCatalogFromDisk();
|
|
485
433
|
await this.loadTokens();
|
|
@@ -489,9 +437,15 @@ export class WorkBuddyWorld {
|
|
|
489
437
|
const before = this.proc.running;
|
|
490
438
|
try {
|
|
491
439
|
const state = await this.syncProc(true);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
440
|
+
// 只在「在跑 / 没在跑」真正翻转时推一次,避免每 6 秒让面板重绘。
|
|
441
|
+
if (state.running !== before) {
|
|
442
|
+
this.broadcast({
|
|
443
|
+
kind: 'process',
|
|
444
|
+
running: state.running,
|
|
445
|
+
mainPid: state.mainPid,
|
|
446
|
+
via: state.via,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
495
449
|
} catch {
|
|
496
450
|
/* 探测失败不改状态,等下一次 */
|
|
497
451
|
}
|
|
@@ -501,7 +455,9 @@ export class WorkBuddyWorld {
|
|
|
501
455
|
// 刚启动时探测偶尔比桌面端枚举慢半拍:1.2 秒后再确认一次,免得界面先亮出「未检测到主进程」。
|
|
502
456
|
this._procConfirm = setTimeout(() => {
|
|
503
457
|
void this.syncProc(true)
|
|
504
|
-
.then(() =>
|
|
458
|
+
.then((state) =>
|
|
459
|
+
this.broadcast({ kind: 'process', running: state.running, mainPid: state.mainPid, via: state.via }),
|
|
460
|
+
)
|
|
505
461
|
.catch(() => {});
|
|
506
462
|
}, 1200);
|
|
507
463
|
this._procConfirm.unref?.();
|
|
@@ -530,7 +486,7 @@ export class WorkBuddyWorld {
|
|
|
530
486
|
origin: 'internal',
|
|
531
487
|
senderKey: this.id,
|
|
532
488
|
text: this.tokens
|
|
533
|
-
? `WorkBuddy
|
|
489
|
+
? `WorkBuddy 桥接已挂载:目录 ${this.catalog.length} 个模型,当前模型 ${this.cfg.model || '(未选)'}。`
|
|
534
490
|
: 'WorkBuddy 桥接已挂载,但未发现登录态;wb_* 工具暂不可用。',
|
|
535
491
|
});
|
|
536
492
|
} catch (err) {
|
|
@@ -614,7 +570,9 @@ export class WorkBuddyWorld {
|
|
|
614
570
|
const base = this.findModel(p.modelIds[0]);
|
|
615
571
|
const view = priceViewOf({ ...(base ?? { id: p.modelIds[0] }), promos: [p] }, now);
|
|
616
572
|
const when = view.windowText || view.absoluteText || '';
|
|
617
|
-
|
|
573
|
+
// 生效与否只看时段(promoActiveAt):只挂徽标、不改价的促销没有 promoId,
|
|
574
|
+
// 拿「是否改价」判断会把正在生效的徽标类活动标成「即将生效」。
|
|
575
|
+
const flag = promoActiveAt(p, now) ? '生效中' : view.badgeActive ? '即将生效' : '未生效';
|
|
618
576
|
return `- ${p.id}:${discountText(p)}${when ? `,${when}` : ''},影响 ${p.modelIds.join('/')}(${flag})`;
|
|
619
577
|
});
|
|
620
578
|
const promoBlock = promoLines.length > 0 ? `\n限时优惠:\n${promoLines.join('\n')}` : '';
|
|
@@ -656,7 +614,7 @@ export class WorkBuddyWorld {
|
|
|
656
614
|
|
|
657
615
|
async toolChat({ prompt, model, system }) {
|
|
658
616
|
if (!prompt) return { text: 'prompt 不能为空。', failed: true };
|
|
659
|
-
//
|
|
617
|
+
// 桥接可用的前提:WorkBuddy 桌面端主进程在跑。
|
|
660
618
|
await this.syncProc();
|
|
661
619
|
if (!this.proc.running) {
|
|
662
620
|
return { text: '未检测到 WorkBuddy 主进程:请先启动 WorkBuddy 桌面端再试。', failed: true };
|
|
@@ -676,12 +634,11 @@ export class WorkBuddyWorld {
|
|
|
676
634
|
const messages = [];
|
|
677
635
|
if (system) messages.push({ role: 'system', content: system });
|
|
678
636
|
messages.push({ role: 'user', content: prompt });
|
|
679
|
-
const price = this.priceOf(useId);
|
|
680
637
|
const startedAt = Date.now();
|
|
681
638
|
/** 记一笔账本;失败不影响本次调用的回执。 */
|
|
682
639
|
const record = async (entry) => {
|
|
683
640
|
try {
|
|
684
|
-
await this.ledger.append({ ts: startedAt, model: useId,
|
|
641
|
+
await this.ledger.append({ ts: startedAt, model: useId, source: this.tokens?.source ?? '', ...entry });
|
|
685
642
|
} catch (err) {
|
|
686
643
|
this.log.warn?.(`workbuddy: 用量落盘失败:${err?.message ?? err}`);
|
|
687
644
|
}
|
|
@@ -693,6 +650,8 @@ export class WorkBuddyWorld {
|
|
|
693
650
|
() => {},
|
|
694
651
|
);
|
|
695
652
|
const durationMs = Date.now() - startedAt;
|
|
653
|
+
// 用响应完成时的价格记账,避免请求跨过折扣边界后仍写发起时的旧倍率。
|
|
654
|
+
const price = this.priceOf(useId);
|
|
696
655
|
const used = typeof usage?.credit === 'number' ? usage.credit : credit;
|
|
697
656
|
await record({
|
|
698
657
|
ok: true,
|
|
@@ -703,9 +662,14 @@ export class WorkBuddyWorld {
|
|
|
703
662
|
reasoningTokens: usage?.reasoningTokens,
|
|
704
663
|
credit: used,
|
|
705
664
|
multiplier: price?.effective,
|
|
665
|
+
baseMultiplier: price?.base,
|
|
706
666
|
promoId: price?.promoId,
|
|
707
667
|
durationMs,
|
|
708
668
|
});
|
|
669
|
+
// 跨午夜后先把今日小计翻篇,否则连续运行的 Coopanion 会把昨天的用量
|
|
670
|
+
// 继续累加到今天的徽标 / envPrompt(用量面板的 summary.totals.today 是对的,
|
|
671
|
+
// 两处「今日积分」互相矛盾 —— 审查报告第 4 条)。
|
|
672
|
+
this.rollTodayIfNeeded();
|
|
709
673
|
if (typeof used === 'number') this.today.credit += used;
|
|
710
674
|
this.today.calls += 1;
|
|
711
675
|
if (typeof usage?.promptTokens === 'number') this.today.prompt += usage.promptTokens;
|
|
@@ -762,6 +726,7 @@ export class WorkBuddyWorld {
|
|
|
762
726
|
// -------------------------------------------------------------------------
|
|
763
727
|
|
|
764
728
|
webUi(language) {
|
|
729
|
+
this.rollTodayIfNeeded(); // 详情页徽标同样要跟着本地日期翻篇
|
|
765
730
|
const zh = language !== 'en';
|
|
766
731
|
return {
|
|
767
732
|
label: zh ? 'WorkBuddy 桥接' : 'WorkBuddy Bridge',
|
|
@@ -784,14 +749,8 @@ export class WorkBuddyWorld {
|
|
|
784
749
|
? '未检测到 WorkBuddy 主进程'
|
|
785
750
|
: '正在检测 WorkBuddy 主进程…',
|
|
786
751
|
},
|
|
787
|
-
{
|
|
788
|
-
label: zh ? '中转站优先' : 'Relay first',
|
|
789
|
-
state: this.preferOn ? 'online' : 'offline',
|
|
790
|
-
hint: this.preferOn ? '开:桌宠优先走 WorkBuddy 模型' : '关:跳过 WorkBuddy',
|
|
791
|
-
},
|
|
792
752
|
],
|
|
793
753
|
badges: [
|
|
794
|
-
{ label: zh ? '区域' : 'Region', value: this.cfg.region, tone: 'plain' },
|
|
795
754
|
{
|
|
796
755
|
label: zh ? '当前模型' : 'Model',
|
|
797
756
|
value: this.cfg.model || '—',
|
|
@@ -826,20 +785,12 @@ export class WorkBuddyWorld {
|
|
|
826
785
|
: 'Credential source, credit balance and manual token entry. Tokens live in the secret store only.',
|
|
827
786
|
getMethods: ['state'],
|
|
828
787
|
},
|
|
829
|
-
{
|
|
830
|
-
id: 'priority',
|
|
831
|
-
title: zh ? '是否启用 WorkBuddy' : 'Enable WorkBuddy',
|
|
832
|
-
description: zh
|
|
833
|
-
? '打开后桌宠每轮对话优先调用 WorkBuddy 的模型,关闭则完全跳过。默认为关。需要 WorkBuddy 桌面端主进程在运行。'
|
|
834
|
-
: 'When on, the pet asks a WorkBuddy model first every turn; when off, WorkBuddy is skipped entirely. Off by default. Requires the WorkBuddy desktop app to be running.',
|
|
835
|
-
getMethods: ['state'],
|
|
836
|
-
},
|
|
837
788
|
{
|
|
838
789
|
id: 'usage',
|
|
839
|
-
title: zh ? '
|
|
790
|
+
title: zh ? '用量与成本' : 'Usage & cost',
|
|
840
791
|
description: zh
|
|
841
|
-
? '
|
|
842
|
-
: '
|
|
792
|
+
? '经由 WorkBuddy 消耗的 token 量与积分量(本地记账)、积分包余额,以及当前生效的限时折扣 / 免费。'
|
|
793
|
+
: 'Tokens and credits spent through WorkBuddy (locally recorded), the credit balance, and currently active time-limited discounts / free tiers.',
|
|
843
794
|
getMethods: ['state'],
|
|
844
795
|
},
|
|
845
796
|
],
|
|
@@ -851,12 +802,10 @@ export class WorkBuddyWorld {
|
|
|
851
802
|
path: ENV_PROMPT_FILE,
|
|
852
803
|
role: 'envPrompt',
|
|
853
804
|
vars: [
|
|
854
|
-
{ name: 'workbuddy.region', description: '桥接的区域(国内 / 国际)。' },
|
|
855
805
|
{ name: 'workbuddy.model', description: '当前选用的模型 id。' },
|
|
856
806
|
{ name: 'workbuddy.multiplier', description: '所选模型的默认积分倍率。' },
|
|
857
807
|
{ name: 'workbuddy.catalogSize', description: '目录里模型的数量。' },
|
|
858
808
|
{ name: 'workbuddy.authed', description: '是否已有可用登录态。' },
|
|
859
|
-
{ name: 'workbuddy.priority', description: '「中转站优先」开关:on / off。' },
|
|
860
809
|
{ name: 'workbuddy.process', description: 'WorkBuddy 主进程状态:running / stopped。' },
|
|
861
810
|
{ name: 'workbuddy.safeOnly', description: '安全清单闸门:on(只放行公开对话模型)/ off。' },
|
|
862
811
|
{ name: 'workbuddy.todayCredit', description: '今日已消耗积分(本地记账)。' },
|
|
@@ -864,10 +813,9 @@ export class WorkBuddyWorld {
|
|
|
864
813
|
],
|
|
865
814
|
},
|
|
866
815
|
],
|
|
867
|
-
storage: [{ key: 'workbuddy-catalog', label: zh ? '模型目录缓存' : 'Model catalog cache' }],
|
|
868
816
|
invoke: (panel, method, args) => this.handleInvoke(panel, method, args, language),
|
|
869
817
|
stream: (panel, socket) => {
|
|
870
|
-
if (panel !== 'models' && panel !== 'account' && panel !== '
|
|
818
|
+
if (panel !== 'models' && panel !== 'account' && panel !== 'usage') {
|
|
871
819
|
socket.close('unknown panel');
|
|
872
820
|
return;
|
|
873
821
|
}
|
|
@@ -915,12 +863,19 @@ export class WorkBuddyWorld {
|
|
|
915
863
|
*
|
|
916
864
|
* 口径:积分以每次调用 SSE 里的 `usage.credit` 为准(上游真实扣费);
|
|
917
865
|
* 倍率只用于「预估/解释」,不拿它去折算金额。
|
|
866
|
+
*
|
|
918
867
|
*/
|
|
919
868
|
async usageState() {
|
|
920
869
|
const now = Date.now();
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
870
|
+
// 0.3.0 每次打开面板都整文件重写一遍账本(prune 无条件执行)。只有确实存在
|
|
871
|
+
// 超期行时才裁,且裁完重新读,保证聚合用的是裁剪后的内容。
|
|
872
|
+
let records = await this.ledger.readAll();
|
|
873
|
+
const cutoff = now - 90 * 864e5;
|
|
874
|
+
if (records.some((r) => typeof r.ts !== 'number' || r.ts < cutoff)) {
|
|
875
|
+
await this.ledger.prune(now);
|
|
876
|
+
records = await this.ledger.readAll();
|
|
877
|
+
}
|
|
878
|
+
const summary = summarize(records, now);
|
|
924
879
|
const balance = this.lastBalance ?? (await this.ledger.readBalance());
|
|
925
880
|
const promos = this.promos.map((promo) => {
|
|
926
881
|
const firstId = Array.isArray(promo.modelIds) ? promo.modelIds[0] : undefined;
|
|
@@ -953,22 +908,36 @@ export class WorkBuddyWorld {
|
|
|
953
908
|
catalogAt: this.catalogAt,
|
|
954
909
|
model: this.cfg.model,
|
|
955
910
|
price: this.priceOf(this.cfg.model),
|
|
956
|
-
region: this.cfg.region,
|
|
957
911
|
running: !!this.proc.running,
|
|
958
912
|
authed: !!this.tokens,
|
|
959
913
|
};
|
|
960
914
|
}
|
|
961
915
|
|
|
962
|
-
/**
|
|
916
|
+
/** 本地日期键 YYYY-MM-DD(与 usage.dayKey 同口径,绝不用 toISOString 的 UTC 日期)。 */
|
|
917
|
+
localDay(ms = Date.now()) {
|
|
918
|
+
const d = new Date(ms);
|
|
919
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* 跨过本地午夜后把「今日」小计归零。
|
|
924
|
+
*
|
|
925
|
+
* envPromptVars() / webUi() 是同步的,不能在这里 await 账本;先把过期的计数清掉,
|
|
926
|
+
* 下一次 refreshToday()(启动、清空账本)再从账本重算精确值。
|
|
927
|
+
*/
|
|
928
|
+
rollTodayIfNeeded() {
|
|
929
|
+
const day = this.localDay();
|
|
930
|
+
if (this.today.day === day) return this.today;
|
|
931
|
+
this.today = { day, credit: 0, calls: 0, prompt: 0, completion: 0 };
|
|
932
|
+
return this.today;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** 从账本重算今日小计(启动时 + 清空账本时调用)。 */
|
|
963
936
|
async refreshToday() {
|
|
964
937
|
const records = await this.ledger.readAll();
|
|
965
938
|
const start = new Date();
|
|
966
939
|
start.setHours(0, 0, 0, 0);
|
|
967
|
-
|
|
968
|
-
// 会得到「昨天」,面板上的「今天」就会指错一天。
|
|
969
|
-
const day = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(
|
|
970
|
-
start.getDate(),
|
|
971
|
-
).padStart(2, '0')}`;
|
|
940
|
+
const day = this.localDay(start.getTime());
|
|
972
941
|
const today = { day, credit: 0, calls: 0, prompt: 0, completion: 0 };
|
|
973
942
|
for (const r of records) {
|
|
974
943
|
if (typeof r?.ts !== 'number' || r.ts < start.getTime()) continue;
|
|
@@ -1002,31 +971,6 @@ export class WorkBuddyWorld {
|
|
|
1002
971
|
if (!allowed) throw new Error(`unknown panel: ${panel}`);
|
|
1003
972
|
if (!allowed.includes(method)) throw new Error(`panel ${panel} 不支持方法 ${method}`);
|
|
1004
973
|
|
|
1005
|
-
// 「中转站优先」:详情页开关走这组方法,状态只有一个来源。
|
|
1006
|
-
if (panel === 'priority') {
|
|
1007
|
-
if (method === 'state') {
|
|
1008
|
-
await this.syncProc();
|
|
1009
|
-
return { ok: true, ...this.priorityState() };
|
|
1010
|
-
}
|
|
1011
|
-
if (method === 'set') {
|
|
1012
|
-
const on = args[0] === true || args[0] === 'on' || args[0] === 1 || args[0] === 'true';
|
|
1013
|
-
if (on) {
|
|
1014
|
-
await this.syncProc();
|
|
1015
|
-
if (!this.proc.running) {
|
|
1016
|
-
// 未检测到主进程:保持熄灭,并把原因交给调用方弹提示。
|
|
1017
|
-
const state = this.priorityState();
|
|
1018
|
-
return { ok: false, error: '未检测到Workbuddy', reason: 'no-process', ...state };
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
const state = await this.setPrefer(on);
|
|
1022
|
-
return { ok: true, ...state };
|
|
1023
|
-
}
|
|
1024
|
-
if (method === 'process') {
|
|
1025
|
-
await this.syncProc(true);
|
|
1026
|
-
return { ok: true, ...this.priorityState() };
|
|
1027
|
-
}
|
|
1028
|
-
throw new Error(`unknown method: ${method}`);
|
|
1029
|
-
}
|
|
1030
974
|
// 「用量」面板:本地账本聚合 + 积分包快照 + 促销清单。积分以 usage.credit 为准,不拿倍率倒推。
|
|
1031
975
|
if (panel === 'usage') {
|
|
1032
976
|
if (method === 'state') return await this.usageState();
|
|
@@ -1048,14 +992,12 @@ export class WorkBuddyWorld {
|
|
|
1048
992
|
tokenSource: this.tokens?.source ?? null,
|
|
1049
993
|
tokenMask: this.tokens?.accessToken ? maskToken(this.tokens.accessToken) : null,
|
|
1050
994
|
account: this.tokens?.account ?? null,
|
|
1051
|
-
region: this.cfg.region,
|
|
1052
995
|
baseUrl: this.baseUrl(),
|
|
1053
996
|
model: this.cfg.model,
|
|
1054
997
|
catalogAt: this.catalogAt,
|
|
1055
998
|
lastError: this.lastError,
|
|
1056
999
|
safeOnly: this.cfg.safeOnly !== false,
|
|
1057
1000
|
models: this.catalog.map((m) => this.describeModel(m)),
|
|
1058
|
-
prefer: this.preferOn,
|
|
1059
1001
|
running: !!this.proc.running,
|
|
1060
1002
|
mainPid: this.proc.mainPid,
|
|
1061
1003
|
};
|
|
@@ -1084,6 +1026,7 @@ export class WorkBuddyWorld {
|
|
|
1084
1026
|
if (!token) return { ok: false, error: 'empty token' };
|
|
1085
1027
|
this.ctx.storeSecret('accessToken', token);
|
|
1086
1028
|
this.tokens = { accessToken: token, source: 'manual' };
|
|
1029
|
+
this.lastError = '';
|
|
1087
1030
|
return { ok: true, mask: maskToken(token) };
|
|
1088
1031
|
}
|
|
1089
1032
|
if (method === 'clearToken') {
|
|
@@ -1096,6 +1039,9 @@ export class WorkBuddyWorld {
|
|
|
1096
1039
|
return { ok: false, error: `写入失败:${err?.message ?? err}` };
|
|
1097
1040
|
}
|
|
1098
1041
|
this.tokens = null;
|
|
1042
|
+
// 立刻给出可操作提示:0.3.0 要等重启后 loadTokens 才设 lastError,
|
|
1043
|
+
// 当下灯只显示「未发现登录态」,用户不知道该点「重新探测」。
|
|
1044
|
+
this.lastError = '已登出:点「重新探测」或重新填 token 才能继续使用。';
|
|
1099
1045
|
if (!stored) return { ok: false, error: '密钥库拒绝了写入,未能登出。' };
|
|
1100
1046
|
return { ok: true, authed: false };
|
|
1101
1047
|
}
|
|
@@ -1112,10 +1058,21 @@ export class WorkBuddyWorld {
|
|
|
1112
1058
|
}
|
|
1113
1059
|
}
|
|
1114
1060
|
if (method === 'rediscover') {
|
|
1061
|
+
// 只有「密钥库里还留着 wb-cleared 登出哨兵」时才需要把它换成 wb-auto。
|
|
1062
|
+
// 用户手动粘贴过 token 时 loadTokens() 直接返回那份 manual token,
|
|
1063
|
+
// 0.3.0 这时仍写 TOKEN_AUTO,把密钥库里的真 token 覆盖掉,重启后手动 token 丢失
|
|
1064
|
+
// (审查报告第 3 条)。
|
|
1065
|
+
let stored = null;
|
|
1066
|
+
try {
|
|
1067
|
+
stored = this.ctx.secret('accessToken');
|
|
1068
|
+
} catch {
|
|
1069
|
+
stored = null;
|
|
1070
|
+
}
|
|
1071
|
+
const wasCleared = stored === TOKEN_CLEARED;
|
|
1115
1072
|
this.tokens = null;
|
|
1116
1073
|
// 用户主动要求重新探测:这时才允许越过「已登出」哨兵去读数据目录。
|
|
1117
1074
|
await this.loadTokens({ rediscover: true });
|
|
1118
|
-
if (this.tokens?.accessToken) {
|
|
1075
|
+
if (this.tokens?.accessToken && wasCleared) {
|
|
1119
1076
|
// 探测成功就把哨兵从「已登出」换成「允许自动发现」:
|
|
1120
1077
|
// 否则密钥库里留着 wb-cleared,扩展一重启又会变回未登录(审查报告第 2 条)。
|
|
1121
1078
|
try {
|