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 +40 -28
- package/lib/constants.js +55 -0
- package/lib/index.js +258 -335
- 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,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
|
-
|
|
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(
|
|
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
|
-
|
|
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,这是最合理的兜底。
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1402
|
-
const
|
|
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
|
-
|
|
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
|
|
1439
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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(
|
|
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)
|
|
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 =
|
|
1843
|
+
const delta = compareSemver(current, state.runningVersion)
|
|
1774
1844
|
return Object.assign({}, state, {
|
|
1775
1845
|
latest: latest,
|
|
1776
1846
|
diskVersion: current,
|
|
1777
|
-
available: !!latest &&
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 } 上次订阅刷新失败时刻(失败退避:期内不重试)
|
|
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
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
const
|
|
2419
|
-
|
|
2420
|
-
if (typeof entry
|
|
2421
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
2452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2970
|
+
subscriptionEngine.markRequested(sourceKey); // 该源进入 60s 周期刷新
|
|
3031
2971
|
subscriptionSourceProvider[sourceKey] = sel.provider; // 记住 provider(zai 系路由需要)
|
|
3032
|
-
const snap =
|
|
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 ||
|
|
3042
|
-
const inflight =
|
|
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 =
|
|
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
|
-
|
|
3080
|
-
const snap =
|
|
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 ||
|
|
3087
|
-
const inflight =
|
|
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 =
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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) -
|
|
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 +
|
|
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 +
|
|
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
|
|
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') });
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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 +
|
|
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;
|
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
|
+
}
|