dsh-bottom-info-bar 1.20.2 → 1.20.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/lib/client.js CHANGED
@@ -301,6 +301,10 @@ const FIELD_REGISTRY = [
301
301
  { id: 'unmapped', label: "field.unmapped.label", group: 'notice', colorKind: 'muted', note: "field.unmapped.note" },
302
302
  ];
303
303
  const PRESET_COLORS = ['red', 'green', 'blue', 'purple', 'orange', 'neutral'];
304
+ // v1.6 T7 / v1.7 FR-14:订阅/账单 provider 集合(构建注入,永不变)。以前在 BottomInfoBar 里每次渲染重建
305
+ // 字面量——值从不变,多建的只是垃圾。提到模块级,var 拼法保留(一致性测试按该字面锚点提取)。
306
+ var SUBSCRIPTION_PROVIDERS = ['codex', 'chatgpt', 'opencode-go', 'opencode', 'openai-codex', 'zai', 'zai-coding-cn', 'xiaomi-token-plan-cn', 'xiaomi-token-plan-sgp', 'xiaomi-token-plan-ams', 'command', 'command-code', 'minimax', 'minimax-cn'];
307
+ var BILLING_PROVIDERS = ['together', 'fireworks', 'amazon-bedrock', 'cloudflare-ai-gateway', 'cloudflare-workers-ai'];
304
308
  const PRESET_COLOR_SET = new Set(PRESET_COLORS);
305
309
 
306
310
  // v1.16:订阅窗口百分比方向(quotaDisplayMode)。'remaining' = 历史行为(默认),'used' = 显示已用。
@@ -508,11 +512,19 @@ function buildFieldColorCss() {
508
512
  return rules.join('\n');
509
513
  }
510
514
  const FIELD_COLOR_CSS = buildFieldColorCss();
511
- // 订阅窗口 key → 字段 id:三个窗口是三个独立字段,各自开关、各自着色,两种模式一视同仁
512
- const WINDOW_FIELD_IDS = { five_hour: 'subWindow5h', seven_day: 'subWindowWeek', monthly: 'subWindowMonth' };
515
+ // 订阅窗口元表(一个概念只允许一张表):key → 字段 id / 排序优先级 / 完整标签字典键 / 紧凑缩写。
516
+ // 三个窗口是三个独立字段,各自开关、各自着色,两种模式一视同仁;未知 key 走兜底(字段回 subWindow5h、标签回传进来的 label)。
517
+ const WINDOW_META = {
518
+ five_hour: { field: 'subWindow5h', priority: 1, labelKey: 'host.hour', short: '5h' },
519
+ seven_day: { field: 'subWindowWeek', priority: 2, labelKey: 'ui.weekly', short: null },
520
+ monthly: { field: 'subWindowMonth', priority: 3, labelKey: 'ui.monthly', short: null },
521
+ };
522
+ function windowMeta(key) {
523
+ return Object.hasOwn(WINDOW_META, key) ? WINDOW_META[key] : null;
524
+ }
513
525
  function windowFieldVisible(key) {
514
- const id = WINDOW_FIELD_IDS[key];
515
- return id ? fieldVisible(id) : true;
526
+ const meta = windowMeta(key);
527
+ return meta ? fieldVisible(meta.field) : true;
516
528
  }
517
529
 
518
530
  // 降级节点现在包着 data-field 容器(fieldSpan 着色),文案要穿透一层包装再读
@@ -580,6 +592,8 @@ function installStyles() {
580
592
  style.dataset.plugin = 'dsh-bottom-info-bar';
581
593
  style.dataset.pluginCss = id;
582
594
  style.textContent = `
595
+ /* 样式源约定(P1-20 收尾):信息栏只用 --bi-*,设置页只用 --bib-*,色板/状态色共用同一套。
596
+ 两边零交叉引用是测出来的(见测试),不是约出来的——新规则不许串台,串了测试会红。 */
583
597
  /* 垂直节奏只有 --bi-line 一个来源:两行永远相邻,行间间距恒为 0(无 gap、无 margin)。
584
598
  宽度取宿主的内容宽度令牌(确定值),而不是让 fit-content 的 dock 按内容反推宽度——
585
599
  否则「文字变长 → 根节点变宽 → 另一行换行时机改变」会让版式随内容抖动。
@@ -596,7 +610,8 @@ function installStyles() {
596
610
  可用宽度白白少 64px(2026-09-25 用户报「能一行显示却换行」的直接原因)。
597
611
  · 字号取 --dsh-content-font-size-secondary(宿主次级字号),行高 = 基础 20px + 宿主的字号增量。
598
612
  不得写死 px:宿主提供字号设置,写死会让信息栏在用户调大字号后仍停在旧尺寸。 */
599
- .bi-root { --bi-label-primary: var(--dsw-alias-label-primary, #333); --bi-label-supporting: #3f444a; --bi-separator: var(--dsw-alias-label-tertiary, rgba(128,128,128,0.5)); --bi-state-price-low: #087f5b; --bi-state-alert: #d92d20; --bi-line: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); --bi-extra-h: var(--bi-line); text-align: center; box-sizing: border-box; width: var(--dsh-chat-content-width, 100%); max-width: 100%; padding: 4px 0px 0px; margin: 0 auto; display: flex; flex-direction: column; justify-content: flex-end; align-content: end; align-items: stretch; gap: 0; align-self: center; height: auto; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: var(--bi-line); color: var(--bi-label-supporting); font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
613
+ /* 视觉胶囊配色是固定值而非色板变量:色板蓝在深色主题会翻成 #66a3ff,而胶囊的白字对比度是按 #0057ff 实测的,跟随翻转会跌破 4.5:1。 */
614
+ .bi-root { --bi-vision-border: #0044cc; --bi-vision-bg: #0057ff; --bi-label-primary: var(--dsw-alias-label-primary, #333); --bi-label-supporting: #3f444a; --bi-separator: var(--dsw-alias-label-tertiary, rgba(128,128,128,0.5)); --bi-state-price-low: #087f5b; --bi-state-alert: #d92d20; --bi-line: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); --bi-extra-h: var(--bi-line); text-align: center; box-sizing: border-box; width: var(--dsh-chat-content-width, 100%); max-width: 100%; padding: 4px 0px 0px; margin: 0 auto; display: flex; flex-direction: column; justify-content: flex-end; align-content: end; align-items: stretch; gap: 0; align-self: center; height: auto; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: var(--bi-line); color: var(--bi-label-supporting); font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
600
615
  .bi-root[data-density-saving="true"] { cursor: progress; }
601
616
  /* 以 DSH 实际外观属性切换,避免用户在 DSH 内手动选择外观时与系统偏好失配。 */
602
617
  body[data-ds-dark-theme] .bi-root { --bi-label-supporting: var(--dsw-alias-label-secondary, #cfd3d6); --bi-state-price-low: #86efac; --bi-state-alert: #ff6961; }
@@ -654,7 +669,7 @@ function installStyles() {
654
669
  .bi-model-detail { display: inline-flex; align-items: center; min-width: 0; max-width: 100%; }
655
670
  .bi-model-dot { margin: 0 4px; flex: 0 0 auto; }
656
671
  .bi-model-name { min-width: 0; overflow-wrap: anywhere; }
657
- .bi-vision { display: inline-flex; align-items: center; box-sizing: border-box; min-width: 0; max-width: 100%; height: 16px; margin: 0; padding: 0 6px; border: 1px solid #0044cc; border-radius: 999px; color: #fff; background: #0057ff; font-size: 12px; font-weight: 600; line-height: 14px; white-space: nowrap; }
672
+ .bi-vision { display: inline-flex; align-items: center; box-sizing: border-box; min-width: 0; max-width: 100%; height: 16px; margin: 0; padding: 0 6px; border: 1px solid var(--bi-vision-border); border-radius: 999px; color: #fff; background: var(--bi-vision-bg); font-size: 12px; font-weight: 600; line-height: 14px; white-space: nowrap; }
658
673
  .bi-vision-model { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
659
674
  .bi-vision-kind { flex: 0 0 auto; margin-left: 4px; }
660
675
  /* 会话目录未给出能力时,保留模型名称;只等待能力标识,不猜测为文本模型。 */
@@ -674,7 +689,8 @@ function installStyles() {
674
689
  .bi-ctx-ring { flex: none; display: block; }
675
690
  .bi-ctx-track { fill: none; stroke: var(--dsw-alias-border-l3, rgba(128, 128, 128, 0.35)); stroke-width: 2px; }
676
691
  .bi-ctx-fill { fill: none; stroke: currentColor; stroke-width: 2px; stroke-linecap: round; }
677
- /* 构成明细面板:与原生 ContextMeter 面板同构(264px 宽 / 12px 内边距 / 12px 圆角 / 4px 构成条)。 */
692
+ /* 构成明细面板:与原生 ContextMeter 面板同构(264px 宽 / 12px 内边距 / 12px 圆角 / 4px 构成条)。
693
+ 宽度另见 CONTEXT_PANEL_WIDTH(JS 定位算法用同一数值,两处改必须一起改)。 */
678
694
  /* 面板底色必须不透明(2026-09-24 用户报「面板透明、文字和底下的信息栏叠在一起看不清」):
679
695
  宿主 --dsw-specific-menu 是带 alpha 的色(浅 #f8f9fa94 / 深 #30313680),且宿主菜单另有毛玻璃层,
680
696
  本面板悬在信息栏文字之上,直接用它必然透字。改用不透明的层级底色(浅 #fff / 深 #353638,随主题走),
@@ -721,6 +737,7 @@ const CONTEXT_READING_SLOT = '\u0000';
721
737
  // 面板位置参数与原生 useAnchoredPosition(side:'top', gap:8, margin:12) 一致。
722
738
  const CONTEXT_PANEL_GAP = 8;
723
739
  const CONTEXT_PANEL_MARGIN = 12;
740
+ // 与 CSS 中 .bi-ctx-panel 的 264px 是同一数值(定位退避算法用,两处改必须一起改)。
724
741
  const CONTEXT_PANEL_WIDTH = 264;
725
742
  // 构成明细三行(顺序即色块顺序;色值与原生 ContextMeter 完全相同)。
726
743
  const CONTEXT_PANEL_ROWS = [
@@ -1766,11 +1783,12 @@ function bibSetDisclosure(props) {
1766
1783
  React.createElement('span', { className: 'bib-set-group-action', 'aria-hidden': 'true' },
1767
1784
  open ? t('ui.collapse') : t('ui.expand'),
1768
1785
  bibSetChevron({ expanded: open }))),
1786
+ // 收合态隐藏三件套:grid 0fr(无高度)+ visibility(不可见且不可聚焦)+ aria-hidden(读屏隐藏)。
1787
+ // 不再挂 inert:visibility:hidden 的内容本来就进不了 Tab 序,React 18 又不认识这个属性。
1769
1788
  React.createElement('div', {
1770
1789
  id: props.contentId,
1771
1790
  className: 'bib-set-collapse' + (open ? ' bib-set-collapse--expanded' : ' bib-set-collapse--collapsed'),
1772
1791
  'aria-hidden': open ? undefined : 'true',
1773
- inert: open ? undefined : true,
1774
1792
  }, React.createElement('div', { className: 'bib-set-collapse-inner' }, props.children)));
1775
1793
  }
1776
1794
 
@@ -2963,6 +2981,9 @@ module.exports = {
2963
2981
 
2964
2982
  // 字段配置订阅——插件页保存(CustomEvent)或周期校准更新配置时重渲染。
2965
2983
  // 配置本体存模块级单例,组件只记版本号;订阅建立时立即回读避免首帧用过期配置。
2984
+ // 已见配置版本:订阅回调把最新版本号写进来,值变化才重渲染 + 重跑下面的轮询 effect。
2985
+ // 相同版本号 set 进来时 React 按值比对直接 bail out;这个值只在下面轮询 effect 的
2986
+ // 依赖数组里消费,不需要额外的 void 压住未使用检查。
2966
2987
  const [fieldConfigTick, setFieldConfigTick] = React.useState(fieldConfigVersion);
2967
2988
  React.useEffect(function () {
2968
2989
  const listener = function () { setFieldConfigTick(fieldConfigVersion); };
@@ -2970,7 +2991,6 @@ module.exports = {
2970
2991
  setFieldConfigTick(fieldConfigVersion);
2971
2992
  return function () { fieldConfigListeners.delete(listener); };
2972
2993
  }, []);
2973
- void fieldConfigTick;
2974
2994
 
2975
2995
  // 当前会话 ID 多路获取:slotProps 标准 kit → session 快照 → 运行时 sessions 服务
2976
2996
  // (DSH 各版本注入方式不同,任一路可用即拿到真实会话 ID,避免回退到上一会话的账)
@@ -3199,20 +3219,13 @@ module.exports = {
3199
3219
  || state.pricing.provider !== activeSessionModel.provider || state.pricing.model !== activeSessionModel.model)
3200
3220
  ? { provider: activeSessionModel.provider, model: activeSessionModel.model, providerDisplay: activeSessionModel.providerDisplay, modelDisplay: activeSessionModel.modelDisplay, mode: 'unknown', acceptsImageInput: activeSessionModel.acceptsImageInput }
3201
3221
  : (waitForSessionModel ? null : state.pricing);
3202
- // v1.6 T7:订阅 provider 集合提取为共享常量,消除两端硬编码漂移
3203
- var SUBSCRIPTION_PROVIDERS = ['codex', 'chatgpt', 'opencode-go', 'opencode', 'openai-codex', 'zai', 'zai-coding-cn', 'xiaomi-token-plan-cn', 'xiaomi-token-plan-sgp', 'xiaomi-token-plan-ams', 'command', 'command-code', 'minimax', 'minimax-cn'];
3204
- // v1.7 FR-14:云账单 provider 集合(账单型显示,与余额/额度互斥)
3205
- var BILLING_PROVIDERS = ['together', 'fireworks', 'amazon-bedrock', 'cloudflare-ai-gateway', 'cloudflare-workers-ai'];
3206
3222
  const visibleBillingMode = activeSessionModel && (!state.billingMode
3207
3223
  || state.billingMode.provider !== activeSessionModel.provider || state.billingMode.model !== activeSessionModel.model)
3208
3224
  ? { provider: activeSessionModel.provider, model: activeSessionModel.model, mode: BILLING_PROVIDERS.indexOf(activeSessionModel.provider) >= 0 ? 'billing' : (SUBSCRIPTION_PROVIDERS.indexOf(activeSessionModel.provider) >= 0 ? 'subscription' : 'balance') }
3209
3225
  : (waitForSessionModel ? null : state.billingMode);
3210
- // ---- 与原生一致格式工具 ----
3226
+ // ---- 与原生一致格式工具(走模块级 contextTokenText,K/M 经字典,可随宿主语言切换) ----
3211
3227
  function formatTokens(n) {
3212
- const scaled = function (v) { return v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10); };
3213
- if (n < 1e3) return String(n);
3214
- if (n < 1e6) return scaled(n / 1e3) + 'K';
3215
- return scaled(n / 1e6) + 'M';
3228
+ return contextTokenText(n);
3216
3229
  }
3217
3230
  function formatDuration(ms) {
3218
3231
  const s = ms / 1e3;
@@ -3263,13 +3276,13 @@ module.exports = {
3263
3276
  }
3264
3277
  // 订阅窗口紧凑行标签(5小时 → '5h',周 → '周',月 → '月');hover 明细仍用完整标签
3265
3278
  function quotaWindowLabel(window) {
3266
- const keys = { five_hour: 'host.hour', seven_day: 'ui.weekly', monthly: 'ui.monthly' };
3267
- return Object.hasOwn(keys, window.key) ? t(keys[window.key]) : window.label;
3279
+ const meta = windowMeta(window.key);
3280
+ return meta ? t(meta.labelKey) : window.label;
3268
3281
  }
3269
3282
  function compactWindowLabel(key) {
3270
- if (key === 'five_hour') return '5h';
3271
- if (key === 'seven_day') return t('ui.weekly');
3272
- if (key === 'monthly') return t('ui.monthly');
3283
+ const meta = windowMeta(key);
3284
+ if (meta && meta.short) return meta.short;
3285
+ if (meta) return t(meta.labelKey);
3273
3286
  return t('ui.window');
3274
3287
  }
3275
3288
  // v1.16 订阅窗口百分比方向:remaining(默认)= 显示剩余;used = 显示已用(= 100 - 剩余,钳制 [0,100])。
@@ -3651,11 +3664,10 @@ module.exports = {
3651
3664
  // 重置倒计时挂在哪个窗口上?取"时间最短且有重置时刻"的那个(刷新最快,用户最需关注):
3652
3665
  // 优先级:5小时 > 周 > 月(按窗口时长排序,而非已用百分比)。
3653
3666
  // 窗口组本身按各自的开关全部列出(见下),倒计时只可能挂一个窗口,故单独挑一个。
3654
- const windowPriority = { five_hour: 1, seven_day: 2, monthly: 3 };
3655
3667
  const byPriority = function (a, b) {
3656
- const pa = Object.hasOwn(windowPriority, a.key) ? windowPriority[a.key] : 99;
3657
- const pb = Object.hasOwn(windowPriority, b.key) ? windowPriority[b.key] : 99;
3658
- return pa - pb;
3668
+ const ma = windowMeta(a.key);
3669
+ const mb = windowMeta(b.key);
3670
+ return (ma ? ma.priority : 99) - (mb ? mb.priority : 99);
3659
3671
  };
3660
3672
  const windowsWithReset = windows.filter(function (w) { return w.resetsAt; });
3661
3673
  // 有重置时刻的窗口优先(倒计时才有意义);全都没有时退回按时长取最短窗口 ——
@@ -3685,7 +3697,7 @@ module.exports = {
3685
3697
  const remaining = remainingPercent(w);
3686
3698
  const numberClass = remaining <= LOW_QUOTA_PERCENT ? 'bi-quota-low' : '';
3687
3699
  // 每个窗口独立 data-field(subWindow5h/Week/Month),色变量按字段注入;「低」字标签保留
3688
- winNodes.push(fieldSpan(WINDOW_FIELD_IDS[w.key] || 'subWindow5h', 'w' + i,
3700
+ winNodes.push(fieldSpan((windowMeta(w.key) || {}).field || 'subWindow5h', 'w' + i,
3689
3701
  metric(compactWindowLabel(w.key), quotaWindowPercent(w, windowMode) + '%', numberClass)));
3690
3702
  if (remaining <= LOW_QUOTA_PERCENT) winNodes.push(React.createElement('span', { key: 'low' + i, className: 'bi-low-status' }, t('ui.low')));
3691
3703
  }
package/lib/constants.js CHANGED
@@ -6,6 +6,45 @@ export const SUBSCRIPTION_PROVIDERS = ['codex', 'chatgpt', 'opencode-go', 'openc
6
6
  // 云账单 provider 集合:这些 provider 走"账单型"显示(本月真实花费 / 预算%),与余额型/额度型互斥(FR-14)
7
7
  export const BILLING_PROVIDERS = ['together', 'fireworks', 'amazon-bedrock', 'cloudflare-ai-gateway', 'cloudflare-workers-ai']
8
8
 
9
+ // ---------- provider 身份总表(新增服务商只改这里 + 上面两个集合) ----------
10
+ // 一个 DSH provider id 一行:account 花费记在哪个账户(分账轴);subscription / billing
11
+ // 快照源键(无→null)。以前这是三条 if-else 链(accountForProvider / subscriptionSourceFor /
12
+ // billingSourceFor),加一个服务商要改三处,必然漏改;现在三函数只查这张表。
13
+ // 上面两个集合保留字面量(构建直接正则提取注入客户端,派生写法提不出来);
14
+ // tests/test-subscription-providers-consistency.cjs 锁死「表 ↔ 集合」双向一致,漂移即红。
15
+ // 不在这张表里的:余额来源例外(PLATFORM_BALANCE_PROVIDERS,另一条轴)、上报币种
16
+ // (PROVIDER_REPORTED_CURRENCY,不同语义)、显示名(PROVIDER_DISPLAY,目录驱动+翻译)。
17
+ export const PROVIDER_IDENTITY = {
18
+ 'deepseek': { account: 'deepseek', subscription: null, billing: null },
19
+ 'deepseek-official': { account: 'deepseek', subscription: null, billing: null },
20
+ 'openai': { account: 'openai', subscription: null, billing: null },
21
+ 'moonshotai': { account: 'moonshotai', subscription: null, billing: null },
22
+ 'moonshotai-cn': { account: 'moonshotai', subscription: null, billing: null },
23
+ 'kimi-coding': { account: 'moonshotai', subscription: null, billing: null },
24
+ 'openrouter': { account: 'openrouter', subscription: null, billing: null },
25
+ 'stepfun': { account: 'stepfun', subscription: null, billing: null },
26
+ 'codex': { account: 'codex', subscription: 'codex', billing: null },
27
+ 'chatgpt': { account: 'codex', subscription: 'codex', billing: null },
28
+ 'openai-codex': { account: 'codex', subscription: 'codex', billing: null },
29
+ 'opencode-go': { account: 'opencode-go', subscription: 'opencode-go', billing: null },
30
+ 'opencode': { account: 'opencode-go', subscription: 'opencode-go', billing: null },
31
+ 'zai': { account: 'zai', subscription: 'zai', billing: null },
32
+ 'zai-coding-cn': { account: 'zai', subscription: 'zai', billing: null },
33
+ 'xiaomi': { account: 'xiaomi', subscription: null, billing: null },
34
+ 'xiaomi-token-plan-cn': { account: 'xiaomi-token-plan', subscription: 'xiaomi-cn', billing: null },
35
+ 'xiaomi-token-plan-sgp': { account: 'xiaomi-token-plan', subscription: 'xiaomi-sgp', billing: null },
36
+ 'xiaomi-token-plan-ams': { account: 'xiaomi-token-plan', subscription: 'xiaomi-ams', billing: null },
37
+ 'command': { account: 'command-code', subscription: 'command-code', billing: null },
38
+ 'command-code': { account: 'command-code', subscription: 'command-code', billing: null },
39
+ 'minimax': { account: 'minimax', subscription: 'minimax', billing: null },
40
+ 'minimax-cn': { account: 'minimax', subscription: 'minimax-cn', billing: null },
41
+ 'together': { account: 'together', subscription: null, billing: 'together' },
42
+ 'fireworks': { account: 'fireworks', subscription: null, billing: 'fireworks' },
43
+ 'amazon-bedrock': { account: 'amazon-bedrock', subscription: null, billing: 'amazon-bedrock' },
44
+ 'cloudflare-ai-gateway': { account: 'cloudflare', subscription: null, billing: 'cloudflare' },
45
+ 'cloudflare-workers-ai': { account: 'cloudflare', subscription: null, billing: 'cloudflare' },
46
+ }
47
+
9
48
  // ============================================================================================
10
49
  // 显示模型(唯一一套逻辑,2026-09-26 用户拍板定型 —— 动任何显示相关的代码前先读完这一段)
11
50
  // ============================================================================================
@@ -132,6 +171,22 @@ export const FIELD_SECTIONS = [
132
171
  { id: 'common', label: "section.common.label", desc: "section.common.desc" },
133
172
  ]
134
173
 
174
+ // ---------- 字段在信息栏的归属(位置 + 门控的机器可读版) ----------
175
+ // 每个模式列出它可能渲染的字段 id(按渲染顺序);公共尾部(提醒 + 圆环)三种模式共用。
176
+ // 注意这张表只管「谁可能出现在哪一行」,不管显隐——显隐永远只由字段开关决定,
177
+ // 数据条件(provider 有没有这项数据)由各字段自己的构建分支判定。两者都满足才出现。
178
+ // tests/test-display-model.cjs 双向锁死:表里的每个 id 必须在对应渲染分支有门控,
179
+ // 渲染分支里的每个门控 id 必须在这张表里;渲染出的实际顺序由渲染级断言覆盖。
180
+ // 订阅窗口三字段走 WINDOW_META 间接门控(windowFieldVisible),锚点三字段走 anchorId 参数门控,
181
+ // 见表下注释与测试里的机制说明——三者都是 fieldVisible,只是写法不同。
182
+ export const FIELD_MODES = {
183
+ native: ['turnsSteps', 'llmTime', 'toolTime', 'cacheHit', 'tokensIO'],
184
+ balance: ['customText', 'anchorGroup', 'mainTime', 'worldTime', 'unmapped', 'noKeyHint', 'balance', 'balanceError', 'period', 'countdown', 'sessionCost', 'usageError'],
185
+ subscription: ['customText', 'subServiceGroup', 'mainTime', 'worldTime', 'expiry', 'subWindow5h', 'subWindowWeek', 'subWindowMonth', 'resetCountdown', 'subBalance', 'sessionCost'],
186
+ billing: ['customText', 'billingServiceGroup', 'mainTime', 'worldTime', 'billingSpend', 'budget', 'freeQuota'],
187
+ trailing: ['refreshFailure', 'persistWarning', 'updateNotice', 'updateFailure', 'contextUsage'],
188
+ }
189
+
135
190
  // ---------- v1.9.0 PR2:预设色板(语义色名) ----------
136
191
  // 客户端按「浅色默认 → 深色覆盖 → 增强对比」三套配对定义 --bi-palette-<name>;
137
192
  // 宿主只校验名字白名单;'default'(恢复默认)不进白名单——它等价于 null,不入库。
package/lib/index.js CHANGED
@@ -11,15 +11,17 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
11
11
  import { createHash, createHmac, randomUUID } from 'node:crypto'
12
12
  import { homedir } from 'node:os'
13
13
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
14
- import { fileURLToPath } from 'node:url'
14
+
15
15
  // v1.9.0 PR2:字段注册表/预设色名单一来源(ESM 直接 import,构建时把 constants.js 一并复制进 lib/)
16
- import { FIELD_REGISTRY, PRESET_COLOR_NAMES } from './constants.js'
16
+ import { FIELD_REGISTRY, PRESET_COLOR_NAMES, PROVIDER_IDENTITY } from './constants.js'
17
+ // 版本身份与语义化比较的唯一来源(包名/registry 地址/包版本/semver 比较,见 src/version.js)
18
+ import { PACKAGE_DIR, PACKAGE_FILE, UPDATE_REGISTRY_URL, compareSemver, packageVersion } from './version.js'
17
19
  // 自更新引擎(宿主没有「更新」动作,插件自己更新自己;见 docs/DECISIONS-AUTO-UPDATE.md)
18
20
  import { createSelfUpdater } from './self-update.js'
19
21
  import * as hostLocale from './host-locale.js'
20
22
  const t = hostLocale.createHostTranslator()
21
23
 
22
- const DATA_DIR = process.env.DSH_BOTTOM_INFO_BAR_DATA_DIR || join(homedir(), '.dsh', 'dsh-bottom-info-bar')
24
+ const DATA_DIR = process.env.DSH_BOTTOM_INFO_BAR_DATA_DIR || join(dshHomeDir(), 'dsh-bottom-info-bar')
23
25
  const DATA_FILE = join(DATA_DIR, 'usage-records.json')
24
26
  const DATA_BACKUP_FILE = DATA_FILE + '.bak'
25
27
  const DATA_TEMP_FILE = DATA_FILE + '.tmp'
@@ -52,10 +54,7 @@ const MS_PER_DAY = 86400 * 1000
52
54
  const BEIJING_OFFSET_MS = 8 * 3600 * 1000
53
55
  // recordAccount 为 null 的“无主记录”在桶/会话索引里的键(accountForProvider 永不返回空串,无碰撞)
54
56
  const NULL_ACCOUNT_KEY = ''
55
- const PACKAGE_FILE = new URL('../package.json', import.meta.url)
56
- // 包根目录:自更新的目标边界(写文件一律经 resolveSafePath 限定在它之内)
57
- const PACKAGE_DIR = dirname(fileURLToPath(PACKAGE_FILE))
58
- const UPDATE_REGISTRY_URL = 'https://registry.npmjs.org/dsh-bottom-info-bar/latest'
57
+ // PACKAGE_FILE/DIR:自更新的目标边界(写文件一律经 resolveSafePath 限定在它之内),定义见 version.js
59
58
  const UPDATE_CHECK_TIMEOUT_MS = 5000
60
59
  // 所有对外 HTTP 请求的统一超时。集中一处,避免每个调用点各自记一个数。
61
60
  const HTTP_TIMEOUT_MS = 15000
@@ -71,19 +70,8 @@ function timeoutSignal(ms) {
71
70
  : undefined
72
71
  }
73
72
 
74
- function packageVersion() {
75
- try {
76
- const pkg = JSON.parse(readFileSync(PACKAGE_FILE, 'utf8'))
77
- return typeof pkg.version === 'string' ? pkg.version : '0.0.0'
78
- } catch {
79
- return '0.0.0'
80
- }
81
- }
82
-
83
- function stableVersion(value) {
84
- const match = typeof value === 'string' && value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/)
85
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
86
- }
73
+ // stableVersion / compareVersions 的旧名已并入 version.js(parseSemver / compareSemver),
74
+ // tests/test-update-check.cjs 改为直接从 version.js 取行为,见该文件头部说明。
87
75
 
88
76
  // 自更新只在「本包确实被当作 profile 插件装载」时启用:
89
77
  // · 真实安装 → <DSH_HOME>/profiles/<profile>/node_modules/<包名>(实体目录);
@@ -98,15 +86,7 @@ function isLoadedAsProfilePlugin() {
98
86
  } catch { return false }
99
87
  }
100
88
 
101
- function compareVersions(left, right) {
102
- const a = stableVersion(left)
103
- const b = stableVersion(right)
104
- if (!a || !b) return 0
105
- for (let i = 0; i < 3; i++) {
106
- if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1
107
- }
108
- return 0
109
- }
89
+ // compareVersions 的旧名已并入 version.js 的 compareSemver,下两处调用已同步改名。
110
90
 
111
91
  // 运行中的 profile 名。DSH CLI 强制要求 --profile <name>,拿不到时退回 web——
112
92
  // 本插件的安装脚本默认就装到 web,这是最合理的兜底。
@@ -121,6 +101,13 @@ function dshHomeDir() {
121
101
  const configured = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
122
102
  return configured.length > 0 ? configured : join(homedir(), '.dsh')
123
103
  }
104
+ // ---------- 路径唯一入口(P0-3,用户拍板:网页端与桌面端分开) ----------
105
+ // 本插件自己的数据(账本/设置/价目缓存/更新状态)跟随「所在端的家目录」:
106
+ // DSH_HOME 有值 → 数据落该端名下,网页端与桌面端各归各,互不读写;
107
+ // 无值 → 沿用历史默认(与原来逐字相同,老用户路径一字不差,不搬家)。
108
+ // 环境变量 DSH_BOTTOM_INFO_BAR_DATA_DIR 永远优先(测试与特殊部署用)。
109
+ // 登录态文件(~/.codex/auth.json 等)是「人」的身份不是「端」的数据,只读不写,原地不动——
110
+ // 换端不断登,账本分开算,两件事不要混在一起。
124
111
 
125
112
  // 「更新命令」取决于本插件是怎么装上的,两者不能混用:
126
113
  // - npm 安装 → dsh plugin add …@latest;
@@ -330,7 +317,24 @@ const SUBSCRIPTION_RETRY_BACKOFF_MS = 60000 // 订阅刷新失败后的退避期
330
317
  // 订阅源 auth 文件路径(可用环境变量覆盖——测试隔离用,避免测试误读真实登录态);
331
318
  // 本插件只读令牌查询额度,令牌的绑定/续期由独立插件 dsh-chatgpt-sub 维护
332
319
  const CODEX_AUTH_FILE = process.env.DSH_BOTTOM_INFO_BAR_CODEX_AUTH || join(homedir(), '.codex', 'auth.json')
333
- const OPENCODE_AUTH_FILE = process.env.DSH_BOTTOM_INFO_BAR_OPENCODE_AUTH || join(homedir(), '.local', 'share', 'opencode', 'auth.json')
320
+ // 平台共享目录:POSIX 沿用 ~/.local/share;Windows 走 %LOCALAPPDATA%。
321
+ // 平台分支只在这里做一次,调用方只读结果。
322
+ function platformShareDir() {
323
+ if (process.platform === 'win32' && typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.length > 0) return process.env.LOCALAPPDATA
324
+ return join(homedir(), '.local', 'share')
325
+ }
326
+ // opencode 登录文件候选位置(按序试读,先命中即用):显式环境变量 → 平台共享目录 → 历史 POSIX 路径。
327
+ // Windows 下真实位置未经实测,用「新老都试」代替「猜一个」:原来认得出来的现在还认得出来,
328
+ // 原来认不出来的现在多一次机会。非 Windows 平台候选归一,与原来逐字相同。
329
+ function opencodeAuthFiles() {
330
+ if (typeof process.env.DSH_BOTTOM_INFO_BAR_OPENCODE_AUTH === 'string' && process.env.DSH_BOTTOM_INFO_BAR_OPENCODE_AUTH.length > 0) {
331
+ return [process.env.DSH_BOTTOM_INFO_BAR_OPENCODE_AUTH]
332
+ }
333
+ const files = [join(platformShareDir(), 'opencode', 'auth.json')]
334
+ const legacy = join(homedir(), '.local', 'share', 'opencode', 'auth.json')
335
+ if (files[0] !== legacy) files.push(legacy)
336
+ return files
337
+ }
334
338
  const COMMAND_CODE_AUTH_FILE = process.env.DSH_BOTTOM_INFO_BAR_COMMAND_CODE_AUTH || join(homedir(), '.commandcode', 'auth.json')
335
339
  const COMMAND_CODE_API_BASE = 'https://api.commandcode.ai'
336
340
  const COMMAND_CODE_PLAN_TOTAL_CREDITS = {
@@ -356,25 +360,12 @@ const COMMAND_CODE_PLAN_NAMES = {
356
360
 
357
361
  // ---------- 服务商账户映射(v1.6 分账核心):DSH provider id → 账户键;未知返回 null ----------
358
362
  // v1.7:新增 xiaomi(按量)、xiaomi-token-plan-*(套餐)、together / fireworks / amazon-bedrock / cloudflare-*(云账单)
363
+ // 三条映射只查 PROVIDER_IDENTITY(定义见 constants.js):新增服务商加表行即可,
364
+ // 再也不用在三条链里各写一遍。minimax 两站共用同一账户记账、订阅源各自独立——差异在表里,不在代码里。
359
365
  function accountForProvider(pid) {
360
366
  if (!pid) return null
361
- if (pid === 'deepseek' || pid === 'deepseek-official') return 'deepseek'
362
- if (pid === 'openai') return 'openai'
363
- if (pid === 'moonshotai' || pid === 'moonshotai-cn' || pid === 'kimi-coding') return 'moonshotai'
364
- if (pid === 'openrouter') return 'openrouter'
365
- if (pid === 'stepfun') return 'stepfun'
366
- if (pid === 'codex' || pid === 'chatgpt' || pid === 'openai-codex') return 'codex' // 订阅源 codex
367
- if (pid === 'opencode-go' || pid === 'opencode') return 'opencode-go' // 订阅源 opencode-go
368
- if (pid === 'zai' || pid === 'zai-coding-cn') return 'zai' // 订阅源 zai
369
- if (pid === 'xiaomi') return 'xiaomi'
370
- if (pid === 'xiaomi-token-plan-cn' || pid === 'xiaomi-token-plan-sgp' || pid === 'xiaomi-token-plan-ams') return 'xiaomi-token-plan'
371
- if (pid === 'command' || pid === 'command-code') return 'command-code'
372
- if (pid === 'minimax' || pid === 'minimax-cn') return 'minimax' // 账户键 minimax(Global / CN 共用同一账户记账;订阅源按站点各自独立,见 subscriptionSourceFor)
373
- if (pid === 'together') return 'together'
374
- if (pid === 'fireworks') return 'fireworks'
375
- if (pid === 'amazon-bedrock') return 'amazon-bedrock'
376
- if (pid === 'cloudflare-ai-gateway' || pid === 'cloudflare-workers-ai') return 'cloudflare'
377
- return null
367
+ const meta = Object.hasOwn(PROVIDER_IDENTITY, pid) ? PROVIDER_IDENTITY[pid] : null
368
+ return meta ? meta.account : null
378
369
  }
379
370
 
380
371
  // ---------- 余额来源映射(与 accountForProvider 是两条独立的轴,严禁合并) ----------
@@ -397,25 +388,14 @@ const PROVIDER_REPORTED_CURRENCY = { openrouter: 'USD' }
397
388
  // v1.7:小米 Token Plan 按地区分源(各地区独立 baseUrl 与凭据,避免跨地区串数据)
398
389
  // v1.16.0:MiniMax 双站点各自独立成源(对齐 xiaomi 地区隔离先例,快照/退避/并发去重互不串)
399
390
  function subscriptionSourceFor(providerId) {
400
- if (providerId === 'codex' || providerId === 'chatgpt' || providerId === 'openai-codex') return 'codex'
401
- if (providerId === 'opencode-go' || providerId === 'opencode') return 'opencode-go'
402
- if (providerId === 'zai' || providerId === 'zai-coding-cn') return 'zai'
403
- if (providerId === 'xiaomi-token-plan-cn') return 'xiaomi-cn'
404
- if (providerId === 'xiaomi-token-plan-sgp') return 'xiaomi-sgp'
405
- if (providerId === 'xiaomi-token-plan-ams') return 'xiaomi-ams'
406
- if (providerId === 'command' || providerId === 'command-code') return 'command-code'
407
- if (providerId === 'minimax') return 'minimax'
408
- if (providerId === 'minimax-cn') return 'minimax-cn'
409
- return null
391
+ const meta = providerId && Object.hasOwn(PROVIDER_IDENTITY, providerId) ? PROVIDER_IDENTITY[providerId] : null
392
+ return meta ? meta.subscription : null
410
393
  }
411
394
 
412
395
  // 云账单 provider → 账单源标识(together / fireworks / amazon-bedrock / cloudflare);非账单型 → null(FR-14)
413
396
  function billingSourceFor(providerId) {
414
- if (providerId === 'together') return 'together'
415
- if (providerId === 'fireworks') return 'fireworks'
416
- if (providerId === 'amazon-bedrock') return 'amazon-bedrock'
417
- if (providerId === 'cloudflare-ai-gateway' || providerId === 'cloudflare-workers-ai') return 'cloudflare'
418
- return null
397
+ const meta = providerId && Object.hasOwn(PROVIDER_IDENTITY, providerId) ? PROVIDER_IDENTITY[providerId] : null
398
+ return meta ? meta.billing : null
419
399
  }
420
400
 
421
401
  // ---------- M5:DSH 目录名 → 展示名(模型名/服务商名与模型切换器完全一致) ----------
@@ -606,14 +586,10 @@ function minimaxBaseUrl(providerId) {
606
586
  return providerId === 'minimax-cn' ? 'https://api.minimaxi.com' : 'https://api.minimax.io'
607
587
  }
608
588
 
609
- // 取条目里的数值字段(容忍数字型字符串;负数/缺失/非法一律 null)
589
+ // 取条目里的数值字段(容忍数字型字符串;负数/缺失/非法一律 null)。
590
+ // 与 parseFiniteNonNegativeAmount 同口径,名字保留(测试按契约引用),实现只调一份。
610
591
  function minimaxNumericField(value) {
611
- if (typeof value === 'number') return Number.isFinite(value) && value >= 0 ? value : null
612
- if (typeof value === 'string' && value.trim().length > 0) {
613
- const parsed = Number(value.trim())
614
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
615
- }
616
- return null
592
+ return parseFiniteNonNegativeAmount(value)
617
593
  }
618
594
 
619
595
  // 剩余百分比 → [0,100] 整数;缺失/非法/负数 → null(该条目跳过)
@@ -697,17 +673,77 @@ function parseMinimaxTokenPlanRemains(body, windowLabels) {
697
673
  }
698
674
 
699
675
  // 快照更新规则("失败保留旧快照"的纯函数形态):失败保留旧 data/fetchedAt 只换 error;成功换 data 并更新 fetchedAt
700
- function mergeSubscriptionResult(prev, result, translate) {
676
+ // 快照合并(订阅/账单共用):失败保留旧 data/fetchedAt、只换 error;成功替换 data 并刷新时间。
677
+ // fallbackCode 区分两条通道的兜底错误(旧快照与新错误都没有时的最后一道,平时走不到)。
678
+ function mergeSnapshotResult(prev, result, translate, fallbackCode) {
701
679
  if (translate === undefined) translate = t
702
680
  if (!result || result.error) {
703
681
  return {
704
682
  data: prev && prev.data ? prev.data : null,
705
683
  fetchedAt: prev && prev.fetchedAt ? prev.fetchedAt : null,
706
- error: result ? result.error : { kind: 'exception', code: 'subscription.request-failed', message: translate('error.subscription.request-failed') },
684
+ error: result ? result.error : { kind: 'exception', code: fallbackCode, message: translate('error.' + fallbackCode) },
707
685
  }
708
686
  }
709
687
  return { data: result.data || null, fetchedAt: Date.now(), error: null }
710
688
  }
689
+ // 对外名字保留(测试按名抽取快照语义、check-host 按名断言存在):行为与原来逐字一致。
690
+ function mergeSubscriptionResult(prev, result, translate) {
691
+ return mergeSnapshotResult(prev, result, translate, 'subscription.request-failed')
692
+ }
693
+ // 快照引擎(订阅/账单共用):并发去重 + seq 防旧覆盖 + 失败退避 + 新鲜度判定。
694
+ // 数据投影(out 对象形状)两边完全不同,留在各自 RPC 里手写;这里只收机械部分。
695
+ // merge(snap, result, translate) 由调用方注入;fetch 统一按 src.fetch(arg) 调用,
696
+ // 不需要路由参数的源忽略 arg(与原来 kickBillingRefresh 不传参等价)。
697
+ function createSnapshotEngine(sources, options) {
698
+ const snapshots = Object.create(null)
699
+ const seq = Object.create(null)
700
+ const inFlight = Object.create(null)
701
+ const requested = Object.create(null)
702
+ const lastFailAt = Object.create(null)
703
+ const merge = options.merge
704
+ const normalize = options.normalize || null
705
+ const translate = options.translate
706
+ function kick(key, arg) {
707
+ const src = sources[key]
708
+ if (!src) return Promise.resolve()
709
+ if (inFlight[key]) return inFlight[key]
710
+ const n = (seq[key] || 0) + 1
711
+ seq[key] = n
712
+ inFlight[key] = src.fetch(arg).then(function (result) {
713
+ if (seq[key] === n) {
714
+ if (normalize && result && result.data) result.data = normalize(result.data)
715
+ snapshots[key] = merge(snapshots[key], result, translate)
716
+ lastFailAt[key] = result && result.error ? Date.now() : 0
717
+ }
718
+ }).catch(function (err) {
719
+ if (seq[key] === n) {
720
+ snapshots[key] = merge(snapshots[key], { error: { kind: 'exception', message: String((err && err.message) || err) } }, translate)
721
+ lastFailAt[key] = Date.now()
722
+ }
723
+ }).finally(function () {
724
+ inFlight[key] = null
725
+ })
726
+ return inFlight[key]
727
+ }
728
+ return {
729
+ kick: kick,
730
+ markRequested: function (key) { requested[key] = true },
731
+ get: function (key) { return snapshots[key] || { data: null, fetchedAt: null, error: null } },
732
+ lastFailAt: function (key) { return lastFailAt[key] || 0 },
733
+ isStale: function (key, refreshMs, backoffMs, nowMs) {
734
+ const snap = snapshots[key] || { fetchedAt: null }
735
+ return (!snap.fetchedAt || (nowMs - snap.fetchedAt) > refreshMs) && (nowMs - (lastFailAt[key] || 0)) >= backoffMs
736
+ },
737
+ refreshActive: function (refreshMs, backoffMs, argForKey) {
738
+ const nowMs = Date.now()
739
+ for (const key in sources) {
740
+ if (!requested[key]) continue
741
+ if (nowMs - (lastFailAt[key] || 0) < backoffMs) continue
742
+ kick(key, argForKey ? argForKey(key) : undefined)
743
+ }
744
+ },
745
+ }
746
+ }
711
747
 
712
748
  // 读 auth.json:{ ok:true, auth } 或 { ok:false, reason:'missing'|'corrupt' }(缺失/损坏一律不抛异常)
713
749
  function readCodexAuthFile(filePath) {
@@ -928,7 +964,9 @@ const ZAI_LIMIT_WINDOWS = {
928
964
  '6:1': 'seven_day', // 6=周 × 1 → 每周窗口
929
965
  }
930
966
 
931
- // 取 limit 里的数值字段(容忍数字型字符串,如 "5";其余返回 null)
967
+ // 取 limit 里的数值字段(容忍数字型字符串,如 "5";其余返回 null)。
968
+ // 注意与 parseFiniteNonNegativeAmount 的区别:limit 语义允许负数哨兵值,这里只要求有限,
969
+ // 不另做 >= 0 截断——两处不可合并,合了会把上游的负数语义吞掉。
932
970
  function zaiLimitNumber(value) {
933
971
  if (typeof value === 'number') return Number.isFinite(value) ? value : null
934
972
  if (typeof value === 'string' && value.trim().length > 0) {
@@ -1318,6 +1356,24 @@ function usageRecordKey(record, index, source) {
1318
1356
  // the key so upgrading never merges two legitimate, identical requests.
1319
1357
  return 'legacy:' + source + ':' + index + ':' + record.ts + ':' + record.provider + ':' + record.model + ':' + record.sessionId
1320
1358
  }
1359
+ // 校验 → 归一化 → 去重 → 收集,一处实现(三处调用:快照+流水加载、冷归档读取、导出聚合)。
1360
+ // 去重键由 usageRecordKey 派生,调用方只给 (record, index, source),source 区分数据来源。
1361
+ function createRecordCollector() {
1362
+ const records = []
1363
+ const seen = new Set()
1364
+ return {
1365
+ records: records,
1366
+ add: function (record, index, source) {
1367
+ if (!isValidUsageRecord(record)) return false
1368
+ const normalized = normalizeUsageRecord(record, index, source)
1369
+ const key = usageRecordKey(normalized, index, source)
1370
+ if (seen.has(key)) return false
1371
+ seen.add(key)
1372
+ records.push(normalized)
1373
+ return true
1374
+ },
1375
+ }
1376
+ }
1321
1377
 
1322
1378
  // 账单数据清理只允许触达插件自己创建的精确文件/目录。临时文件也按固定前缀
1323
1379
  // 收集,避免留下半次原子写;settings.json、pricing-cache.json 等非账单数据永不触达。
@@ -1398,19 +1454,15 @@ function loadUsageRecords() {
1398
1454
  const loaded = readUsageSnapshot(candidates[i])
1399
1455
  if (loaded) { snapshot = loaded; break }
1400
1456
  }
1401
- const records = []
1402
- const seen = new Set()
1457
+ const collector = createRecordCollector()
1458
+ const records = collector.records
1403
1459
  let migratedLegacyRecord = false
1404
1460
  // journal 行数/字节数基线:滚动压缩只看阈值是否超限,不参与记账正确性
1405
1461
  let journalLines = 0
1406
1462
  function add(record, index, source) {
1407
1463
  if (!isValidUsageRecord(record)) return
1408
1464
  if (typeof record.id !== 'string' || record.id.length === 0) migratedLegacyRecord = true
1409
- const normalized = normalizeUsageRecord(record, index, source)
1410
- const key = usageRecordKey(normalized, index, source)
1411
- if (seen.has(key)) return
1412
- seen.add(key)
1413
- records.push(normalized)
1465
+ collector.add(record, index, source)
1414
1466
  }
1415
1467
  snapshot.forEach(function (record, index) { add(record, index, 'snapshot') })
1416
1468
  // A torn journal line only loses that one unfinished append; all preceding
@@ -1435,8 +1487,8 @@ function loadUsageRecords() {
1435
1487
  }
1436
1488
 
1437
1489
  function readArchivedUsageRecords() {
1438
- const records = []
1439
- const seen = new Set()
1490
+ const collector = createRecordCollector()
1491
+ const records = collector.records
1440
1492
  try {
1441
1493
  if (!existsSync(USAGE_ARCHIVE_DIR)) return { records: records, error: null }
1442
1494
  const files = readdirSync(USAGE_ARCHIVE_DIR).filter(function (name) { return name.endsWith('.jsonl') }).sort()
@@ -1446,13 +1498,7 @@ function readArchivedUsageRecords() {
1446
1498
  raw.split('\n').forEach(function (line, index) {
1447
1499
  if (!line.trim()) return
1448
1500
  try {
1449
- const parsed = JSON.parse(line)
1450
- if (!isValidUsageRecord(parsed)) return
1451
- const normalized = normalizeUsageRecord(parsed, index, 'archive:' + name)
1452
- const key = usageRecordKey(normalized, index, 'archive:' + name)
1453
- if (seen.has(key)) return
1454
- seen.add(key)
1455
- records.push(normalized)
1501
+ collector.add(JSON.parse(line), index, 'archive:' + name)
1456
1502
  } catch (err) { /* skip only a corrupt archive line */ }
1457
1503
  })
1458
1504
  }
@@ -1618,27 +1664,51 @@ function loadSettingsFromDisk() {
1618
1664
  }
1619
1665
  }
1620
1666
 
1621
- // 原子写:tmp 文件写满 + fsync 后 rename 覆盖(与 writeAndSync 快照路径同一模式,避免半截文件)
1622
- function writeFileAtomic(filePath, content, translate) {
1623
- if (translate === undefined) translate = t
1624
- mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
1625
- const tmp = filePath + '.tmp.' + process.pid + '.' + randomUUID()
1667
+ // 写满 + fsync 的底层原语(短写抛调用方给定的消息,其余错误原样抛)。
1668
+ // 上层分两个耐久档:atomicReplaceFile(tmp + rename,必须不能丢)与直接调用(journal 追加的
1669
+ // O_APPEND 语义不能经 tmp;自更新状态/日志是 best-effort,走自己的 try/catch,不合进来)。
1670
+ function writeFullySync(filePath, content, flags) {
1626
1671
  let fd = null
1627
1672
  try {
1628
- fd = openSync(tmp, 'w', 0o600)
1673
+ fd = openSync(filePath, flags, 0o600)
1629
1674
  const bytes = Buffer.from(content)
1630
1675
  let offset = 0
1631
1676
  while (offset < bytes.length) {
1632
1677
  const written = writeSync(fd, bytes, offset, bytes.length - offset)
1633
- if (!written) throw new Error(translate('host.settingsFileCouldNotBe'))
1678
+ if (!written) {
1679
+ const err = new Error('short write to ' + filePath)
1680
+ err.code = 'BIB_SHORT_WRITE'
1681
+ throw err
1682
+ }
1634
1683
  offset += written
1635
1684
  }
1636
1685
  fsyncSync(fd)
1637
1686
  } finally {
1638
1687
  if (fd !== null) closeSync(fd)
1639
1688
  }
1689
+ }
1690
+ // 原子替换写:tmp 写满 + fsync 后 rename 覆盖(避免半截文件),可选先把旧文件转成备份。
1691
+ function atomicReplaceFile(filePath, content, options) {
1692
+ const opts = options || {}
1693
+ if (opts.ensureDir) mkdirSync(opts.ensureDir, { recursive: true, mode: 0o700 })
1694
+ const tmp = (opts.tmpPrefix || filePath + '.tmp.') + process.pid + '.' + randomUUID()
1695
+ writeFullySync(tmp, content, 'w')
1696
+ if (opts.backupPath && existsSync(filePath)) {
1697
+ try { renameSync(filePath, opts.backupPath) } catch (err) { /* 下面的替换仍是原子操作 */ }
1698
+ }
1640
1699
  renameSync(tmp, filePath)
1641
1700
  }
1701
+ // settings 落盘:目录确保 + 短写时翻译报错(open/rename 失败沿用原文,调用方按 code 归一)。
1702
+ function writeFileAtomic(filePath, content, translate) {
1703
+ if (translate === undefined) translate = t
1704
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
1705
+ try {
1706
+ atomicReplaceFile(filePath, content, {})
1707
+ } catch (err) {
1708
+ if (err && err.code === 'BIB_SHORT_WRITE') throw new Error(translate('host.settingsFileCouldNotBe'))
1709
+ throw err
1710
+ }
1711
+ }
1642
1712
 
1643
1713
  // L1(安全审计低危建议):启动时尽力而为收敛敏感文件权限——账本目录 0700、流水账 journal 0600。
1644
1714
  // 只做 try/catch 包裹的 chmod:权限收敛失败(如非 POSIX 文件系统/只读挂载)绝不影响启动与记账。
@@ -1770,11 +1840,11 @@ export default {
1770
1840
  // 方向由 host 判定(客户端不做版本比较):'update' 说明磁盘已是新版、'rollback' 说明用户
1771
1841
  // 回滚过、磁盘比运行版本旧,两者都只有重启才生效,但文案要分开。
1772
1842
  // 判据是「两者不等」而不是「disk 更高」:回滚之后 disk 低于 running,同样只有重启才生效。
1773
- const delta = compareVersions(current, state.runningVersion)
1843
+ const delta = compareSemver(current, state.runningVersion)
1774
1844
  return Object.assign({}, state, {
1775
1845
  latest: latest,
1776
1846
  diskVersion: current,
1777
- available: !!latest && compareVersions(latest, current) > 0,
1847
+ available: !!latest && compareSemver(latest, current) > 0,
1778
1848
  pendingRestart: delta !== 0,
1779
1849
  restartDirection: delta > 0 ? 'update' : (delta < 0 ? 'rollback' : null),
1780
1850
  })
@@ -2061,41 +2131,32 @@ export default {
2061
2131
  }
2062
2132
  }
2063
2133
 
2134
+ // balance_infos 列表解析(DeepSeek / Kimi 同构:优先可用的 CNY,再退 USD;
2135
+ // granted/toppedUp 缺失按 0 计)。两家 API 返回同一 envelope,各写一遍只会让修解析时漏改一家。
2136
+ function parseBalanceInfos(body) {
2137
+ const list = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
2138
+ let rec = null;
2139
+ for (const currency of ['CNY', 'USD']) {
2140
+ for (let i = 0; i < list.length; i++) {
2141
+ if (list[i] && list[i].currency === currency && parseFiniteNonNegativeAmount(list[i].total_balance) != null) { rec = list[i]; break; }
2142
+ }
2143
+ if (rec) break;
2144
+ }
2145
+ if (!rec) return null;
2146
+ const total = parseFiniteNonNegativeAmount(rec.total_balance);
2147
+ if (total == null) return null;
2148
+ const granted = parseFiniteNonNegativeAmount(rec.granted_balance);
2149
+ const toppedUp = parseFiniteNonNegativeAmount(rec.topped_up_balance);
2150
+ return { currency: rec.currency, total: total, granted: granted == null ? 0 : granted, toppedUp: toppedUp == null ? 0 : toppedUp };
2151
+ }
2152
+
2064
2153
  // ---------- 服务商适配器(余额仅 DeepSeek 真实 API;OpenAI 为记账回退估算) ----------
2065
2154
  const PROVIDERS = {
2066
2155
  deepseek: {
2067
2156
  id: 'deepseek', displayName: 'DeepSeek', credential: 'DEEPSEEK_API_KEY',
2068
2157
  balanceAPI: 'https://api.deepseek.com/user/balance',
2069
2158
  estimate: false,
2070
- parseBalance: function (body) {
2071
- const list = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
2072
- let rec = null;
2073
- for (let i = 0; i < list.length; i++) {
2074
- if (list[i] && list[i].currency === 'CNY' && parseFiniteNonNegativeAmount(list[i].total_balance) != null) {
2075
- rec = list[i]; break;
2076
- }
2077
- }
2078
- if (!rec) {
2079
- for (let i = 0; i < list.length; i++) {
2080
- if (list[i] && list[i].currency === 'USD' && parseFiniteNonNegativeAmount(list[i].total_balance) != null) {
2081
- rec = list[i]; break;
2082
- }
2083
- }
2084
- }
2085
- if (!rec) return null;
2086
- const currency = rec.currency === 'USD' || rec.currency === 'CNY' ? rec.currency : null;
2087
- if (!currency) return null;
2088
- const total = parseFiniteNonNegativeAmount(rec.total_balance);
2089
- if (total == null) return null;
2090
- const granted = parseFiniteNonNegativeAmount(rec.granted_balance);
2091
- const toppedUp = parseFiniteNonNegativeAmount(rec.topped_up_balance);
2092
- return {
2093
- currency: currency,
2094
- total: total,
2095
- granted: granted == null ? 0 : granted,
2096
- toppedUp: toppedUp == null ? 0 : toppedUp,
2097
- };
2098
- },
2159
+ parseBalance: parseBalanceInfos,
2099
2160
  },
2100
2161
  // DSH 桌面端内置 DeepSeek 账号:与上面 API Key 路是同一品牌的**两条通道**,账户键刻意分开 ——
2101
2162
  // 两条路的凭据与失效原因完全不同(「没配 API Key」和「账号没登录」是两句不同的话),
@@ -2117,36 +2178,7 @@ export default {
2117
2178
  id: 'moonshotai', displayName: 'Kimi', credential: 'MOONSHOT_API_KEY',
2118
2179
  balanceAPI: 'https://api.moonshot.cn/v1/users/me/balance',
2119
2180
  estimate: false,
2120
- parseBalance: function (body) {
2121
- const list = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
2122
- // 优先选择可用的 CNY 账户;没有时选择可用的 USD 账户,不依赖数组首项。
2123
- let rec = null;
2124
- for (let i = 0; i < list.length; i++) {
2125
- if (list[i] && list[i].currency === 'CNY' && parseFiniteNonNegativeAmount(list[i].total_balance) != null) {
2126
- rec = list[i]; break;
2127
- }
2128
- }
2129
- if (!rec) {
2130
- for (let i = 0; i < list.length; i++) {
2131
- if (list[i] && list[i].currency === 'USD' && parseFiniteNonNegativeAmount(list[i].total_balance) != null) {
2132
- rec = list[i]; break;
2133
- }
2134
- }
2135
- }
2136
- if (!rec) return null;
2137
- const currency = rec.currency === 'USD' || rec.currency === 'CNY' ? rec.currency : null;
2138
- if (!currency) return null;
2139
- const total = parseFiniteNonNegativeAmount(rec.total_balance);
2140
- if (total == null) return null;
2141
- const granted = parseFiniteNonNegativeAmount(rec.granted_balance);
2142
- const toppedUp = parseFiniteNonNegativeAmount(rec.topped_up_balance);
2143
- return {
2144
- currency: currency,
2145
- total: total,
2146
- granted: granted == null ? 0 : granted,
2147
- toppedUp: toppedUp == null ? 0 : toppedUp,
2148
- };
2149
- },
2181
+ parseBalance: parseBalanceInfos,
2150
2182
  },
2151
2183
  // v1.6 T4:openrouter 余额适配器
2152
2184
  openrouter: {
@@ -2374,12 +2406,8 @@ export default {
2374
2406
  }
2375
2407
 
2376
2408
  // ---------- 订阅额度快照(复用余额模式:周期刷新 / 失败保留旧快照 / seq 防旧覆盖) ----------
2377
- let subscriptions = Object.create(null); // { [sourceKey]: { data: {provider,plan,windows}, fetchedAt, error } }
2378
- const subscriptionSeq = Object.create(null); // 每 source 刷新序号:仅最新一次请求可写入快照
2379
- const subscriptionInFlight = Object.create(null); // { [sourceKey]: Promise } 并发去重(同一时刻只发一个请求)
2380
- const subscriptionRequested = Object.create(null); // 仅"客户端请求过"的源进入 60s 周期刷新(余额制下不打扰订阅接口)
2381
- const subscriptionSourceProvider = Object.create(null); // { [sourceKey]: providerId }——记住请求该源时的 provider(zai 系按 provider 路由 host/凭据)
2382
- const subscriptionLastFailAt = Object.create(null); // { [sourceKey]: ms } 上次订阅刷新失败时刻(失败退避:期内不重试)
2409
+ const subscriptionSourceProvider = Object.create(null); // { [sourceKey]: providerId }——记住请求该源时的 provider(zai 系按 provider 路由 host/凭据),供引擎路由参数用
2410
+ // 快照状态(snapshots/seq/inFlight/requested/lastFailAt)收进下面的两个引擎实例,不再各写一套。
2383
2411
 
2384
2412
  // FR-8 / D7:Codex / ChatGPT 订阅卡(纯本地通道)
2385
2413
  // 只读令牌:令牌由独立插件 dsh-chatgpt-sub 维护,本插件不续期、不写回、不注入凭据;
@@ -2412,16 +2440,18 @@ export default {
2412
2440
  const cred = await ctx.credentials.resolve('OPENCODE_GO_API_KEY');
2413
2441
  if (cred && typeof cred.value === 'string' && cred.value.length > 0) return cred.value;
2414
2442
  } catch (err) { /* 回退到 auth.json */ }
2415
- try {
2416
- const auth = JSON.parse(readFileSync(OPENCODE_AUTH_FILE, 'utf8'));
2417
- for (const name of ['opencode-go', 'opencode']) {
2418
- const entry = auth && auth[name];
2419
- if (entry && typeof entry === 'object') {
2420
- if (typeof entry.key === 'string' && entry.key.length > 0) return entry.key;
2421
- if (typeof entry.apiKey === 'string' && entry.apiKey.length > 0) return entry.apiKey;
2443
+ for (const file of opencodeAuthFiles()) {
2444
+ try {
2445
+ const auth = JSON.parse(readFileSync(file, 'utf8'));
2446
+ for (const name of ['opencode-go', 'opencode']) {
2447
+ const entry = auth && auth[name];
2448
+ if (entry && typeof entry === 'object') {
2449
+ if (typeof entry.key === 'string' && entry.key.length > 0) return entry.key;
2450
+ if (typeof entry.apiKey === 'string' && entry.apiKey.length > 0) return entry.apiKey;
2451
+ }
2422
2452
  }
2423
- }
2424
- } catch (err) { /* 未配置 → 返回 null */ }
2453
+ } catch (err) { /* 该候选无文件/损坏 → 试下一个 */ }
2454
+ }
2425
2455
  return null;
2426
2456
  }
2427
2457
 
@@ -2448,10 +2478,8 @@ export default {
2448
2478
  // Command Code 凭据:DSH 设置中的标准名 → Provider API 文档兼容名 → 进程环境 → CLI 登录文件。
2449
2479
  // ~/.commandcode/auth.json 只读 apiKey,绝不写回或把密钥放进错误信息。
2450
2480
  async function resolveCommandCodeKey() {
2451
- for (const name of ['COMMAND_CODE_API_KEY', 'CMD_API_KEY']) {
2452
- const configured = await resolveCredentialValue(name)
2453
- if (configured) return configured
2454
- }
2481
+ const configured = await resolveWithFallback(['COMMAND_CODE_API_KEY', 'CMD_API_KEY'])
2482
+ if (configured) return configured
2455
2483
  for (const name of ['COMMAND_CODE_API_KEY', 'CMD_API_KEY']) {
2456
2484
  if (typeof process.env[name] === 'string' && process.env[name].length > 0) return process.env[name]
2457
2485
  }
@@ -2528,9 +2556,7 @@ export default {
2528
2556
  const isCn = providerId === 'minimax-cn'
2529
2557
  const primary = isCn ? 'MINIMAX_CN_API_KEY' : 'MINIMAX_API_KEY'
2530
2558
  const fallback = isCn ? 'MINIMAX_API_KEY' : 'MINIMAX_CN_API_KEY'
2531
- const primaryValue = await resolveCredentialValue(primary)
2532
- if (primaryValue) return primaryValue
2533
- return resolveCredentialValue(fallback)
2559
+ return resolveWithFallback([primary, fallback])
2534
2560
  }
2535
2561
 
2536
2562
  async function fetchMinimaxTokenPlanUsage(providerId) {
@@ -2700,6 +2726,14 @@ export default {
2700
2726
  } catch (err) { /* 未配置/读取失败 → null */ }
2701
2727
  return null;
2702
2728
  }
2729
+ // 按优先级逐个试凭据名,首个命中即返回(无副作用的纯查找,顺序即优先级)。
2730
+ async function resolveWithFallback(names) {
2731
+ for (const name of names) {
2732
+ const value = await resolveCredentialValue(name);
2733
+ if (value) return value;
2734
+ }
2735
+ return null;
2736
+ }
2703
2737
 
2704
2738
  // v1.7 FR-9:小米 MiMo Token Plan 订阅源(按地区路由 baseUrl + 凭据,地区互不串数据)
2705
2739
  // 凭据:XIAOMI_TOKEN_PLAN_CN/SGP/AMS_API_KEY 按地区优先,回退 XIAOMI_API_KEY;Bearer。
@@ -2709,11 +2743,7 @@ export default {
2709
2743
  }
2710
2744
  async function resolveXiaomiRegionKey(region) {
2711
2745
  const primary = xiaomiRegionKeyName(region);
2712
- if (primary) {
2713
- const cred = await resolveCredentialValue(primary);
2714
- if (cred) return cred;
2715
- }
2716
- return resolveCredentialValue('XIAOMI_API_KEY');
2746
+ return resolveWithFallback(primary ? [primary, 'XIAOMI_API_KEY'] : ['XIAOMI_API_KEY']);
2717
2747
  }
2718
2748
 
2719
2749
  async function fetchXiaomiTokenPlanUsage(region) {
@@ -2921,100 +2951,10 @@ export default {
2921
2951
  'minimax-cn': { fetch: function () { return fetchMinimaxTokenPlanUsage('minimax-cn'); } },
2922
2952
  };
2923
2953
 
2924
- // 触发一次刷新(并发去重 + seq 防旧覆盖);返回本次刷新 Promise
2925
- // providerId:需要按 provider 路由 host/凭据的源(如 zai 系)使用;其余源忽略
2926
- function kickSubscriptionRefresh(sourceKey, providerId) {
2927
- const src = SUBSCRIPTION_SOURCES[sourceKey];
2928
- if (!src) return Promise.resolve();
2929
- if (subscriptionInFlight[sourceKey]) return subscriptionInFlight[sourceKey];
2930
- const seq = (subscriptionSeq[sourceKey] || 0) + 1;
2931
- subscriptionSeq[sourceKey] = seq;
2932
- subscriptionInFlight[sourceKey] = src.fetch(providerId).then(function (result) {
2933
- if (subscriptionSeq[sourceKey] === seq) {
2934
- subscriptions[sourceKey] = mergeSubscriptionResult(subscriptions[sourceKey], result, t);
2935
- // 失败退避记录:失败记时刻(期内不重试),成功清零
2936
- if (result && result.error) subscriptionLastFailAt[sourceKey] = Date.now();
2937
- else subscriptionLastFailAt[sourceKey] = 0;
2938
- }
2939
- }).catch(function (err) {
2940
- if (subscriptionSeq[sourceKey] === seq) {
2941
- subscriptions[sourceKey] = mergeSubscriptionResult(subscriptions[sourceKey], {
2942
- error: { kind: 'exception', message: String((err && err.message) || err) },
2943
- });
2944
- subscriptionLastFailAt[sourceKey] = Date.now();
2945
- }
2946
- }).finally(function () {
2947
- subscriptionInFlight[sourceKey] = null;
2948
- });
2949
- return subscriptionInFlight[sourceKey];
2950
- }
2951
-
2952
- // 60s 周期刷新:仅刷新客户端请求过的源(余额制模式下不打扰未公开的订阅接口);失败退避期内跳过
2953
- function refreshActiveSubscriptions() {
2954
- const nowMs = Date.now();
2955
- for (const sourceKey in SUBSCRIPTION_SOURCES) {
2956
- if (!subscriptionRequested[sourceKey]) continue;
2957
- const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
2958
- if (nowMs - lastFailAt < SUBSCRIPTION_RETRY_BACKOFF_MS) continue;
2959
- kickSubscriptionRefresh(sourceKey, subscriptionSourceProvider[sourceKey]);
2960
- }
2961
- }
2962
2954
 
2963
2955
  // ---------- v1.7 FR-10~13:账单快照(云账单型;与订阅/余额快照完全隔离,同一套策略) ----------
2964
2956
  const BILLING_REFRESH_MS = 60000;
2965
2957
  const BILLING_RETRY_BACKOFF_MS = 60000;
2966
- let billingSnapshots = Object.create(null); // { [key]: { data, fetchedAt, error } }
2967
- const billingSeq = Object.create(null);
2968
- const billingInFlight = Object.create(null);
2969
- const billingRequested = Object.create(null);
2970
- const billingLastFailAt = Object.create(null);
2971
-
2972
- function mergeBillingResult(prev, result) {
2973
- if (!result || result.error) {
2974
- return {
2975
- data: prev && prev.data ? prev.data : null,
2976
- fetchedAt: prev && prev.fetchedAt ? prev.fetchedAt : null,
2977
- error: result ? result.error : { kind: 'exception', code: 'billing.request-failed', message: t('error.billing.request-failed') },
2978
- };
2979
- }
2980
- return { data: result.data || null, fetchedAt: Date.now(), error: null };
2981
- }
2982
-
2983
- function kickBillingRefresh(key) {
2984
- const src = BILLING_SOURCES[key];
2985
- if (!src) return Promise.resolve();
2986
- if (billingInFlight[key]) return billingInFlight[key];
2987
- const seq = (billingSeq[key] || 0) + 1;
2988
- billingSeq[key] = seq;
2989
- billingInFlight[key] = src.fetch().then(function (result) {
2990
- if (billingSeq[key] === seq) {
2991
- // FR-14:适配器原始输出统一经 normalizeAccountStatus 收敛到客户端契约
2992
- if (result && result.data) result.data = normalizeAccountStatus(result.data.kind, result.data);
2993
- billingSnapshots[key] = mergeBillingResult(billingSnapshots[key], result);
2994
- billingLastFailAt[key] = result && result.error ? Date.now() : 0;
2995
- }
2996
- }).catch(function (err) {
2997
- if (billingSeq[key] === seq) {
2998
- billingSnapshots[key] = mergeBillingResult(billingSnapshots[key], {
2999
- error: { kind: 'exception', message: String((err && err.message) || err) },
3000
- });
3001
- billingLastFailAt[key] = Date.now();
3002
- }
3003
- }).finally(function () {
3004
- billingInFlight[key] = null;
3005
- });
3006
- return billingInFlight[key];
3007
- }
3008
-
3009
- function refreshActiveBilling() {
3010
- const nowMs = Date.now();
3011
- for (const key in BILLING_SOURCES) {
3012
- if (!billingRequested[key]) continue;
3013
- const lastFailAt = billingLastFailAt[key] || 0;
3014
- if (nowMs - lastFailAt < BILLING_RETRY_BACKOFF_MS) continue;
3015
- kickBillingRefresh(key);
3016
- }
3017
- }
3018
2958
 
3019
2959
  // RPC:当前订阅额度快照 + 模式判定(非订阅模式直接返回,不发任何订阅请求)
3020
2960
  async function getSubscriptionSnapshotRpc(selection, force) {
@@ -3027,24 +2967,21 @@ export default {
3027
2967
  const sourceKey = subscriptionSourceFor(sel.provider);
3028
2968
  if (!sourceKey) return out;
3029
2969
  out.source = sourceKey;
3030
- subscriptionRequested[sourceKey] = true; // 该源进入 60s 周期刷新
2970
+ subscriptionEngine.markRequested(sourceKey); // 该源进入 60s 周期刷新
3031
2971
  subscriptionSourceProvider[sourceKey] = sel.provider; // 记住 provider(zai 系路由需要)
3032
- const snap = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
2972
+ const snap = subscriptionEngine.get(sourceKey);
3033
2973
  const nowMs = Date.now();
3034
- const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
3035
2974
  // 失败退避:快照过期(>60s 无成功)且距上次失败 ≥ 退避期(60s)才重试——
3036
2975
  // 减少重复订阅请求,也避免“刷新失败”提示随每次轮询反复闪烁(失败期内直接读缓存快照)
3037
- const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > SUBSCRIPTION_REFRESH_MS)
3038
- && (nowMs - lastFailAt) >= SUBSCRIPTION_RETRY_BACKOFF_MS;
3039
2976
  // force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查,
3040
2977
  // 让用户手动刷新页面时立刻拿到最新额度/余额,而不是干等下一轮自动刷新
3041
- if (force || stale) {
3042
- const inflight = kickSubscriptionRefresh(sourceKey, subscriptionSourceProvider[sourceKey]);
2978
+ if (force || subscriptionEngine.isStale(sourceKey, SUBSCRIPTION_REFRESH_MS, SUBSCRIPTION_RETRY_BACKOFF_MS, nowMs)) {
2979
+ const inflight = subscriptionEngine.kick(sourceKey, subscriptionSourceProvider[sourceKey]);
3043
2980
  // 从未成功过(无旧数据)或强制刷新 → 等本次结果返回最新数据(含错误);
3044
2981
  // 已有旧数据的普通刷新 → 后台刷新,本次直接返回快照(不阻塞轮询)
3045
2982
  if (!snap.data || force) await inflight;
3046
2983
  }
3047
- const cur = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
2984
+ const cur = subscriptionEngine.get(sourceKey);
3048
2985
  if (cur.data) {
3049
2986
  out.plan = cur.data.plan;
3050
2987
  out.windows = cur.data.windows;
@@ -3066,6 +3003,22 @@ export default {
3066
3003
  cloudflare: { fetch: fetchCloudflareBilling }, // cloudflare-ai-gateway / cloudflare-workers-ai 共用
3067
3004
  };
3068
3005
 
3006
+ // 两套快照引擎实例:订阅制与账单制各一个,状态完全隔离(地区隔离、互不串扰的语义不变),
3007
+ // 机械部分(并发去重 / seq 防旧覆盖 / 失败退避 / 新鲜度判定)只在 createSnapshotEngine 里写一遍。
3008
+ const subscriptionEngine = createSnapshotEngine(SUBSCRIPTION_SOURCES, { merge: mergeSubscriptionResult, translate: t });
3009
+ const billingEngine = createSnapshotEngine(BILLING_SOURCES, {
3010
+ merge: function (prev, result) { return mergeSnapshotResult(prev, result, t, 'billing.request-failed'); },
3011
+ // FR-14:适配器原始输出统一经 normalizeAccountStatus 收敛到客户端契约
3012
+ normalize: function (data) { return normalizeAccountStatus(data.kind, data); },
3013
+ });
3014
+ // 60s 周期刷新:仅刷新客户端请求过的源(余额制模式下不打扰未公开的订阅接口);失败退避期内跳过
3015
+ function refreshActiveSubscriptions() {
3016
+ subscriptionEngine.refreshActive(SUBSCRIPTION_REFRESH_MS, SUBSCRIPTION_RETRY_BACKOFF_MS, function (key) { return subscriptionSourceProvider[key]; });
3017
+ }
3018
+ function refreshActiveBilling() {
3019
+ billingEngine.refreshActive(BILLING_REFRESH_MS, BILLING_RETRY_BACKOFF_MS);
3020
+ }
3021
+
3069
3022
  async function getBillingSnapshotRpc(selection, force) {
3070
3023
  const sel = selection || modelSelection();
3071
3024
  const bm = selectionIsResolved(sel)
@@ -3076,18 +3029,15 @@ export default {
3076
3029
  const key = billingSourceFor(sel.provider);
3077
3030
  if (!key) return out;
3078
3031
  out.type = key;
3079
- billingRequested[key] = true; // 该源进入 60s 周期刷新
3080
- const snap = billingSnapshots[key] || { data: null, fetchedAt: null, error: null };
3032
+ billingEngine.markRequested(key); // 该源进入 60s 周期刷新
3033
+ const snap = billingEngine.get(key);
3081
3034
  const nowMs = Date.now();
3082
- const lastFailAt = billingLastFailAt[key] || 0;
3083
- const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > BILLING_REFRESH_MS)
3084
- && (nowMs - lastFailAt) >= BILLING_RETRY_BACKOFF_MS;
3085
3035
  // force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查最新账单
3086
- if (force || stale) {
3087
- const inflight = kickBillingRefresh(key);
3036
+ if (force || billingEngine.isStale(key, BILLING_REFRESH_MS, BILLING_RETRY_BACKOFF_MS, nowMs)) {
3037
+ const inflight = billingEngine.kick(key);
3088
3038
  if (!snap.data || force) await inflight;
3089
3039
  }
3090
- const cur = billingSnapshots[key] || { data: null, fetchedAt: null, error: null };
3040
+ const cur = billingEngine.get(key);
3091
3041
  out.data = cur.data;
3092
3042
  out.fetchedAt = cur.fetchedAt;
3093
3043
  out.error = cur.error;
@@ -3099,24 +3049,24 @@ export default {
3099
3049
  // 以生效时刻为界,保留此前周末请求原有的峰谷结算规则,供未冻结的旧记录回算使用。
3100
3050
  const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.UTC(2026, 7, 22, 16, 0, 0);
3101
3051
  function beijingMinutes(nowMs) {
3102
- const d = new Date(nowMs + 8 * 3600 * 1000);
3052
+ const d = new Date(nowMs + BEIJING_OFFSET_MS);
3103
3053
  return d.getUTCHours() * 60 + d.getUTCMinutes();
3104
3054
  }
3105
3055
  function currentPeriod(nowMs) {
3106
- const d = new Date(nowMs + 8 * 3600 * 1000);
3056
+ const d = new Date(nowMs + BEIJING_OFFSET_MS);
3107
3057
  const weekend = d.getUTCDay() === 0 || d.getUTCDay() === 6;
3108
3058
  if (nowMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && weekend) return 'offpeak';
3109
3059
  const m = beijingMinutes(nowMs);
3110
3060
  return (m >= 9 * 60 && m < 12 * 60) || (m >= 14 * 60 && m < 18 * 60) ? 'peak' : 'offpeak';
3111
3061
  }
3112
3062
  function nextSwitchAt(nowMs) {
3113
- const d = new Date(nowMs + 8 * 3600 * 1000);
3063
+ const d = new Date(nowMs + BEIJING_OFFSET_MS);
3114
3064
  const bounds = [9, 12, 14, 18];
3115
3065
  const period = currentPeriod(nowMs);
3116
3066
  // 周末没有峰谷切换;从当前北京日期起查找下一个实际变价点(最长覆盖至下一周一)。
3117
3067
  for (let day = 0; day <= 8; day++) {
3118
3068
  for (let i = 0; i < bounds.length; i++) {
3119
- const at = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + day, bounds[i], 0, 0) - 8 * 3600 * 1000).getTime();
3069
+ const at = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + day, bounds[i], 0, 0) - BEIJING_OFFSET_MS).getTime();
3120
3070
  if (at > nowMs && currentPeriod(at) !== period) return at;
3121
3071
  }
3122
3072
  }
@@ -3125,13 +3075,13 @@ export default {
3125
3075
  function nextPeriodLabel(nowMs) {
3126
3076
  const at = nextSwitchAt(nowMs);
3127
3077
  if (at == null) return null;
3128
- const d = new Date(at + 8 * 3600 * 1000);
3078
+ const d = new Date(at + BEIJING_OFFSET_MS);
3129
3079
  const hh = String(d.getUTCHours()).padStart(2, '0');
3130
3080
  const mm = String(d.getUTCMinutes()).padStart(2, '0');
3131
3081
  return { at: at, atLabel: hh + ':' + mm, nextIsPeak: currentPeriod(at) === 'peak' };
3132
3082
  }
3133
3083
  function beijingDayKey(ts) {
3134
- const d = new Date(ts + 8 * 3600 * 1000);
3084
+ const d = new Date(ts + BEIJING_OFFSET_MS);
3135
3085
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
3136
3086
  }
3137
3087
 
@@ -3554,18 +3504,10 @@ export default {
3554
3504
 
3555
3505
  function allUsageRecordsForExport() {
3556
3506
  const archived = readArchivedUsageRecords();
3557
- const records = [];
3558
- const seen = new Set();
3559
- function add(record, index, source) {
3560
- if (!isValidUsageRecord(record)) return;
3561
- const normalized = normalizeUsageRecord(record, index, source);
3562
- const key = usageRecordKey(normalized, index, source);
3563
- if (seen.has(key)) return;
3564
- seen.add(key);
3565
- records.push(normalized);
3566
- }
3567
- usageRecords.forEach(function (record, index) { add(record, index, 'current') });
3568
- archived.records.forEach(function (record, index) { add(record, index, 'archive-export') });
3507
+ const collector = createRecordCollector();
3508
+ const records = collector.records;
3509
+ usageRecords.forEach(function (record, index) { collector.add(record, index, 'current') });
3510
+ archived.records.forEach(function (record, index) { collector.add(record, index, 'archive-export') });
3569
3511
  return {
3570
3512
  records: records.sort(function (a, b) { return a.ts - b.ts }),
3571
3513
  archiveReadError: archived.error,
@@ -3835,12 +3777,7 @@ export default {
3835
3777
  foldedSessions: sessionsDelta,
3836
3778
  foldedAccountTotals: accountTotals,
3837
3779
  };
3838
- const tmp = USAGE_SUMMARIES_TEMP_PREFIX + process.pid + '.' + randomUUID();
3839
- writeAndSync(tmp, JSON.stringify(payload), 'w');
3840
- if (existsSync(USAGE_SUMMARIES_FILE)) {
3841
- try { renameSync(USAGE_SUMMARIES_FILE, USAGE_SUMMARIES_BACKUP_FILE); } catch (err) { /* 下面的替换仍是原子操作 */ }
3842
- }
3843
- renameSync(tmp, USAGE_SUMMARIES_FILE);
3780
+ atomicReplaceFile(USAGE_SUMMARIES_FILE, JSON.stringify(payload), { tmpPrefix: USAGE_SUMMARIES_TEMP_PREFIX, backupPath: USAGE_SUMMARIES_BACKUP_FILE });
3844
3781
  }
3845
3782
 
3846
3783
  // ---------- 折叠:超窗 priced 明细 → 冷归档 + 冻结天桶/会话/账户增量 ----------
@@ -4001,9 +3938,7 @@ export default {
4001
3938
  } catch (err) { /* 撕裂行保守保留 */ }
4002
3939
  kept.push(line);
4003
3940
  });
4004
- const tmp = USAGE_JOURNAL_FILE + '.compact.' + process.pid + '.' + randomUUID();
4005
- writeAndSync(tmp, kept.length > 0 ? kept.join('\n') + '\n' : '', 'w');
4006
- renameSync(tmp, USAGE_JOURNAL_FILE);
3941
+ atomicReplaceFile(USAGE_JOURNAL_FILE, kept.length > 0 ? kept.join('\n') + '\n' : '', { tmpPrefix: USAGE_JOURNAL_FILE + '.compact.' });
4007
3942
  journalLineCount = kept.length;
4008
3943
  journalByteCount = Buffer.byteLength(kept.length > 0 ? kept.join('\n') + '\n' : '', 'utf8');
4009
3944
  perfCounters.journalCompactions += 1;
@@ -4047,20 +3982,13 @@ export default {
4047
3982
  return count;
4048
3983
  }
4049
3984
 
3985
+ // 账本域的写满 + fsync:短写翻译报错,其余原样抛(与 settings 的 writeFileAtomic 同策略)。
4050
3986
  function writeAndSync(filePath, content, flags) {
4051
- let fd = null;
4052
3987
  try {
4053
- fd = openSync(filePath, flags, 0o600);
4054
- const bytes = Buffer.from(content);
4055
- let offset = 0;
4056
- while (offset < bytes.length) {
4057
- const written = writeSync(fd, bytes, offset, bytes.length - offset);
4058
- if (!written) throw new Error(t('host.spendLedgerCouldNotBe'));
4059
- offset += written;
4060
- }
4061
- fsyncSync(fd);
4062
- } finally {
4063
- if (fd !== null) closeSync(fd);
3988
+ writeFullySync(filePath, content, flags);
3989
+ } catch (err) {
3990
+ if (err && err.code === 'BIB_SHORT_WRITE') throw new Error(t('host.spendLedgerCouldNotBe'));
3991
+ throw err;
4064
3992
  }
4065
3993
  }
4066
3994
 
@@ -4094,12 +4022,7 @@ export default {
4094
4022
  // The append-only journal has already made each record durable. This
4095
4023
  // snapshot is a recovery/cache layer, so an overlapping older process
4096
4024
  // can no longer erase newer records by writing stale memory here.
4097
- const tmp = DATA_TEMP_PREFIX + process.pid + '.' + randomUUID();
4098
- writeAndSync(tmp, JSON.stringify(usageRecords), 'w');
4099
- if (existsSync(DATA_FILE)) {
4100
- try { renameSync(DATA_FILE, DATA_BACKUP_FILE); } catch (err) { /* replacement below is still atomic */ }
4101
- }
4102
- renameSync(tmp, DATA_FILE);
4025
+ atomicReplaceFile(DATA_FILE, JSON.stringify(usageRecords), { tmpPrefix: DATA_TEMP_PREFIX, backupPath: DATA_BACKUP_FILE, ensureDir: DATA_DIR });
4103
4026
  dirty = false; // 写盘成功后才清除脏标记:失败时保留,卸载冲刷可重试
4104
4027
  if (ledgerError && ledgerError.kind === 'snapshot-stale' && !summariesDirty) ledgerError = null;
4105
4028
  maybeCompactJournal();
@@ -4479,7 +4402,7 @@ export default {
4479
4402
  const sel = selection || modelSelection();
4480
4403
  if (!selectionIsResolved(sel)) return null;
4481
4404
  const activeAccount = accountForProvider(sel.provider);
4482
- const d = new Date(nowMs + 8 * 3600 * 1000);
4405
+ const d = new Date(nowMs + BEIJING_OFFSET_MS);
4483
4406
  const key = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0');
4484
4407
  const cur = activeCurrency(sel);
4485
4408
  if (!cur) return null;
@@ -27,8 +27,9 @@ import { createHash } from 'node:crypto'
27
27
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
28
28
  import { gunzipSync } from 'node:zlib'
29
29
 
30
- export const PACKAGE_NAME = 'dsh-bottom-info-bar'
31
- export const UPDATE_REGISTRY_ORIGIN = 'https://registry.npmjs.org'
30
+ import { PACKAGE_NAME, UPDATE_REGISTRY_ORIGIN, compareSemver, parseSemver } from './version.js'
31
+ // 下游(测试)从本模块取版本 API:名字不变,转交 version.js(唯一的定义处)。
32
+ export { PACKAGE_NAME, compareSemver, parseSemver }
32
33
 
33
34
  // 允许被自更新替换的路径:正好是 package.json 的 files 字段声明的分发内容。
34
35
  // 白名单而非黑名单——新增一种分发内容时必须显式加进来,不允许"顺手"写到别处。
@@ -87,21 +88,7 @@ export function describeUpdateError(value) {
87
88
  return 'unknown'
88
89
  }
89
90
 
90
- // 语义化版本比较:只接受 x.y.z(可带 v 前缀),返回 [major, minor, patch] 或 null。
91
- export function parseSemver(value) {
92
- const match = typeof value === 'string' && value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/)
93
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
94
- }
95
-
96
- export function compareSemver(left, right) {
97
- const a = parseSemver(left)
98
- const b = parseSemver(right)
99
- if (!a || !b) return 0
100
- for (let i = 0; i < 3; i++) {
101
- if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1
102
- }
103
- return 0
104
- }
91
+ // parseSemver / compareSemver 见 version.js(唯一的定义处,本模块顶部转交)。
105
92
 
106
93
  // ---------- tar 解析 ----------
107
94
  // npm 的 tarball 是 gzip + ustar(长路径用 pax 扩展头)。只为解包自己的包服务,
package/lib/version.js ADDED
@@ -0,0 +1,40 @@
1
+ // Bottom Info Bar — 版本身份与语义化比较:唯一的定义处。
2
+ // host(读包版本、检查更新)与自更新引擎(比版本、拼 registry 地址)共用这一份;
3
+ // 以前两边各抄一份同正则,真改比较规则时必然漏改一边。
4
+ // 注意:本文件与 host.js / self-update.js 同在 src/(构建后同在 lib/),
5
+ // 所以 '../package.json' 在两边解析到同一个包根,PACKAGE_FILE/DIR 放在这里是安全的。
6
+ import { readFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ export const PACKAGE_NAME = 'dsh-bottom-info-bar'
11
+ export const UPDATE_REGISTRY_ORIGIN = 'https://registry.npmjs.org'
12
+ export const UPDATE_REGISTRY_URL = UPDATE_REGISTRY_ORIGIN + '/' + PACKAGE_NAME + '/latest'
13
+ export const PACKAGE_FILE = new URL('../package.json', import.meta.url)
14
+ export const PACKAGE_DIR = dirname(fileURLToPath(PACKAGE_FILE))
15
+
16
+ // 运行中代码的版本号:读包内 package.json(构建产物与源码同包,路径同构)。
17
+ export function packageVersion() {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(PACKAGE_FILE, 'utf8'))
20
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0'
21
+ } catch {
22
+ return '0.0.0'
23
+ }
24
+ }
25
+
26
+ // 语义化版本比较:只接受 x.y.z(可带 v 前缀),返回 [major, minor, patch] 或 null。
27
+ export function parseSemver(value) {
28
+ const match = typeof value === 'string' && value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/)
29
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
30
+ }
31
+
32
+ export function compareSemver(left, right) {
33
+ const a = parseSemver(left)
34
+ const b = parseSemver(right)
35
+ if (!a || !b) return 0
36
+ for (let i = 0; i < 3; i++) {
37
+ if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1
38
+ }
39
+ return 0
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bottom-info-bar",
3
- "version": "1.20.2",
3
+ "version": "1.20.4",
4
4
  "description": "Shows the current model, balance and spend under the composer.",
5
5
  "keywords": [
6
6
  "deepseek",