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/promo.js CHANGED
@@ -23,6 +23,9 @@
23
23
  * 没有可用折扣的促销(只有徽标,例如「订阅优先」)不改变倍率。
24
24
  */
25
25
 
26
+ /** WorkBuddy 未声明时区时的默认活动时区。 */
27
+ export const DEFAULT_TIMEZONE = 'Asia/Shanghai';
28
+
26
29
  /** `'HH:mm'` → 当天分钟数;`24:00` 表示 1440(一天的终点)。 */
27
30
  export function minutesOfClock(value) {
28
31
  if (typeof value !== 'string') return undefined;
@@ -59,7 +62,7 @@ function minutesInZone(nowMs, timeZone) {
59
62
  * 返回 0 等于把所有活动都当成「在 00:00 生效」,过期促销会被误判成正在打折。
60
63
  */
61
64
  export function zonedMinutes(nowMs, timeZone) {
62
- const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() : 'UTC';
65
+ const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() : DEFAULT_TIMEZONE;
63
66
  try {
64
67
  return minutesInZone(nowMs, wanted) ?? NaN;
65
68
  } catch {
@@ -71,17 +74,55 @@ export function zonedMinutes(nowMs, timeZone) {
71
74
  }
72
75
  }
73
76
 
77
+ /** 把 YYYY-MM-DD 的当地午夜换算成时间戳;非法时区沿用每日时段的 UTC 回退。 */
78
+ function zonedDateTimestamp(year, month, day, timeZone) {
79
+ const target = Date.UTC(year, month - 1, day);
80
+ const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() : DEFAULT_TIMEZONE;
81
+ let formatter;
82
+ try {
83
+ formatter = new Intl.DateTimeFormat('en-US', {
84
+ timeZone: wanted,
85
+ year: 'numeric', month: '2-digit', day: '2-digit',
86
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
87
+ });
88
+ } catch {
89
+ formatter = new Intl.DateTimeFormat('en-US', {
90
+ timeZone: 'UTC',
91
+ year: 'numeric', month: '2-digit', day: '2-digit',
92
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
93
+ });
94
+ }
95
+ let guess = target;
96
+ // 先按 UTC 猜,再用该时区偏移修正;迭代两次可覆盖 DST 切换前后的偏移变化。
97
+ for (let i = 0; i < 2; i += 1) {
98
+ const parts = formatter.formatToParts(new Date(guess));
99
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
100
+ const localAsUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
101
+ guess = target - (localAsUtc - guess);
102
+ }
103
+ return guess;
104
+ }
105
+
74
106
  /**
75
107
  * 把上游给的时间点归一成毫秒时间戳。
76
- * 数字也认(< 1e12 视为 Unix 秒),字符串则先试纯数字再试 Date.parse。
108
+ * 数字也认(< 1e12 视为 Unix 秒);纯日期按活动时区解释,validUntil 含当天整日。
77
109
  */
78
- export function timestampOf(value) {
110
+ export function timestampOf(value, timeZone, endOfDay = false) {
79
111
  if (typeof value === 'number' && Number.isFinite(value)) {
80
112
  return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
81
113
  }
82
114
  if (typeof value === 'string' && value.trim() !== '') {
83
115
  const text = value.trim();
84
116
  if (/^\d+(?:\.\d+)?$/.test(text)) return timestampOf(Number(text));
117
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
118
+ if (dateOnly) {
119
+ const year = Number(dateOnly[1]);
120
+ const month = Number(dateOnly[2]);
121
+ const day = Number(dateOnly[3]);
122
+ const check = new Date(Date.UTC(year, month - 1, day));
123
+ if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return undefined;
124
+ return zonedDateTimestamp(year, month, day + (endOfDay ? 1 : 0), timeZone);
125
+ }
85
126
  const parsed = Date.parse(text);
86
127
  return Number.isFinite(parsed) ? parsed : undefined;
87
128
  }
@@ -212,10 +253,12 @@ export function absoluteWindowText(promo, formatDate) {
212
253
  const fmt = formatDate ?? ((ms) => new Date(ms).toISOString().slice(0, 10));
213
254
  const from = schedule?.validFrom;
214
255
  const until = schedule?.validUntil;
256
+ // 纯日期的 validUntil 在内部存成次日 00:00(排他边界),展示仍用上游写的当天日期。
257
+ const untilText = schedule?.validUntilDate ?? (until === undefined ? undefined : fmt(until));
215
258
  if (from === undefined && until === undefined) return '';
216
- if (from !== undefined && until !== undefined) return `${fmt(from)} ~ ${fmt(until)}`;
259
+ if (from !== undefined && until !== undefined) return `${fmt(from)} ~ ${untilText}`;
217
260
  if (from !== undefined) return `${fmt(from)} 起`;
218
- return `至 ${fmt(until)}`;
261
+ return `至 ${untilText}`;
219
262
  }
220
263
 
221
264
  /** 折扣文案:`5 折(0.50x)`、`免费(0x)`。 */
@@ -249,14 +292,22 @@ function parseDiscount(raw) {
249
292
  export function parseSchedule(raw) {
250
293
  if (typeof raw !== 'object' || raw === null) return undefined;
251
294
  const timezone = typeof raw.timezone === 'string' && raw.timezone !== '' ? raw.timezone : undefined;
252
- const validFrom = timestampOf(raw.validFrom);
253
- const validUntil = timestampOf(raw.validUntil);
254
- const daily = Array.isArray(raw.daily)
255
- ? raw.daily
256
- .map((w) => ({ start: w?.start, end: w?.end }))
257
- .filter((w) => minutesOfClock(w.start) !== undefined && minutesOfClock(w.end) !== undefined)
295
+ const validFrom = timestampOf(raw.validFrom, timezone);
296
+ const validUntil = timestampOf(raw.validUntil, timezone, true);
297
+ const validUntilDate = typeof raw.validUntil === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.validUntil.trim())
298
+ ? raw.validUntil.trim()
258
299
  : undefined;
259
- const dailyUnusable = daily === undefined && Array.isArray(raw.daily) && raw.daily.length > 0;
300
+ const rawDaily = Array.isArray(raw.daily) ? raw.daily : undefined;
301
+ const daily =
302
+ rawDaily === undefined
303
+ ? undefined
304
+ : rawDaily
305
+ .map((w) => ({ start: w?.start, end: w?.end }))
306
+ .filter((w) => minutesOfClock(w.start) !== undefined && minutesOfClock(w.end) !== undefined);
307
+ // 上游确实给了时段、但一条都解析不出来时,绝不能当成「没有时段限制」全时段生效。
308
+ // 0.3.0 写成 `daily === undefined && ...`,而过滤后得到的是空数组 [],判定恒为 false,
309
+ // 于是把写错的时段当作全天打折(审查报告第 2 条)。
310
+ const dailyUnusable = rawDaily !== undefined && rawDaily.length > 0 && (daily === undefined || daily.length === 0);
260
311
  if (
261
312
  timezone === undefined &&
262
313
  validFrom === undefined &&
@@ -270,6 +321,7 @@ export function parseSchedule(raw) {
270
321
  ...(timezone === undefined ? {} : { timezone }),
271
322
  ...(validFrom === undefined || Number.isNaN(validFrom) ? {} : { validFrom }),
272
323
  ...(validUntil === undefined || Number.isNaN(validUntil) ? {} : { validUntil }),
324
+ ...(validUntilDate === undefined || validUntil === undefined ? {} : { validUntilDate }),
273
325
  ...(daily === undefined ? {} : { daily }),
274
326
  ...(dailyUnusable ? { dailyUnusable: true } : {}),
275
327
  };
package/src/upstream.js CHANGED
@@ -2,12 +2,12 @@
2
2
  * WorkBuddy 上游客户端:模型目录、积分余额、chat completions 流式、token 刷新。
3
3
  *
4
4
  * 协议参照 dsh-connect-workbuddy(MIT)与 workbuddy2api(MIT)的实测结论:
5
- * - 模型目录:GET /v2/enterprises/personal/models(/v2 形态两区网关都答;
5
+ * - 模型目录:GET /v2/enterprises/personal/models(WorkBuddy网关;
6
6
  * 每个模型条目带 id / name / maxInputTokens / maxOutputTokens /
7
7
  * credits(积分倍率字符串,如 "x0.79")/ supportsImages / reasoning 等)
8
8
  * - 对话: POST /v2/chat/completions,强制 stream:true,SSE 回包
9
- * - 刷新: POST /v2/auth/refresh,刷新令牌走 X-Refresh-Token 头,
10
- * chat 请求绝不携带 refresh token(安全红线)
9
+ * - 登录态: token 失效时重新从本机凭据发现(桌面端自己刷新落盘);本模块不做
10
+ * 自动刷新,chat 请求绝不携带 refresh token(安全红线)
11
11
  * - 归属头: X-IDE-Name / X-IDE-Type / X-Product = WorkBuddy,让用量页
12
12
  * 把这笔调用记成官方客户端发的
13
13
  *
@@ -17,11 +17,8 @@
17
17
 
18
18
  import { attachPromos, parsePromos } from './promo.js';
19
19
 
20
- /** 两个区域的官方上游域名。 */
21
- export const WB_REGION_BASE = {
22
- cn: 'https://copilot.tencent.com',
23
- global: 'https://www.workbuddy.ai',
24
- };
20
+ /** WorkBuddy 客户端的官方上游域名。本扩展只支持这一套上游。 */
21
+ export const WB_BASE = 'https://copilot.tencent.com';
25
22
 
26
23
  const MODELS_PATH = '/v2/enterprises/personal/models';
27
24
  /**
@@ -33,7 +30,6 @@ const MODELS_PATH = '/v2/enterprises/personal/models';
33
30
  */
34
31
  const CONFIG_PATH = '/v3/config';
35
32
  const CHAT_PATH = '/v2/chat/completions';
36
- const REFRESH_PATH = '/v2/auth/refresh';
37
33
  /**
38
34
  * 计费域积分读数(官方客户端「套餐与积分」页同族接口;失败时优雅降级)。
39
35
  *
@@ -44,11 +40,8 @@ const REFRESH_PATH = '/v2/auth/refresh';
44
40
  * 路径来自 dsh-connect-workbuddy 5.17.0 的 billingBase()/get-user-resource。
45
41
  */
46
42
  const CREDITS_PATH = '/v2/billing/meter/get-user-resource';
47
- /** 计费域(积分/签到/福利)根地址,按区域。 */
48
- const WB_BILLING_BASE = {
49
- cn: 'https://www.codebuddy.cn',
50
- global: 'https://www.workbuddy.ai',
51
- };
43
+ /** 计费域(积分/签到/福利)根地址。 */
44
+ const WB_BILLING_BASE = 'https://www.codebuddy.cn';
52
45
 
53
46
  const PRODUCT_UA = 'WorkBuddy/2.63.2';
54
47
  /**
@@ -93,15 +86,12 @@ function commonHeaders(cred, extra = {}) {
93
86
 
94
87
  export class WbUpstream {
95
88
  /**
96
- * @param {{ baseUrl: string, region?: 'cn' | 'global', log?: { warn(msg: string): void } }} opts
89
+ * @param {{ baseUrl: string, log?: { warn(msg: string): void } }} opts
97
90
  */
98
91
  constructor(opts) {
99
92
  this.baseUrl = (opts.baseUrl ?? '').replace(/\/+$/, '');
100
93
  /** 用户显式配置了 baseUrl(代理 / 镜像):计费请求也走它。 */
101
- this.customBase = Boolean(this.baseUrl) && !Object.values(WB_REGION_BASE).includes(this.baseUrl);
102
- // 计费域只看区域,不看 baseUrl:以前按域名子串猜,自定义 baseUrl(例如走代理/镜像)
103
- // 会悄悄落到国际计费域 www.workbuddy.ai,查出来的余额是另一个账号的。
104
- this.region = opts.region === 'global' ? 'global' : 'cn';
94
+ this.customBase = Boolean(this.baseUrl) && this.baseUrl !== WB_BASE;
105
95
  this.log = opts.log ?? { warn: () => {} };
106
96
  }
107
97
 
@@ -112,11 +102,11 @@ export class WbUpstream {
112
102
  /**
113
103
  * 计费域的完整地址(积分、签到、福利都在这边,和 chat 域不同)。
114
104
  *
115
- * 没配 `baseUrl` 时按区域查表;配了自定义 baseUrl(代理/镜像)就跟着走同一个出口,
105
+ * 没配 `baseUrl` 时用官方计费域;配了自定义 baseUrl(代理/镜像)就跟着走同一个出口,
116
106
  * 否则 chat 走代理、积分直连官方域名,代理用户的网络与隐私假设会被打破。
117
107
  */
118
108
  billingUrl(path) {
119
- return `${this.customBase ? this.baseUrl : WB_BILLING_BASE[this.region]}${path}`;
109
+ return `${this.customBase ? this.baseUrl : WB_BILLING_BASE}${path}`;
120
110
  }
121
111
 
122
112
  /**
@@ -132,6 +122,10 @@ export class WbUpstream {
132
122
  * @param {{ accessToken: string }} cred
133
123
  */
134
124
  async fetchCatalog(cred) {
125
+ // 每次请求重新判定:认证失败标记留在实例上会污染后续请求(同一个 WbUpstream
126
+ // 被复用时,上一次的 401 会让下一次成功请求也被判成登录态失效)。
127
+ this._authFailed = false;
128
+ this._authStatus = 0;
135
129
  try {
136
130
  const res = await fetch(this.url(CONFIG_PATH), {
137
131
  method: 'GET',
@@ -148,6 +142,7 @@ export class WbUpstream {
148
142
  if (!res.ok) {
149
143
  // 静默降级会让「为什么没有限时优惠」变成悬案,这里至少留一条 warn。
150
144
  this._authFailed = res.status === 401 || res.status === 403;
145
+ if (this._authFailed) this._authStatus = res.status;
151
146
  this.log?.warn?.(
152
147
  `产品配置 HTTP ${res.status}${this._authFailed ? '(认证失败,不再降级)' : ',退回旧目录路径(本次没有促销信息)'}。`,
153
148
  );
@@ -168,7 +163,13 @@ export class WbUpstream {
168
163
  // 认证类失败(401/403)再打一次旧接口没有意义:token 本身是死的,
169
164
  // 降级只会把真实错误(session_dead)换成 legacy 的二次失败,掩盖问题。
170
165
  if (this._authFailed) {
171
- throw new WbUpstreamError('WorkBuddy 登录态已失效(产品配置返回 401/403),请重新登录客户端。', 'session_dead');
166
+ // 构造函数第二参是 info 对象(status / kind)。0.3.0 写成 'session_dead'
167
+ // 字符串,读不到 kind,错误恒为 'server'、status=0,上层于是不清 token、
168
+ // 面板也不提示重新登录(审查报告里最严重的一条)。
169
+ throw new WbUpstreamError('WorkBuddy 登录态已失效(产品配置返回 401/403),请重新登录客户端。', {
170
+ status: this._authStatus,
171
+ kind: 'session_dead',
172
+ });
172
173
  }
173
174
  const models = await this.fetchModelsLegacy(cred);
174
175
  return { models, promos: [], source: 'legacy' };
@@ -198,7 +199,7 @@ export class WbUpstream {
198
199
  throw new WbUpstreamError(`模型目录请求失败 HTTP ${res.status}`, {
199
200
  status: res.status,
200
201
  code: body?.code,
201
- kind: res.status === 401 ? 'session_dead' : 'server',
202
+ kind: res.status === 401 || res.status === 403 ? 'session_dead' : 'server',
202
203
  });
203
204
  }
204
205
  if (body && typeof body.code === 'number' && body.code !== 0 && body.code !== 200) {
@@ -277,44 +278,6 @@ export class WbUpstream {
277
278
  return { total, used, packages, accounts };
278
279
  }
279
280
 
280
- /**
281
- * 刷新 access token。刷新令牌只出现在这一条请求里。
282
- * @param {{ refreshToken: string }} cred
283
- * @returns {Promise<{ accessToken: string, refreshToken?: string, expiresIn?: number }>}
284
- */
285
- async refresh(cred) {
286
- const res = await fetch(this.url(REFRESH_PATH), {
287
- method: 'POST',
288
- headers: {
289
- Accept: 'application/json',
290
- 'Content-Type': 'application/json',
291
- 'User-Agent': PRODUCT_UA,
292
- 'X-Requested-With': 'XMLHttpRequest',
293
- 'X-Refresh-Token': cred.refreshToken,
294
- 'X-Auth-Refresh-Source': 'workbuddy',
295
- },
296
- body: '{}',
297
- signal: AbortSignal.timeout(10_000),
298
- });
299
- const body = await res.json().catch(() => null);
300
- if (!res.ok || !body) {
301
- throw new WbUpstreamError(`token 刷新失败 HTTP ${res.status}`, {
302
- status: res.status,
303
- kind: 'session_dead',
304
- });
305
- }
306
- const data = body.data ?? body;
307
- const accessToken = data.accessToken ?? data.access_token;
308
- if (typeof accessToken !== 'string' || accessToken === '') {
309
- throw new WbUpstreamError('token 刷新响应里没有 accessToken', { kind: 'session_dead' });
310
- }
311
- return {
312
- accessToken,
313
- refreshToken: data.refreshToken ?? data.refresh_token,
314
- expiresIn: data.expiresIn ?? data.expires_in,
315
- };
316
- }
317
-
318
281
  /**
319
282
  * 发起一次对话(SSE 流式),把增量文本逐段交给 onDelta。
320
283
  * @param {{ accessToken: string }} cred
@@ -343,7 +306,7 @@ export class WbUpstream {
343
306
  }
344
307
  let text = '';
345
308
  let credit;
346
- let usage;
309
+ let usage = {};
347
310
  const decoder = new TextDecoder();
348
311
  let buf = '';
349
312
  /** 处理一行 SSE;末尾残留(没有换行)也走这里。 */
@@ -375,7 +338,7 @@ export class WbUpstream {
375
338
  // 最后一段 SSE 带完整用量(实测:prompt_tokens / completion_tokens / total_tokens /
376
339
  // 明细里的 cached_tokens、reasoning_tokens,以及本次真实扣费 credits 的 `credit`)。
377
340
  const u = evt.usage;
378
- usage = {
341
+ const next = {
379
342
  promptTokens: numberOrUndefined(u.prompt_tokens),
380
343
  completionTokens: numberOrUndefined(u.completion_tokens),
381
344
  totalTokens: numberOrUndefined(u.total_tokens),
@@ -386,6 +349,10 @@ export class WbUpstream {
386
349
  numberOrUndefined(u.completion_thinking_tokens),
387
350
  credit: numberOrUndefined(u.credit),
388
351
  };
352
+ // SSE 可能分多帧返回用量,末帧只带 credit;逐字段合并,不能用末帧覆盖前帧 token。
353
+ for (const [key, value] of Object.entries(next)) {
354
+ if (value !== undefined) usage[key] = value;
355
+ }
389
356
  if (typeof u.credit === 'number') credit = u.credit;
390
357
  }
391
358
  };
@@ -408,6 +375,7 @@ export class WbUpstream {
408
375
 
409
376
  /** `0` 是合法用量,只有 null / undefined / NaN 才算「没有」。 */
410
377
  function numberOrUndefined(v) {
378
+ if (v === null || v === undefined || v === '') return undefined;
411
379
  const n = Number(v);
412
380
  return Number.isFinite(n) ? n : undefined;
413
381
  }
package/src/usage.js CHANGED
@@ -73,17 +73,17 @@ export class UsageLedger {
73
73
  /**
74
74
  * 追加一条调用记录。
75
75
  * @param {{
76
- * ts?: number, model: string, region?: string, ok?: boolean, error?: string,
76
+ * ts?: number, model: string, ok?: boolean, error?: string,
77
77
  * promptTokens?: number, completionTokens?: number, totalTokens?: number,
78
78
  * cachedTokens?: number, reasoningTokens?: number, credit?: number | null,
79
- * multiplier?: number | null, promoId?: string, source?: string, durationMs?: number,
79
+ * multiplier?: number | null, baseMultiplier?: number | null,
80
+ * promoId?: string, source?: string, durationMs?: number,
80
81
  * }} entry
81
82
  */
82
83
  async append(entry) {
83
84
  const record = {
84
85
  ts: typeof entry.ts === 'number' ? entry.ts : Date.now(),
85
86
  model: String(entry.model ?? '—'),
86
- region: entry.region,
87
87
  ok: entry.ok !== false,
88
88
  ...(entry.error === undefined ? {} : { error: String(entry.error).slice(0, 300) }),
89
89
  ...num('promptTokens', entry.promptTokens),
@@ -95,6 +95,9 @@ export class UsageLedger {
95
95
  ...(typeof entry.multiplier === 'number' && Number.isFinite(entry.multiplier)
96
96
  ? { multiplier: entry.multiplier }
97
97
  : {}),
98
+ ...(typeof entry.baseMultiplier === 'number' && Number.isFinite(entry.baseMultiplier)
99
+ ? { baseMultiplier: entry.baseMultiplier }
100
+ : {}),
98
101
  ...(entry.promoId === undefined ? {} : { promoId: String(entry.promoId) }),
99
102
  ...(entry.source === undefined ? {} : { source: String(entry.source) }),
100
103
  ...num('durationMs', entry.durationMs),
@@ -182,13 +185,18 @@ export class UsageLedger {
182
185
  }
183
186
  }
184
187
 
185
- /** 写入余额快照(先写临时文件再改名,避免半截 JSON)。 */
188
+ /** 写入余额快照(走与账本同一条写队列,先写临时文件再改名,避免并发互相覆盖)。 */
186
189
  async writeBalance(snapshot) {
190
+ const text = JSON.stringify(snapshot, null, 2);
187
191
  try {
188
- await mkdir(dirname(this.balancePath), { recursive: true });
189
- const tmp = join(this.dir, `${BALANCE_FILE}.tmp`);
190
- await writeFile(tmp, JSON.stringify(snapshot, null, 2), 'utf8');
191
- await rename(tmp, this.balancePath);
192
+ await this._withLock(async () => {
193
+ await mkdir(dirname(this.balancePath), { recursive: true });
194
+ // 0.3.0 用固定名 BALANCE_FILE.tmp 且不排队:两个并发写会抢同一个临时文件,
195
+ // 互相覆盖甚至 rename 到一个已被删掉的路径。队列串行 + 带 pid 的临时名。
196
+ const tmp = join(this.dir, `${BALANCE_FILE}.${process.pid}.tmp`);
197
+ await writeFile(tmp, text, 'utf8');
198
+ await rename(tmp, this.balancePath);
199
+ });
192
200
  return true;
193
201
  } catch (err) {
194
202
  this.log.warn?.(`写余额快照失败:${err?.message ?? err}`);
@@ -202,10 +210,8 @@ export class UsageLedger {
202
210
  *
203
211
  * @param {Array<{ts:number, model:string, ok?:boolean, credit?:number, promptTokens?:number, completionTokens?:number, totalTokens?:number, cachedTokens?:number, reasoningTokens?:number}>} records
204
212
  * @param {number} nowMs
205
- * @param {{ days?: number }} [opts]
206
213
  */
207
- export function summarize(records, nowMs, opts = {}) {
208
- const windowDays = opts.days ?? 30;
214
+ export function summarize(records, nowMs) {
209
215
  const startOfToday = startOfDay(nowMs);
210
216
  const windows = {
211
217
  today: startOfToday,
@@ -217,13 +223,13 @@ export function summarize(records, nowMs, opts = {}) {
217
223
  const totals = {};
218
224
  for (const [key, from] of Object.entries(windows)) totals[key] = emptyTotals();
219
225
 
220
- /** 按模型聚合(近 N 天)。 */
226
+ /** 按模型聚合(近 30 天,与概览的 month 行同口径)。 */
221
227
  const byModel = new Map();
222
- /** 按天聚合(近 N 天,用于迷你趋势)。 */
228
+ /** 按天聚合(近 30 天,用于迷你趋势)。 */
223
229
  const byDay = new Map();
224
230
 
225
231
  for (const rec of records) {
226
- const credit = numberOr(rec.credit, 0);
232
+ const credit = typeof rec.credit === 'number' && Number.isFinite(rec.credit) ? rec.credit : undefined;
227
233
  const prompt = numberOr(rec.promptTokens, 0);
228
234
  const completion = numberOr(rec.completionTokens, 0);
229
235
  const total = numberOr(rec.totalTokens, prompt + completion);
@@ -256,34 +262,34 @@ export function summarize(records, nowMs, opts = {}) {
256
262
  ts: rec.ts,
257
263
  model: rec.model,
258
264
  ok: rec.ok !== false,
259
- credit: numberOr(rec.credit, 0),
265
+ credit: typeof rec.credit === 'number' && Number.isFinite(rec.credit) ? rec.credit : null,
260
266
  total: numberOr(rec.totalTokens, numberOr(rec.promptTokens, 0) + numberOr(rec.completionTokens, 0)),
261
267
  prompt: numberOr(rec.promptTokens, 0),
262
268
  completion: numberOr(rec.completionTokens, 0),
269
+ cached: numberOr(rec.cachedTokens, 0),
263
270
  multiplier: rec.multiplier,
271
+ baseMultiplier: rec.baseMultiplier,
264
272
  durationMs: rec.durationMs,
265
273
  }));
266
274
 
267
- const spanDays = Math.max(1, Math.ceil((nowMs - startOfToday) / 864e5) + 1);
268
275
  return {
269
- windowDays,
270
- spanDays,
271
276
  totals,
272
277
  byModel: modelRows.slice(0, 10),
273
- byDay: dayRows.slice(-Math.max(7, Math.min(30, windowDays))),
278
+ byDay: dayRows.slice(-30),
274
279
  recent,
275
280
  recordCount: records.length,
276
281
  };
277
282
  }
278
283
 
279
284
  function emptyTotals() {
280
- return { calls: 0, failed: 0, credit: 0, prompt: 0, completion: 0, total: 0, cached: 0, reasoning: 0 };
285
+ return { calls: 0, failed: 0, credit: 0, creditUnknown: 0, prompt: 0, completion: 0, total: 0, cached: 0, reasoning: 0 };
281
286
  }
282
287
 
283
288
  function accumulate(target, v) {
284
289
  target.calls += 1;
285
290
  if (!v.ok) target.failed += 1;
286
- target.credit += v.credit;
291
+ if (typeof v.credit === 'number') target.credit += v.credit;
292
+ else target.creditUnknown += 1;
287
293
  target.prompt += v.prompt;
288
294
  target.completion += v.completion;
289
295
  target.total += v.total;
package/src/wbproc.js CHANGED
@@ -129,10 +129,3 @@ export async function detectWorkBuddy(force = false) {
129
129
  if (state.via !== 'error') cache = { at: now, value };
130
130
  return value;
131
131
  }
132
-
133
- /** 给面板/提示词用的一行摘要。 */
134
- export function describeProc(state) {
135
- if (!state) return 'unknown';
136
- if (state.running) return `running (pid ${state.mainPid ?? '?'})`;
137
- return 'stopped';
138
- }