dsh-bottom-info-bar 1.20.2 → 1.20.3
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 +40 -28
- package/lib/constants.js +55 -0
- package/lib/index.js +221 -324
- package/lib/self-update.js +4 -17
- package/lib/version.js +40 -0
- package/package.json +1 -1
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
|
-
//
|
|
512
|
-
|
|
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
|
|
515
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
3267
|
-
return
|
|
3279
|
+
const meta = windowMeta(window.key);
|
|
3280
|
+
return meta ? t(meta.labelKey) : window.label;
|
|
3268
3281
|
}
|
|
3269
3282
|
function compactWindowLabel(key) {
|
|
3270
|
-
|
|
3271
|
-
if (
|
|
3272
|
-
if (
|
|
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
|
|
3657
|
-
const
|
|
3658
|
-
return
|
|
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(
|
|
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,9 +11,11 @@ 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
|
-
|
|
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'
|
|
@@ -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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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,这是最合理的兜底。
|
|
@@ -356,25 +336,12 @@ const COMMAND_CODE_PLAN_NAMES = {
|
|
|
356
336
|
|
|
357
337
|
// ---------- 服务商账户映射(v1.6 分账核心):DSH provider id → 账户键;未知返回 null ----------
|
|
358
338
|
// v1.7:新增 xiaomi(按量)、xiaomi-token-plan-*(套餐)、together / fireworks / amazon-bedrock / cloudflare-*(云账单)
|
|
339
|
+
// 三条映射只查 PROVIDER_IDENTITY(定义见 constants.js):新增服务商加表行即可,
|
|
340
|
+
// 再也不用在三条链里各写一遍。minimax 两站共用同一账户记账、订阅源各自独立——差异在表里,不在代码里。
|
|
359
341
|
function accountForProvider(pid) {
|
|
360
342
|
if (!pid) return null
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
343
|
+
const meta = Object.hasOwn(PROVIDER_IDENTITY, pid) ? PROVIDER_IDENTITY[pid] : null
|
|
344
|
+
return meta ? meta.account : null
|
|
378
345
|
}
|
|
379
346
|
|
|
380
347
|
// ---------- 余额来源映射(与 accountForProvider 是两条独立的轴,严禁合并) ----------
|
|
@@ -397,25 +364,14 @@ const PROVIDER_REPORTED_CURRENCY = { openrouter: 'USD' }
|
|
|
397
364
|
// v1.7:小米 Token Plan 按地区分源(各地区独立 baseUrl 与凭据,避免跨地区串数据)
|
|
398
365
|
// v1.16.0:MiniMax 双站点各自独立成源(对齐 xiaomi 地区隔离先例,快照/退避/并发去重互不串)
|
|
399
366
|
function subscriptionSourceFor(providerId) {
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
367
|
+
const meta = providerId && Object.hasOwn(PROVIDER_IDENTITY, providerId) ? PROVIDER_IDENTITY[providerId] : null
|
|
368
|
+
return meta ? meta.subscription : null
|
|
410
369
|
}
|
|
411
370
|
|
|
412
371
|
// 云账单 provider → 账单源标识(together / fireworks / amazon-bedrock / cloudflare);非账单型 → null(FR-14)
|
|
413
372
|
function billingSourceFor(providerId) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (providerId === 'amazon-bedrock') return 'amazon-bedrock'
|
|
417
|
-
if (providerId === 'cloudflare-ai-gateway' || providerId === 'cloudflare-workers-ai') return 'cloudflare'
|
|
418
|
-
return null
|
|
373
|
+
const meta = providerId && Object.hasOwn(PROVIDER_IDENTITY, providerId) ? PROVIDER_IDENTITY[providerId] : null
|
|
374
|
+
return meta ? meta.billing : null
|
|
419
375
|
}
|
|
420
376
|
|
|
421
377
|
// ---------- M5:DSH 目录名 → 展示名(模型名/服务商名与模型切换器完全一致) ----------
|
|
@@ -606,14 +562,10 @@ function minimaxBaseUrl(providerId) {
|
|
|
606
562
|
return providerId === 'minimax-cn' ? 'https://api.minimaxi.com' : 'https://api.minimax.io'
|
|
607
563
|
}
|
|
608
564
|
|
|
609
|
-
// 取条目里的数值字段(容忍数字型字符串;负数/缺失/非法一律 null
|
|
565
|
+
// 取条目里的数值字段(容忍数字型字符串;负数/缺失/非法一律 null)。
|
|
566
|
+
// 与 parseFiniteNonNegativeAmount 同口径,名字保留(测试按契约引用),实现只调一份。
|
|
610
567
|
function minimaxNumericField(value) {
|
|
611
|
-
|
|
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
|
|
568
|
+
return parseFiniteNonNegativeAmount(value)
|
|
617
569
|
}
|
|
618
570
|
|
|
619
571
|
// 剩余百分比 → [0,100] 整数;缺失/非法/负数 → null(该条目跳过)
|
|
@@ -697,17 +649,77 @@ function parseMinimaxTokenPlanRemains(body, windowLabels) {
|
|
|
697
649
|
}
|
|
698
650
|
|
|
699
651
|
// 快照更新规则("失败保留旧快照"的纯函数形态):失败保留旧 data/fetchedAt 只换 error;成功换 data 并更新 fetchedAt
|
|
700
|
-
|
|
652
|
+
// 快照合并(订阅/账单共用):失败保留旧 data/fetchedAt、只换 error;成功替换 data 并刷新时间。
|
|
653
|
+
// fallbackCode 区分两条通道的兜底错误(旧快照与新错误都没有时的最后一道,平时走不到)。
|
|
654
|
+
function mergeSnapshotResult(prev, result, translate, fallbackCode) {
|
|
701
655
|
if (translate === undefined) translate = t
|
|
702
656
|
if (!result || result.error) {
|
|
703
657
|
return {
|
|
704
658
|
data: prev && prev.data ? prev.data : null,
|
|
705
659
|
fetchedAt: prev && prev.fetchedAt ? prev.fetchedAt : null,
|
|
706
|
-
error: result ? result.error : { kind: 'exception', code:
|
|
660
|
+
error: result ? result.error : { kind: 'exception', code: fallbackCode, message: translate('error.' + fallbackCode) },
|
|
707
661
|
}
|
|
708
662
|
}
|
|
709
663
|
return { data: result.data || null, fetchedAt: Date.now(), error: null }
|
|
710
664
|
}
|
|
665
|
+
// 对外名字保留(测试按名抽取快照语义、check-host 按名断言存在):行为与原来逐字一致。
|
|
666
|
+
function mergeSubscriptionResult(prev, result, translate) {
|
|
667
|
+
return mergeSnapshotResult(prev, result, translate, 'subscription.request-failed')
|
|
668
|
+
}
|
|
669
|
+
// 快照引擎(订阅/账单共用):并发去重 + seq 防旧覆盖 + 失败退避 + 新鲜度判定。
|
|
670
|
+
// 数据投影(out 对象形状)两边完全不同,留在各自 RPC 里手写;这里只收机械部分。
|
|
671
|
+
// merge(snap, result, translate) 由调用方注入;fetch 统一按 src.fetch(arg) 调用,
|
|
672
|
+
// 不需要路由参数的源忽略 arg(与原来 kickBillingRefresh 不传参等价)。
|
|
673
|
+
function createSnapshotEngine(sources, options) {
|
|
674
|
+
const snapshots = Object.create(null)
|
|
675
|
+
const seq = Object.create(null)
|
|
676
|
+
const inFlight = Object.create(null)
|
|
677
|
+
const requested = Object.create(null)
|
|
678
|
+
const lastFailAt = Object.create(null)
|
|
679
|
+
const merge = options.merge
|
|
680
|
+
const normalize = options.normalize || null
|
|
681
|
+
const translate = options.translate
|
|
682
|
+
function kick(key, arg) {
|
|
683
|
+
const src = sources[key]
|
|
684
|
+
if (!src) return Promise.resolve()
|
|
685
|
+
if (inFlight[key]) return inFlight[key]
|
|
686
|
+
const n = (seq[key] || 0) + 1
|
|
687
|
+
seq[key] = n
|
|
688
|
+
inFlight[key] = src.fetch(arg).then(function (result) {
|
|
689
|
+
if (seq[key] === n) {
|
|
690
|
+
if (normalize && result && result.data) result.data = normalize(result.data)
|
|
691
|
+
snapshots[key] = merge(snapshots[key], result, translate)
|
|
692
|
+
lastFailAt[key] = result && result.error ? Date.now() : 0
|
|
693
|
+
}
|
|
694
|
+
}).catch(function (err) {
|
|
695
|
+
if (seq[key] === n) {
|
|
696
|
+
snapshots[key] = merge(snapshots[key], { error: { kind: 'exception', message: String((err && err.message) || err) } }, translate)
|
|
697
|
+
lastFailAt[key] = Date.now()
|
|
698
|
+
}
|
|
699
|
+
}).finally(function () {
|
|
700
|
+
inFlight[key] = null
|
|
701
|
+
})
|
|
702
|
+
return inFlight[key]
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
kick: kick,
|
|
706
|
+
markRequested: function (key) { requested[key] = true },
|
|
707
|
+
get: function (key) { return snapshots[key] || { data: null, fetchedAt: null, error: null } },
|
|
708
|
+
lastFailAt: function (key) { return lastFailAt[key] || 0 },
|
|
709
|
+
isStale: function (key, refreshMs, backoffMs, nowMs) {
|
|
710
|
+
const snap = snapshots[key] || { fetchedAt: null }
|
|
711
|
+
return (!snap.fetchedAt || (nowMs - snap.fetchedAt) > refreshMs) && (nowMs - (lastFailAt[key] || 0)) >= backoffMs
|
|
712
|
+
},
|
|
713
|
+
refreshActive: function (refreshMs, backoffMs, argForKey) {
|
|
714
|
+
const nowMs = Date.now()
|
|
715
|
+
for (const key in sources) {
|
|
716
|
+
if (!requested[key]) continue
|
|
717
|
+
if (nowMs - (lastFailAt[key] || 0) < backoffMs) continue
|
|
718
|
+
kick(key, argForKey ? argForKey(key) : undefined)
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
}
|
|
711
723
|
|
|
712
724
|
// 读 auth.json:{ ok:true, auth } 或 { ok:false, reason:'missing'|'corrupt' }(缺失/损坏一律不抛异常)
|
|
713
725
|
function readCodexAuthFile(filePath) {
|
|
@@ -928,7 +940,9 @@ const ZAI_LIMIT_WINDOWS = {
|
|
|
928
940
|
'6:1': 'seven_day', // 6=周 × 1 → 每周窗口
|
|
929
941
|
}
|
|
930
942
|
|
|
931
|
-
// 取 limit 里的数值字段(容忍数字型字符串,如 "5";其余返回 null
|
|
943
|
+
// 取 limit 里的数值字段(容忍数字型字符串,如 "5";其余返回 null)。
|
|
944
|
+
// 注意与 parseFiniteNonNegativeAmount 的区别:limit 语义允许负数哨兵值,这里只要求有限,
|
|
945
|
+
// 不另做 >= 0 截断——两处不可合并,合了会把上游的负数语义吞掉。
|
|
932
946
|
function zaiLimitNumber(value) {
|
|
933
947
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
934
948
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
@@ -1318,6 +1332,24 @@ function usageRecordKey(record, index, source) {
|
|
|
1318
1332
|
// the key so upgrading never merges two legitimate, identical requests.
|
|
1319
1333
|
return 'legacy:' + source + ':' + index + ':' + record.ts + ':' + record.provider + ':' + record.model + ':' + record.sessionId
|
|
1320
1334
|
}
|
|
1335
|
+
// 校验 → 归一化 → 去重 → 收集,一处实现(三处调用:快照+流水加载、冷归档读取、导出聚合)。
|
|
1336
|
+
// 去重键由 usageRecordKey 派生,调用方只给 (record, index, source),source 区分数据来源。
|
|
1337
|
+
function createRecordCollector() {
|
|
1338
|
+
const records = []
|
|
1339
|
+
const seen = new Set()
|
|
1340
|
+
return {
|
|
1341
|
+
records: records,
|
|
1342
|
+
add: function (record, index, source) {
|
|
1343
|
+
if (!isValidUsageRecord(record)) return false
|
|
1344
|
+
const normalized = normalizeUsageRecord(record, index, source)
|
|
1345
|
+
const key = usageRecordKey(normalized, index, source)
|
|
1346
|
+
if (seen.has(key)) return false
|
|
1347
|
+
seen.add(key)
|
|
1348
|
+
records.push(normalized)
|
|
1349
|
+
return true
|
|
1350
|
+
},
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1321
1353
|
|
|
1322
1354
|
// 账单数据清理只允许触达插件自己创建的精确文件/目录。临时文件也按固定前缀
|
|
1323
1355
|
// 收集,避免留下半次原子写;settings.json、pricing-cache.json 等非账单数据永不触达。
|
|
@@ -1398,19 +1430,15 @@ function loadUsageRecords() {
|
|
|
1398
1430
|
const loaded = readUsageSnapshot(candidates[i])
|
|
1399
1431
|
if (loaded) { snapshot = loaded; break }
|
|
1400
1432
|
}
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1433
|
+
const collector = createRecordCollector()
|
|
1434
|
+
const records = collector.records
|
|
1403
1435
|
let migratedLegacyRecord = false
|
|
1404
1436
|
// journal 行数/字节数基线:滚动压缩只看阈值是否超限,不参与记账正确性
|
|
1405
1437
|
let journalLines = 0
|
|
1406
1438
|
function add(record, index, source) {
|
|
1407
1439
|
if (!isValidUsageRecord(record)) return
|
|
1408
1440
|
if (typeof record.id !== 'string' || record.id.length === 0) migratedLegacyRecord = true
|
|
1409
|
-
|
|
1410
|
-
const key = usageRecordKey(normalized, index, source)
|
|
1411
|
-
if (seen.has(key)) return
|
|
1412
|
-
seen.add(key)
|
|
1413
|
-
records.push(normalized)
|
|
1441
|
+
collector.add(record, index, source)
|
|
1414
1442
|
}
|
|
1415
1443
|
snapshot.forEach(function (record, index) { add(record, index, 'snapshot') })
|
|
1416
1444
|
// A torn journal line only loses that one unfinished append; all preceding
|
|
@@ -1435,8 +1463,8 @@ function loadUsageRecords() {
|
|
|
1435
1463
|
}
|
|
1436
1464
|
|
|
1437
1465
|
function readArchivedUsageRecords() {
|
|
1438
|
-
const
|
|
1439
|
-
const
|
|
1466
|
+
const collector = createRecordCollector()
|
|
1467
|
+
const records = collector.records
|
|
1440
1468
|
try {
|
|
1441
1469
|
if (!existsSync(USAGE_ARCHIVE_DIR)) return { records: records, error: null }
|
|
1442
1470
|
const files = readdirSync(USAGE_ARCHIVE_DIR).filter(function (name) { return name.endsWith('.jsonl') }).sort()
|
|
@@ -1446,13 +1474,7 @@ function readArchivedUsageRecords() {
|
|
|
1446
1474
|
raw.split('\n').forEach(function (line, index) {
|
|
1447
1475
|
if (!line.trim()) return
|
|
1448
1476
|
try {
|
|
1449
|
-
|
|
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)
|
|
1477
|
+
collector.add(JSON.parse(line), index, 'archive:' + name)
|
|
1456
1478
|
} catch (err) { /* skip only a corrupt archive line */ }
|
|
1457
1479
|
})
|
|
1458
1480
|
}
|
|
@@ -1618,27 +1640,51 @@ function loadSettingsFromDisk() {
|
|
|
1618
1640
|
}
|
|
1619
1641
|
}
|
|
1620
1642
|
|
|
1621
|
-
//
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
const tmp = filePath + '.tmp.' + process.pid + '.' + randomUUID()
|
|
1643
|
+
// 写满 + fsync 的底层原语(短写抛调用方给定的消息,其余错误原样抛)。
|
|
1644
|
+
// 上层分两个耐久档:atomicReplaceFile(tmp + rename,必须不能丢)与直接调用(journal 追加的
|
|
1645
|
+
// O_APPEND 语义不能经 tmp;自更新状态/日志是 best-effort,走自己的 try/catch,不合进来)。
|
|
1646
|
+
function writeFullySync(filePath, content, flags) {
|
|
1626
1647
|
let fd = null
|
|
1627
1648
|
try {
|
|
1628
|
-
fd = openSync(
|
|
1649
|
+
fd = openSync(filePath, flags, 0o600)
|
|
1629
1650
|
const bytes = Buffer.from(content)
|
|
1630
1651
|
let offset = 0
|
|
1631
1652
|
while (offset < bytes.length) {
|
|
1632
1653
|
const written = writeSync(fd, bytes, offset, bytes.length - offset)
|
|
1633
|
-
if (!written)
|
|
1654
|
+
if (!written) {
|
|
1655
|
+
const err = new Error('short write to ' + filePath)
|
|
1656
|
+
err.code = 'BIB_SHORT_WRITE'
|
|
1657
|
+
throw err
|
|
1658
|
+
}
|
|
1634
1659
|
offset += written
|
|
1635
1660
|
}
|
|
1636
1661
|
fsyncSync(fd)
|
|
1637
1662
|
} finally {
|
|
1638
1663
|
if (fd !== null) closeSync(fd)
|
|
1639
1664
|
}
|
|
1665
|
+
}
|
|
1666
|
+
// 原子替换写:tmp 写满 + fsync 后 rename 覆盖(避免半截文件),可选先把旧文件转成备份。
|
|
1667
|
+
function atomicReplaceFile(filePath, content, options) {
|
|
1668
|
+
const opts = options || {}
|
|
1669
|
+
if (opts.ensureDir) mkdirSync(opts.ensureDir, { recursive: true, mode: 0o700 })
|
|
1670
|
+
const tmp = (opts.tmpPrefix || filePath + '.tmp.') + process.pid + '.' + randomUUID()
|
|
1671
|
+
writeFullySync(tmp, content, 'w')
|
|
1672
|
+
if (opts.backupPath && existsSync(filePath)) {
|
|
1673
|
+
try { renameSync(filePath, opts.backupPath) } catch (err) { /* 下面的替换仍是原子操作 */ }
|
|
1674
|
+
}
|
|
1640
1675
|
renameSync(tmp, filePath)
|
|
1641
1676
|
}
|
|
1677
|
+
// settings 落盘:目录确保 + 短写时翻译报错(open/rename 失败沿用原文,调用方按 code 归一)。
|
|
1678
|
+
function writeFileAtomic(filePath, content, translate) {
|
|
1679
|
+
if (translate === undefined) translate = t
|
|
1680
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
|
|
1681
|
+
try {
|
|
1682
|
+
atomicReplaceFile(filePath, content, {})
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
if (err && err.code === 'BIB_SHORT_WRITE') throw new Error(translate('host.settingsFileCouldNotBe'))
|
|
1685
|
+
throw err
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1642
1688
|
|
|
1643
1689
|
// L1(安全审计低危建议):启动时尽力而为收敛敏感文件权限——账本目录 0700、流水账 journal 0600。
|
|
1644
1690
|
// 只做 try/catch 包裹的 chmod:权限收敛失败(如非 POSIX 文件系统/只读挂载)绝不影响启动与记账。
|
|
@@ -1770,11 +1816,11 @@ export default {
|
|
|
1770
1816
|
// 方向由 host 判定(客户端不做版本比较):'update' 说明磁盘已是新版、'rollback' 说明用户
|
|
1771
1817
|
// 回滚过、磁盘比运行版本旧,两者都只有重启才生效,但文案要分开。
|
|
1772
1818
|
// 判据是「两者不等」而不是「disk 更高」:回滚之后 disk 低于 running,同样只有重启才生效。
|
|
1773
|
-
const delta =
|
|
1819
|
+
const delta = compareSemver(current, state.runningVersion)
|
|
1774
1820
|
return Object.assign({}, state, {
|
|
1775
1821
|
latest: latest,
|
|
1776
1822
|
diskVersion: current,
|
|
1777
|
-
available: !!latest &&
|
|
1823
|
+
available: !!latest && compareSemver(latest, current) > 0,
|
|
1778
1824
|
pendingRestart: delta !== 0,
|
|
1779
1825
|
restartDirection: delta > 0 ? 'update' : (delta < 0 ? 'rollback' : null),
|
|
1780
1826
|
})
|
|
@@ -2061,41 +2107,32 @@ export default {
|
|
|
2061
2107
|
}
|
|
2062
2108
|
}
|
|
2063
2109
|
|
|
2110
|
+
// balance_infos 列表解析(DeepSeek / Kimi 同构:优先可用的 CNY,再退 USD;
|
|
2111
|
+
// granted/toppedUp 缺失按 0 计)。两家 API 返回同一 envelope,各写一遍只会让修解析时漏改一家。
|
|
2112
|
+
function parseBalanceInfos(body) {
|
|
2113
|
+
const list = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
|
|
2114
|
+
let rec = null;
|
|
2115
|
+
for (const currency of ['CNY', 'USD']) {
|
|
2116
|
+
for (let i = 0; i < list.length; i++) {
|
|
2117
|
+
if (list[i] && list[i].currency === currency && parseFiniteNonNegativeAmount(list[i].total_balance) != null) { rec = list[i]; break; }
|
|
2118
|
+
}
|
|
2119
|
+
if (rec) break;
|
|
2120
|
+
}
|
|
2121
|
+
if (!rec) return null;
|
|
2122
|
+
const total = parseFiniteNonNegativeAmount(rec.total_balance);
|
|
2123
|
+
if (total == null) return null;
|
|
2124
|
+
const granted = parseFiniteNonNegativeAmount(rec.granted_balance);
|
|
2125
|
+
const toppedUp = parseFiniteNonNegativeAmount(rec.topped_up_balance);
|
|
2126
|
+
return { currency: rec.currency, total: total, granted: granted == null ? 0 : granted, toppedUp: toppedUp == null ? 0 : toppedUp };
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2064
2129
|
// ---------- 服务商适配器(余额仅 DeepSeek 真实 API;OpenAI 为记账回退估算) ----------
|
|
2065
2130
|
const PROVIDERS = {
|
|
2066
2131
|
deepseek: {
|
|
2067
2132
|
id: 'deepseek', displayName: 'DeepSeek', credential: 'DEEPSEEK_API_KEY',
|
|
2068
2133
|
balanceAPI: 'https://api.deepseek.com/user/balance',
|
|
2069
2134
|
estimate: false,
|
|
2070
|
-
parseBalance:
|
|
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
|
-
},
|
|
2135
|
+
parseBalance: parseBalanceInfos,
|
|
2099
2136
|
},
|
|
2100
2137
|
// DSH 桌面端内置 DeepSeek 账号:与上面 API Key 路是同一品牌的**两条通道**,账户键刻意分开 ——
|
|
2101
2138
|
// 两条路的凭据与失效原因完全不同(「没配 API Key」和「账号没登录」是两句不同的话),
|
|
@@ -2117,36 +2154,7 @@ export default {
|
|
|
2117
2154
|
id: 'moonshotai', displayName: 'Kimi', credential: 'MOONSHOT_API_KEY',
|
|
2118
2155
|
balanceAPI: 'https://api.moonshot.cn/v1/users/me/balance',
|
|
2119
2156
|
estimate: false,
|
|
2120
|
-
parseBalance:
|
|
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
|
-
},
|
|
2157
|
+
parseBalance: parseBalanceInfos,
|
|
2150
2158
|
},
|
|
2151
2159
|
// v1.6 T4:openrouter 余额适配器
|
|
2152
2160
|
openrouter: {
|
|
@@ -2374,12 +2382,8 @@ export default {
|
|
|
2374
2382
|
}
|
|
2375
2383
|
|
|
2376
2384
|
// ---------- 订阅额度快照(复用余额模式:周期刷新 / 失败保留旧快照 / seq 防旧覆盖) ----------
|
|
2377
|
-
|
|
2378
|
-
|
|
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 } 上次订阅刷新失败时刻(失败退避:期内不重试)
|
|
2385
|
+
const subscriptionSourceProvider = Object.create(null); // { [sourceKey]: providerId }——记住请求该源时的 provider(zai 系按 provider 路由 host/凭据),供引擎路由参数用
|
|
2386
|
+
// 快照状态(snapshots/seq/inFlight/requested/lastFailAt)收进下面的两个引擎实例,不再各写一套。
|
|
2383
2387
|
|
|
2384
2388
|
// FR-8 / D7:Codex / ChatGPT 订阅卡(纯本地通道)
|
|
2385
2389
|
// 只读令牌:令牌由独立插件 dsh-chatgpt-sub 维护,本插件不续期、不写回、不注入凭据;
|
|
@@ -2448,10 +2452,8 @@ export default {
|
|
|
2448
2452
|
// Command Code 凭据:DSH 设置中的标准名 → Provider API 文档兼容名 → 进程环境 → CLI 登录文件。
|
|
2449
2453
|
// ~/.commandcode/auth.json 只读 apiKey,绝不写回或把密钥放进错误信息。
|
|
2450
2454
|
async function resolveCommandCodeKey() {
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
if (configured) return configured
|
|
2454
|
-
}
|
|
2455
|
+
const configured = await resolveWithFallback(['COMMAND_CODE_API_KEY', 'CMD_API_KEY'])
|
|
2456
|
+
if (configured) return configured
|
|
2455
2457
|
for (const name of ['COMMAND_CODE_API_KEY', 'CMD_API_KEY']) {
|
|
2456
2458
|
if (typeof process.env[name] === 'string' && process.env[name].length > 0) return process.env[name]
|
|
2457
2459
|
}
|
|
@@ -2528,9 +2530,7 @@ export default {
|
|
|
2528
2530
|
const isCn = providerId === 'minimax-cn'
|
|
2529
2531
|
const primary = isCn ? 'MINIMAX_CN_API_KEY' : 'MINIMAX_API_KEY'
|
|
2530
2532
|
const fallback = isCn ? 'MINIMAX_API_KEY' : 'MINIMAX_CN_API_KEY'
|
|
2531
|
-
|
|
2532
|
-
if (primaryValue) return primaryValue
|
|
2533
|
-
return resolveCredentialValue(fallback)
|
|
2533
|
+
return resolveWithFallback([primary, fallback])
|
|
2534
2534
|
}
|
|
2535
2535
|
|
|
2536
2536
|
async function fetchMinimaxTokenPlanUsage(providerId) {
|
|
@@ -2700,6 +2700,14 @@ export default {
|
|
|
2700
2700
|
} catch (err) { /* 未配置/读取失败 → null */ }
|
|
2701
2701
|
return null;
|
|
2702
2702
|
}
|
|
2703
|
+
// 按优先级逐个试凭据名,首个命中即返回(无副作用的纯查找,顺序即优先级)。
|
|
2704
|
+
async function resolveWithFallback(names) {
|
|
2705
|
+
for (const name of names) {
|
|
2706
|
+
const value = await resolveCredentialValue(name);
|
|
2707
|
+
if (value) return value;
|
|
2708
|
+
}
|
|
2709
|
+
return null;
|
|
2710
|
+
}
|
|
2703
2711
|
|
|
2704
2712
|
// v1.7 FR-9:小米 MiMo Token Plan 订阅源(按地区路由 baseUrl + 凭据,地区互不串数据)
|
|
2705
2713
|
// 凭据:XIAOMI_TOKEN_PLAN_CN/SGP/AMS_API_KEY 按地区优先,回退 XIAOMI_API_KEY;Bearer。
|
|
@@ -2709,11 +2717,7 @@ export default {
|
|
|
2709
2717
|
}
|
|
2710
2718
|
async function resolveXiaomiRegionKey(region) {
|
|
2711
2719
|
const primary = xiaomiRegionKeyName(region);
|
|
2712
|
-
|
|
2713
|
-
const cred = await resolveCredentialValue(primary);
|
|
2714
|
-
if (cred) return cred;
|
|
2715
|
-
}
|
|
2716
|
-
return resolveCredentialValue('XIAOMI_API_KEY');
|
|
2720
|
+
return resolveWithFallback(primary ? [primary, 'XIAOMI_API_KEY'] : ['XIAOMI_API_KEY']);
|
|
2717
2721
|
}
|
|
2718
2722
|
|
|
2719
2723
|
async function fetchXiaomiTokenPlanUsage(region) {
|
|
@@ -2921,100 +2925,10 @@ export default {
|
|
|
2921
2925
|
'minimax-cn': { fetch: function () { return fetchMinimaxTokenPlanUsage('minimax-cn'); } },
|
|
2922
2926
|
};
|
|
2923
2927
|
|
|
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
2928
|
|
|
2963
2929
|
// ---------- v1.7 FR-10~13:账单快照(云账单型;与订阅/余额快照完全隔离,同一套策略) ----------
|
|
2964
2930
|
const BILLING_REFRESH_MS = 60000;
|
|
2965
2931
|
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
2932
|
|
|
3019
2933
|
// RPC:当前订阅额度快照 + 模式判定(非订阅模式直接返回,不发任何订阅请求)
|
|
3020
2934
|
async function getSubscriptionSnapshotRpc(selection, force) {
|
|
@@ -3027,24 +2941,21 @@ export default {
|
|
|
3027
2941
|
const sourceKey = subscriptionSourceFor(sel.provider);
|
|
3028
2942
|
if (!sourceKey) return out;
|
|
3029
2943
|
out.source = sourceKey;
|
|
3030
|
-
|
|
2944
|
+
subscriptionEngine.markRequested(sourceKey); // 该源进入 60s 周期刷新
|
|
3031
2945
|
subscriptionSourceProvider[sourceKey] = sel.provider; // 记住 provider(zai 系路由需要)
|
|
3032
|
-
const snap =
|
|
2946
|
+
const snap = subscriptionEngine.get(sourceKey);
|
|
3033
2947
|
const nowMs = Date.now();
|
|
3034
|
-
const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
|
|
3035
2948
|
// 失败退避:快照过期(>60s 无成功)且距上次失败 ≥ 退避期(60s)才重试——
|
|
3036
2949
|
// 减少重复订阅请求,也避免“刷新失败”提示随每次轮询反复闪烁(失败期内直接读缓存快照)
|
|
3037
|
-
const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > SUBSCRIPTION_REFRESH_MS)
|
|
3038
|
-
&& (nowMs - lastFailAt) >= SUBSCRIPTION_RETRY_BACKOFF_MS;
|
|
3039
2950
|
// force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查,
|
|
3040
2951
|
// 让用户手动刷新页面时立刻拿到最新额度/余额,而不是干等下一轮自动刷新
|
|
3041
|
-
if (force ||
|
|
3042
|
-
const inflight =
|
|
2952
|
+
if (force || subscriptionEngine.isStale(sourceKey, SUBSCRIPTION_REFRESH_MS, SUBSCRIPTION_RETRY_BACKOFF_MS, nowMs)) {
|
|
2953
|
+
const inflight = subscriptionEngine.kick(sourceKey, subscriptionSourceProvider[sourceKey]);
|
|
3043
2954
|
// 从未成功过(无旧数据)或强制刷新 → 等本次结果返回最新数据(含错误);
|
|
3044
2955
|
// 已有旧数据的普通刷新 → 后台刷新,本次直接返回快照(不阻塞轮询)
|
|
3045
2956
|
if (!snap.data || force) await inflight;
|
|
3046
2957
|
}
|
|
3047
|
-
const cur =
|
|
2958
|
+
const cur = subscriptionEngine.get(sourceKey);
|
|
3048
2959
|
if (cur.data) {
|
|
3049
2960
|
out.plan = cur.data.plan;
|
|
3050
2961
|
out.windows = cur.data.windows;
|
|
@@ -3066,6 +2977,22 @@ export default {
|
|
|
3066
2977
|
cloudflare: { fetch: fetchCloudflareBilling }, // cloudflare-ai-gateway / cloudflare-workers-ai 共用
|
|
3067
2978
|
};
|
|
3068
2979
|
|
|
2980
|
+
// 两套快照引擎实例:订阅制与账单制各一个,状态完全隔离(地区隔离、互不串扰的语义不变),
|
|
2981
|
+
// 机械部分(并发去重 / seq 防旧覆盖 / 失败退避 / 新鲜度判定)只在 createSnapshotEngine 里写一遍。
|
|
2982
|
+
const subscriptionEngine = createSnapshotEngine(SUBSCRIPTION_SOURCES, { merge: mergeSubscriptionResult, translate: t });
|
|
2983
|
+
const billingEngine = createSnapshotEngine(BILLING_SOURCES, {
|
|
2984
|
+
merge: function (prev, result) { return mergeSnapshotResult(prev, result, t, 'billing.request-failed'); },
|
|
2985
|
+
// FR-14:适配器原始输出统一经 normalizeAccountStatus 收敛到客户端契约
|
|
2986
|
+
normalize: function (data) { return normalizeAccountStatus(data.kind, data); },
|
|
2987
|
+
});
|
|
2988
|
+
// 60s 周期刷新:仅刷新客户端请求过的源(余额制模式下不打扰未公开的订阅接口);失败退避期内跳过
|
|
2989
|
+
function refreshActiveSubscriptions() {
|
|
2990
|
+
subscriptionEngine.refreshActive(SUBSCRIPTION_REFRESH_MS, SUBSCRIPTION_RETRY_BACKOFF_MS, function (key) { return subscriptionSourceProvider[key]; });
|
|
2991
|
+
}
|
|
2992
|
+
function refreshActiveBilling() {
|
|
2993
|
+
billingEngine.refreshActive(BILLING_REFRESH_MS, BILLING_RETRY_BACKOFF_MS);
|
|
2994
|
+
}
|
|
2995
|
+
|
|
3069
2996
|
async function getBillingSnapshotRpc(selection, force) {
|
|
3070
2997
|
const sel = selection || modelSelection();
|
|
3071
2998
|
const bm = selectionIsResolved(sel)
|
|
@@ -3076,18 +3003,15 @@ export default {
|
|
|
3076
3003
|
const key = billingSourceFor(sel.provider);
|
|
3077
3004
|
if (!key) return out;
|
|
3078
3005
|
out.type = key;
|
|
3079
|
-
|
|
3080
|
-
const snap =
|
|
3006
|
+
billingEngine.markRequested(key); // 该源进入 60s 周期刷新
|
|
3007
|
+
const snap = billingEngine.get(key);
|
|
3081
3008
|
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
3009
|
// force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查最新账单
|
|
3086
|
-
if (force ||
|
|
3087
|
-
const inflight =
|
|
3010
|
+
if (force || billingEngine.isStale(key, BILLING_REFRESH_MS, BILLING_RETRY_BACKOFF_MS, nowMs)) {
|
|
3011
|
+
const inflight = billingEngine.kick(key);
|
|
3088
3012
|
if (!snap.data || force) await inflight;
|
|
3089
3013
|
}
|
|
3090
|
-
const cur =
|
|
3014
|
+
const cur = billingEngine.get(key);
|
|
3091
3015
|
out.data = cur.data;
|
|
3092
3016
|
out.fetchedAt = cur.fetchedAt;
|
|
3093
3017
|
out.error = cur.error;
|
|
@@ -3099,24 +3023,24 @@ export default {
|
|
|
3099
3023
|
// 以生效时刻为界,保留此前周末请求原有的峰谷结算规则,供未冻结的旧记录回算使用。
|
|
3100
3024
|
const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.UTC(2026, 7, 22, 16, 0, 0);
|
|
3101
3025
|
function beijingMinutes(nowMs) {
|
|
3102
|
-
const d = new Date(nowMs +
|
|
3026
|
+
const d = new Date(nowMs + BEIJING_OFFSET_MS);
|
|
3103
3027
|
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
3104
3028
|
}
|
|
3105
3029
|
function currentPeriod(nowMs) {
|
|
3106
|
-
const d = new Date(nowMs +
|
|
3030
|
+
const d = new Date(nowMs + BEIJING_OFFSET_MS);
|
|
3107
3031
|
const weekend = d.getUTCDay() === 0 || d.getUTCDay() === 6;
|
|
3108
3032
|
if (nowMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && weekend) return 'offpeak';
|
|
3109
3033
|
const m = beijingMinutes(nowMs);
|
|
3110
3034
|
return (m >= 9 * 60 && m < 12 * 60) || (m >= 14 * 60 && m < 18 * 60) ? 'peak' : 'offpeak';
|
|
3111
3035
|
}
|
|
3112
3036
|
function nextSwitchAt(nowMs) {
|
|
3113
|
-
const d = new Date(nowMs +
|
|
3037
|
+
const d = new Date(nowMs + BEIJING_OFFSET_MS);
|
|
3114
3038
|
const bounds = [9, 12, 14, 18];
|
|
3115
3039
|
const period = currentPeriod(nowMs);
|
|
3116
3040
|
// 周末没有峰谷切换;从当前北京日期起查找下一个实际变价点(最长覆盖至下一周一)。
|
|
3117
3041
|
for (let day = 0; day <= 8; day++) {
|
|
3118
3042
|
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) -
|
|
3043
|
+
const at = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + day, bounds[i], 0, 0) - BEIJING_OFFSET_MS).getTime();
|
|
3120
3044
|
if (at > nowMs && currentPeriod(at) !== period) return at;
|
|
3121
3045
|
}
|
|
3122
3046
|
}
|
|
@@ -3125,13 +3049,13 @@ export default {
|
|
|
3125
3049
|
function nextPeriodLabel(nowMs) {
|
|
3126
3050
|
const at = nextSwitchAt(nowMs);
|
|
3127
3051
|
if (at == null) return null;
|
|
3128
|
-
const d = new Date(at +
|
|
3052
|
+
const d = new Date(at + BEIJING_OFFSET_MS);
|
|
3129
3053
|
const hh = String(d.getUTCHours()).padStart(2, '0');
|
|
3130
3054
|
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
|
3131
3055
|
return { at: at, atLabel: hh + ':' + mm, nextIsPeak: currentPeriod(at) === 'peak' };
|
|
3132
3056
|
}
|
|
3133
3057
|
function beijingDayKey(ts) {
|
|
3134
|
-
const d = new Date(ts +
|
|
3058
|
+
const d = new Date(ts + BEIJING_OFFSET_MS);
|
|
3135
3059
|
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
|
|
3136
3060
|
}
|
|
3137
3061
|
|
|
@@ -3554,18 +3478,10 @@ export default {
|
|
|
3554
3478
|
|
|
3555
3479
|
function allUsageRecordsForExport() {
|
|
3556
3480
|
const archived = readArchivedUsageRecords();
|
|
3557
|
-
const
|
|
3558
|
-
const
|
|
3559
|
-
function add(record, index,
|
|
3560
|
-
|
|
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') });
|
|
3481
|
+
const collector = createRecordCollector();
|
|
3482
|
+
const records = collector.records;
|
|
3483
|
+
usageRecords.forEach(function (record, index) { collector.add(record, index, 'current') });
|
|
3484
|
+
archived.records.forEach(function (record, index) { collector.add(record, index, 'archive-export') });
|
|
3569
3485
|
return {
|
|
3570
3486
|
records: records.sort(function (a, b) { return a.ts - b.ts }),
|
|
3571
3487
|
archiveReadError: archived.error,
|
|
@@ -3835,12 +3751,7 @@ export default {
|
|
|
3835
3751
|
foldedSessions: sessionsDelta,
|
|
3836
3752
|
foldedAccountTotals: accountTotals,
|
|
3837
3753
|
};
|
|
3838
|
-
|
|
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);
|
|
3754
|
+
atomicReplaceFile(USAGE_SUMMARIES_FILE, JSON.stringify(payload), { tmpPrefix: USAGE_SUMMARIES_TEMP_PREFIX, backupPath: USAGE_SUMMARIES_BACKUP_FILE });
|
|
3844
3755
|
}
|
|
3845
3756
|
|
|
3846
3757
|
// ---------- 折叠:超窗 priced 明细 → 冷归档 + 冻结天桶/会话/账户增量 ----------
|
|
@@ -4001,9 +3912,7 @@ export default {
|
|
|
4001
3912
|
} catch (err) { /* 撕裂行保守保留 */ }
|
|
4002
3913
|
kept.push(line);
|
|
4003
3914
|
});
|
|
4004
|
-
|
|
4005
|
-
writeAndSync(tmp, kept.length > 0 ? kept.join('\n') + '\n' : '', 'w');
|
|
4006
|
-
renameSync(tmp, USAGE_JOURNAL_FILE);
|
|
3915
|
+
atomicReplaceFile(USAGE_JOURNAL_FILE, kept.length > 0 ? kept.join('\n') + '\n' : '', { tmpPrefix: USAGE_JOURNAL_FILE + '.compact.' });
|
|
4007
3916
|
journalLineCount = kept.length;
|
|
4008
3917
|
journalByteCount = Buffer.byteLength(kept.length > 0 ? kept.join('\n') + '\n' : '', 'utf8');
|
|
4009
3918
|
perfCounters.journalCompactions += 1;
|
|
@@ -4047,20 +3956,13 @@ export default {
|
|
|
4047
3956
|
return count;
|
|
4048
3957
|
}
|
|
4049
3958
|
|
|
3959
|
+
// 账本域的写满 + fsync:短写翻译报错,其余原样抛(与 settings 的 writeFileAtomic 同策略)。
|
|
4050
3960
|
function writeAndSync(filePath, content, flags) {
|
|
4051
|
-
let fd = null;
|
|
4052
3961
|
try {
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
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);
|
|
3962
|
+
writeFullySync(filePath, content, flags);
|
|
3963
|
+
} catch (err) {
|
|
3964
|
+
if (err && err.code === 'BIB_SHORT_WRITE') throw new Error(t('host.spendLedgerCouldNotBe'));
|
|
3965
|
+
throw err;
|
|
4064
3966
|
}
|
|
4065
3967
|
}
|
|
4066
3968
|
|
|
@@ -4094,12 +3996,7 @@ export default {
|
|
|
4094
3996
|
// The append-only journal has already made each record durable. This
|
|
4095
3997
|
// snapshot is a recovery/cache layer, so an overlapping older process
|
|
4096
3998
|
// can no longer erase newer records by writing stale memory here.
|
|
4097
|
-
|
|
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);
|
|
3999
|
+
atomicReplaceFile(DATA_FILE, JSON.stringify(usageRecords), { tmpPrefix: DATA_TEMP_PREFIX, backupPath: DATA_BACKUP_FILE, ensureDir: DATA_DIR });
|
|
4103
4000
|
dirty = false; // 写盘成功后才清除脏标记:失败时保留,卸载冲刷可重试
|
|
4104
4001
|
if (ledgerError && ledgerError.kind === 'snapshot-stale' && !summariesDirty) ledgerError = null;
|
|
4105
4002
|
maybeCompactJournal();
|
|
@@ -4479,7 +4376,7 @@ export default {
|
|
|
4479
4376
|
const sel = selection || modelSelection();
|
|
4480
4377
|
if (!selectionIsResolved(sel)) return null;
|
|
4481
4378
|
const activeAccount = accountForProvider(sel.provider);
|
|
4482
|
-
const d = new Date(nowMs +
|
|
4379
|
+
const d = new Date(nowMs + BEIJING_OFFSET_MS);
|
|
4483
4380
|
const key = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
4484
4381
|
const cur = activeCurrency(sel);
|
|
4485
4382
|
if (!cur) return null;
|
package/lib/self-update.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|