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/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 { WB_REGION_BASE, WbUpstream, WbUpstreamError } from './upstream.js';
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, dailyWindowText, discountText, priceViewOf, promoActiveAt } from './promo.js';
13
+ import { attachPromos, discountText, priceViewOf, promoActiveAt } from './promo.js';
14
14
  import { UsageLedger, summarize } from './usage.js';
15
- import { DEFAULT_SAFE_MODEL, SAFE_MODEL_IDS, classifyModel, preferredSafeModel, safeGroupsOf } from './safety.js';
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
- * 0.3.0 起不再往宿主主界面注入按钮:这里只剩「中转站优先」开关本身的状态。
156
- * @type {null | { appRoot: string, consoleDir: string, htmlPath: string, ok: boolean, reason: string, changed: string[], checkedAt: number }}
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 || WB_REGION_BASE[this.cfg.region] || WB_REGION_BASE.cn;
177
+ return this.cfg.baseUrl || WB_BASE;
185
178
  }
186
179
 
187
180
  upstream() {
188
- return new WbUpstream({ baseUrl: this.baseUrl(), region: this.cfg.region, log: this.log });
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.model ?? '')),
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.prompt ?? ''),
467
- model: typeof args.model === 'string' ? args.model : undefined,
468
- system: typeof args.system === 'string' ? args.system : undefined,
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
- if (state.running !== before) this.broadcast({ kind: 'process', ...this.priorityState() });
493
- // 主进程退出后中转站不可能再用:把「中转站优先」一并熄灭,避免界面亮着却调不通。
494
- if (!state.running && this.preferOn) await this.setPrefer(false);
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(() => this.broadcast({ kind: 'process', ...this.priorityState() }))
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 桥接已挂载:${this.cfg.region} 区,目录 ${this.catalog.length} 个模型,当前模型 ${this.cfg.model || '(未选)'}。`
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
- const flag = view.promoId === p.id ? '生效中' : view.badgeActive ? '即将生效' : '未生效';
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
- // 中转站可用的前提:WorkBuddy 桌面端主进程在跑。
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, region: this.cfg.region, source: this.tokens?.source ?? '', ...entry });
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 ? '用量' : 'Usage',
790
+ title: zh ? '用量与成本' : 'Usage & cost',
840
791
  description: zh
841
- ? '本机记账的调用用量(积分与 token)、WorkBuddy 积分包余额,以及当前生效的限时折扣 / 免费。'
842
- : 'Call usage recorded locally (credits and tokens), WorkBuddy credit balance, and currently active time-limited discounts / free tiers.',
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 !== 'priority' && panel !== 'usage') {
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
- await this.ledger.prune(now);
922
- const records = await this.ledger.readAll();
923
- const summary = summarize(records, now, { days: 30 });
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
- // 用本地日期,不用 toISOString():后者是 UTC,在 UTC+8 的 00:00–08:00 之间
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 {