dsh-desktop-statusbar 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -23,6 +23,17 @@ window.__ModuleLoader__.load({
23
23
 
24
24
  const react = require("react");
25
25
  const h = react.createElement;
26
+ /* 跟手浮层要 portal 到 body(见 dragCard 处的长注释)。react-dom 由 DSH 的客户端
27
+ 模块加载器提供(与 react 同一个表);取不到就退回就地渲染,功能不受影响,
28
+ 只是那层玻璃在场时位置会偏 —— 所以这里绝不因为缺它而抛错。 */
29
+ const reactDom = (() => {
30
+ try {
31
+ return require("react-dom");
32
+ } catch (error) {
33
+ return null;
34
+ }
35
+ })();
36
+ const canPortal = reactDom !== null && reactDom !== undefined && typeof reactDom.createPortal === "function";
26
37
 
27
38
  const NS = "dsh-desktop-statusbar";
28
39
  const STYLE_TAG_ID = "dsh-desktop-statusbar/styles";
@@ -39,13 +50,25 @@ window.__ModuleLoader__.load({
39
50
  const PRICES_URL = "/dsh-desktop-statusbar/api/prices";
40
51
  const HOLIDAYS_URL = "/dsh-desktop-statusbar/api/holidays";
41
52
  const VERSION_URL = "/dsh-desktop-statusbar/api/version";
53
+ /** 一键更新:由本插件自己的 host 端执行(不依赖任何别的插件,见 self-update 说明)。 */
54
+ const SELF_UPDATE_URL = "/dsh-desktop-statusbar/api/self-update";
55
+ /* 子智能体(Agent Teams 的队友)用量的聚合端点:队友是**独立会话**,它的消耗不在本会话
56
+ 的投影里,只能由 host 按 parentSession 找出来,「总计」「本轮」才真的把队友算进去。
57
+ 带 since(毫秒)= 只要那个时刻之后的用量,客户端拿它取「本轮」这一段。 */
58
+ const SUBAGENT_URL = "/dsh-desktop-statusbar/api/subagent-usage";
59
+ /** 队友用量多久重拉一次:底栏本来就有一个**每秒**跳的 now 时钟(活跃时),
60
+ 所以这里取 1 秒 —— 用户要的「实时跳动」;也不必为它新增定时器。
61
+ 没有变化时后台只比对文件的 mtime/size(实测几毫秒),每秒重拉的开销可以忽略。 */
62
+ const SUBAGENT_REFRESH_MS = 1000;
63
+ /** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。 */
64
+ const SUBAGENT_TIMEOUT_MS = 8000;
42
65
  /**
43
66
  * 配置结构版本。
44
67
  * v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
45
68
  */
46
69
  const CONFIG_VERSION = 6;
47
70
  /** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
48
- const VERSION = "2.1.0";
71
+ const VERSION = "2.1.2";
49
72
  /** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
50
73
  const PRICE_VERSION = 2;
51
74
  /** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
@@ -82,7 +105,111 @@ window.__ModuleLoader__.load({
82
105
  return currency === "USD" ? DEFAULT_PRICES_USD : DEFAULT_PRICES;
83
106
  }
84
107
 
85
- const EMPTY_TIER = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
108
+ /** 一组数字的中位数(偶数个取中间两个的平均)。 */
109
+ function medianNumber(list) {
110
+ if (list.length === 0) return null;
111
+ const sorted = list.slice().sort((a, b) => a - b);
112
+ const mid = Math.floor(sorted.length / 2);
113
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
114
+ }
115
+
116
+ /**
117
+ * 人民币 ↔ 美元的统一折算率,**从两套官方参考价表推导**(不硬编码任何来历不明的数字):
118
+ * ① 对每个"两张表里都有"的模型,取它每一格非零价格在两表中的比值,得到该模型的中位比值;
119
+ * ② 再对所有模型的中位比值取中位数。
120
+ * 当前两张表(2026-09 核对)推出:flash 6.666667、v4-pro 6.818182 → **6.742424**。
121
+ * 用法:`人民币金额 / REFERENCE_RATE = 美元金额`,反向乘回去。
122
+ * 仅供**显示层折算**与"切换条目币种时换算该条目数值",绝不改写价格库、也不碰台账。
123
+ */
124
+ const REFERENCE_RATE = (() => {
125
+ const perModel = [];
126
+ Object.keys(DEFAULT_PRICES).forEach((name) => {
127
+ const usd = DEFAULT_PRICES_USD[name];
128
+ if (usd === undefined || usd === null) return;
129
+ const ratios = [];
130
+ ["peak", "offPeak"].forEach((tier) => {
131
+ const left = DEFAULT_PRICES[name][tier];
132
+ const right = usd[tier];
133
+ if (left === undefined || left === null || right === undefined || right === null) return;
134
+ ["input", "cacheRead", "cacheWrite", "output"].forEach((field) => {
135
+ const a = Number(left[field]);
136
+ const b = Number(right[field]);
137
+ if (Number.isFinite(a) === true && Number.isFinite(b) === true && a > 0 && b > 0) ratios.push(a / b);
138
+ });
139
+ });
140
+ const one = medianNumber(ratios);
141
+ if (one !== null) perModel.push(one);
142
+ });
143
+ return medianNumber(perModel);
144
+ })();
145
+
146
+ /** 币种只认 CNY / USD,其余一律当人民币(与配置读取同一口径)。 */
147
+ function normalizeCurrency(raw) {
148
+ return raw === "USD" ? "USD" : "CNY";
149
+ }
150
+
151
+ /**
152
+ * 一个模型条目的币种:`models[name].currency`;没写就退回默认币种(`config.currency`)。
153
+ * 用 pickPriceKey 匹配(与计价同一套规则),所以"会话里的模型名"和"价格库的键名"
154
+ * 不逐字相同也能找到同一个条目。
155
+ */
156
+ function entryCurrencyOf(book, model, defaultCurrency) {
157
+ const fallback = normalizeCurrency(defaultCurrency);
158
+ if (book === null || book === undefined || typeof book !== "object" || typeof model !== "string") return fallback;
159
+ const key = pickPriceKey(book, model);
160
+ if (key === null) return fallback;
161
+ const entry = book[key];
162
+ if (entry === null || entry === undefined || typeof entry !== "object") return fallback;
163
+ return entry.currency === undefined || entry.currency === null ? fallback : normalizeCurrency(entry.currency);
164
+ }
165
+
166
+ /**
167
+ * 金额换币种 —— **只用于底栏显示层**(混币折成一个数字)。
168
+ *
169
+ * 绝不用于"切换条目币种时改价格库数值":那是用户明确反对的做法(官方价是定死的两套,
170
+ * 自建/改过的价该填多少由用户自己填,工具不按汇率替他算)。见 `setModelCurrency`。
171
+ * 同一个币种原样返回;推导不出比率时也不动。
172
+ */
173
+ function convertCurrency(amount, from, to) {
174
+ const value = Number(amount);
175
+ if (Number.isFinite(value) !== true) return amount;
176
+ const a = normalizeCurrency(from);
177
+ const b = normalizeCurrency(to);
178
+ if (a === b || REFERENCE_RATE === null || Number.isFinite(REFERENCE_RATE) !== true || REFERENCE_RATE <= 0) return value;
179
+ /* 比率是"人民币 / 美元":CNY → USD 就除,USD → CNY 就乘 */
180
+ return a === "CNY" ? value / REFERENCE_RATE : value * REFERENCE_RATE;
181
+ }
182
+
183
+ /** 一格未填时的占位:用 `null` 表示"这里还没填过",与用户明确填的 0 区分开。 */
184
+ const EMPTY_TIER = { input: null, cacheRead: null, cacheWrite: null, output: null };
185
+
186
+ /**
187
+ * 单个价格格:**没填过就保留成 `null`**(`null` / `undefined` / 空字符串都算没填),
188
+ * 其余转成数字 —— 用户明确填的 0 就是数字 0,不会被当成"没填"。
189
+ * 计价时 `null` 参与算术会当 0 用,所以不影响算钱,只影响编辑框要不要留空。
190
+ */
191
+ function tierValue(raw) {
192
+ if (raw === null || raw === undefined || raw === "") return null;
193
+ const n = Number(raw);
194
+ /* 非法输入(手改配置、粘贴进字母)当作"没填",不要压成 0 —— 压成 0 会被当成
195
+ "用户明确把价定成了 0",第二轮匹配就会拿它算钱 */
196
+ return Number.isFinite(n) === true ? n : null;
197
+ }
198
+
199
+ /**
200
+ * 峰谷联动:官方口径里空闲价正好是高峰价的一半,所以用户只需填一档,
201
+ * 另一档按比例算出来 —— 填高峰 → 空闲 ÷2;填空闲 → 高峰 ×2。
202
+ * `raw` 是输入框里的字符串:空串原样返回(清空一档时另一档也清空),
203
+ * 非法输入返回空串(宁可留空让用户重填,也不要写进一个错价)。
204
+ */
205
+ function linkedTierValue(raw, fromPeak) {
206
+ if (raw === "") return "";
207
+ const n = Number(raw);
208
+ if (Number.isFinite(n) !== true) return "";
209
+ const linked = fromPeak === true ? n / 2 : n * 2;
210
+ /* 去掉浮点尾巴(0.07×2 之类),最多留 6 位小数 */
211
+ return String(Math.round(linked * 1e6) / 1e6);
212
+ }
86
213
 
87
214
  /** 把一条价格规范化成 { peak, offPeak };旧的平铺结构视为两档同价。 */
88
215
  /** 两档数值是否逐项相同:用来识别被写坏的「空闲价 = 高峰价」。 */
@@ -94,12 +221,8 @@ window.__ModuleLoader__.load({
94
221
 
95
222
  function normalizePrice(entry) {
96
223
  if (entry === null || entry === undefined || typeof entry !== "object") return null;
97
- const flatten = (value) => ({
98
- input: Number(value.input) || 0,
99
- cacheRead: Number(value.cacheRead) || 0,
100
- cacheWrite: Number(value.cacheWrite) || 0,
101
- output: Number(value.output) || 0
102
- });
224
+ /* 直接复用 normalizeTier:两处口径必须一致,否则读回价格库时会把"没填"洗成 0 */
225
+ const flatten = (value) => normalizeTier(value);
103
226
  if (entry.peak !== undefined || entry.offPeak !== undefined) {
104
227
  const peak = entry.peak === undefined || entry.peak === null ? null : flatten(entry.peak);
105
228
  const offPeak = entry.offPeak === undefined || entry.offPeak === null ? null : flatten(entry.offPeak);
@@ -112,17 +235,136 @@ window.__ModuleLoader__.load({
112
235
  return { peak: flat, offPeak: flat };
113
236
  }
114
237
 
115
- /** 按当前价格库查模型单价(规范化成峰谷两档);精确名优先,其次子串匹配;找不到返回 null。 */
116
- function priceOf(model) {
117
- if (typeof model !== "string") return null;
118
- const book = config.models === undefined || config.models === null ? {} : config.models;
119
- if (book[model] !== undefined) return normalizePrice(book[model]);
120
- const lower = model.toLowerCase();
238
+ /* ------------------------------------------------------------ 模型名匹配 */
239
+ /**
240
+ * 模型名切成词元:忽略大小写,分隔符(- . _ 空格等)都当边界。
241
+ * `DeepSeek-V4.1-Flash-SG` → ["deepseek","v4","1","flash","sg"]
242
+ */
243
+ function modelTokens(name) {
244
+ return typeof name === "string"
245
+ ? name.toLowerCase().split(/[^a-z0-9]+/).filter((one) => one.length > 0)
246
+ : [];
247
+ }
248
+
249
+ /** 词元是否对得上:**完全相等**(大小写已经在切词时归一)。 */
250
+ function tokenMatches(needle, hay) {
251
+ return needle === hay;
252
+ }
253
+
254
+ /** 一条价格是不是"什么都没填":两档四项加起来是 0。用来把未定价的条目挡在匹配之外。 */
255
+ function priceIsBlank(entry) {
256
+ const price = normalizePrice(entry);
257
+ if (price === null) return true;
258
+ const sum = (tier) => tier.input + tier.cacheRead + tier.cacheWrite + tier.output;
259
+ return sum(price.peak) + sum(price.offPeak) === 0;
260
+ }
261
+
262
+ /**
263
+ * 这条价格里**有没有任何一格是数字**(= 用户真的填过)。
264
+ * 八格全是 `null`(只在设置页点过「添加」、还没填价就收起了)的条目,**两轮匹配都不该算数**:
265
+ * 否则第二轮兜底会让它命中同名模型,把该模型的费用静默算成 ¥0(实测缺陷)。
266
+ * "明确填了 0"的条目有数字格,第二轮兜底照常生效。
267
+ */
268
+ function hasAnyPriceValue(entry) {
269
+ if (entry === null || entry === undefined || typeof entry !== "object") return false;
270
+ const price = normalizePrice(entry);
271
+ if (price === null) return false;
272
+ const tiers = [price.peak, price.offPeak];
273
+ for (let i = 0; i < tiers.length; i += 1) {
274
+ const one = tiers[i];
275
+ if (one === null || one === undefined) continue;
276
+ if (typeof one.input === "number" || typeof one.cacheRead === "number"
277
+ || typeof one.cacheWrite === "number" || typeof one.output === "number") return true;
278
+ }
279
+ return false;
280
+ }
281
+
282
+ /**
283
+ * 一轮匹配:① 同名单独先行;②③(子串命中与词元全含命中)**合并评选**,
284
+ * 按 weight = 词元数 * 1000 + 键名长度 取最具体的那个 —— 不能分档跑,
285
+ * 否则模型名里含一个短键(如 flash)就会把更具体的长键挤掉。
286
+ * 含数字的键只允许子串命中,不走词元档。
287
+ * skipBlank=true 时跳过"还没定价"(整条全 0)的条目。
288
+ */
289
+ function matchPriceKeyOnce(book, model, skipBlank) {
121
290
  const keys = Object.keys(book);
291
+ const lower = model.toLowerCase();
292
+ /* 空键(手改配置或第三方 POST 才可能出现)不参选;八格全 null 的条目两轮都不参选;
293
+ skipBlank 那一轮再额外跳过"有数字、但总和为 0"的条目 */
294
+ const usable = (key) => key.length > 0
295
+ && hasAnyPriceValue(book[key]) === true
296
+ && (skipBlank !== true || priceIsBlank(book[key]) !== true);
297
+ /* ① 同名:单独先行,最高优先 */
122
298
  for (let i = 0; i < keys.length; i += 1) {
123
- if (lower.indexOf(keys[i].toLowerCase()) !== -1) return normalizePrice(book[keys[i]]);
299
+ if (keys[i].toLowerCase() === lower && usable(keys[i])) return keys[i];
124
300
  }
125
- return null;
301
+ /* ②③ 一次评选:子串命中的键与词元全含命中的键放在一起比 weight,取最具体的那个。
302
+ 不能按"先全部子串、再全部词元"两轮跑 —— 那样模型名里只要含一个短键(如 flash),
303
+ 就会把更具体的 deepseek-flash(只能靠词元档命中)挤掉。
304
+ 含数字的键(如 deepseek-v4-pro)只允许子串命中,不走词元档。 */
305
+ const tokens = modelTokens(model);
306
+ const weightOf = (key) => modelTokens(key).length * 1000 + key.length;
307
+ let best = null;
308
+ let bestWeight = -1;
309
+ for (let i = 0; i < keys.length; i += 1) {
310
+ const key = keys[i];
311
+ if (usable(key) !== true) continue;
312
+ let hit = lower.indexOf(key.toLowerCase()) !== -1;
313
+ if (hit !== true && /[0-9]/.test(key) !== true) {
314
+ const want = modelTokens(key);
315
+ if (want.length > 0) {
316
+ hit = true;
317
+ for (let k = 0; k < want.length; k += 1) {
318
+ let found = false;
319
+ for (let m = 0; m < tokens.length; m += 1) {
320
+ if (tokenMatches(want[k], tokens[m]) === true) { found = true; break; }
321
+ }
322
+ if (found !== true) { hit = false; break; }
323
+ }
324
+ }
325
+ }
326
+ if (hit !== true) continue;
327
+ const weight = weightOf(key);
328
+ if (weight > bestWeight) { bestWeight = weight; best = key; }
329
+ }
330
+ return best;
331
+ }
332
+
333
+ /**
334
+ * 价格键 → 模型名:① 同名最优先;② 子串命中与③ 词元全含命中**合并评选**,取最具体的那个;
335
+ * 名字里含数字的键(如 `deepseek-v4-pro`)只认子串,不做词元匹配 ——
336
+ * 版本号不同的 pro 是另一个模型、另一个价,不该被旧版号吃掉。
337
+ *
338
+ * 为什么必须有词元档:默认键叫 `deepseek-flash`,而会话日志里记的是
339
+ * `deepseek-v4.1-flash-sg` / `deepseek-v41-flash` —— 版本号插在中间,子串匹配命不中,
340
+ * 于是默认价对这类模型永远不生效、每笔都被当成"没有单价"。
341
+ *
342
+ * **分两轮**:第一轮跳过"还没填价"(整条全 0)的条目 —— 用户加了条目但没填价,
343
+ * 不该把该模型的费用算成 0;第一轮一无所获时才放开,让"明确把某模型定为 0 价"仍算得出来。
344
+ * **host 侧 lib/index.js 的 pickPriceKey 必须与这里逐条同规则**,否则底栏与活跃总览会算出两个价。
345
+ */
346
+ function pickPriceKey(book, model) {
347
+ if (book === null || book === undefined || typeof book !== "object") return null;
348
+ if (typeof model !== "string" || model.length === 0) return null;
349
+ if (Object.keys(book).length === 0) return null;
350
+ const strict = matchPriceKeyOnce(book, model, true);
351
+ if (strict !== null) return strict;
352
+ return matchPriceKeyOnce(book, model, false);
353
+ }
354
+
355
+ /**
356
+ * 按当前价格库查模型单价(规范化成峰谷两档);匹配规则见 pickPriceKey;找不到返回 null。
357
+ * 顺带带上 `tiered`:**没勾「峰谷计价」的条目是全天一个价**,计价时必须两档都取 peak ——
358
+ * 否则空闲时段会挑到那个没填的档,把这段时间的费用全算成 0(实测缺陷)。
359
+ */
360
+ function priceOf(model) {
361
+ const book = config.models === undefined || config.models === null ? {} : config.models;
362
+ const key = pickPriceKey(book, model);
363
+ if (key === null) return null;
364
+ const price = normalizePrice(book[key]);
365
+ if (price === null) return null;
366
+ price.tiered = isTiered(book[key]);
367
+ return price;
126
368
  }
127
369
 
128
370
  /**
@@ -247,17 +489,17 @@ window.__ModuleLoader__.load({
247
489
  return dayStart + 33 * 3600 * 1000 - at;
248
490
  }
249
491
 
250
- /** 价格库是否为空或全是 0(用户可能加过一个没填价的条目)。 */
492
+ /**
493
+ * 价格库是不是"还没填过":**所有条目的所有格子都是空的**才算缺失。
494
+ * 不能再用"数值总和为 0"判断 —— 用户明确填 0(表示免费)也是一种填写,
495
+ * 旧判据会把这种库整个换成官方参考价,用户自己的条目直接消失(实测缺陷)。
496
+ */
251
497
  function pricesMissing(book) {
252
498
  if (book === null || book === undefined || typeof book !== "object") return true;
253
499
  const names = Object.keys(book);
254
500
  if (names.length === 0) return true;
255
501
  for (let i = 0; i < names.length; i += 1) {
256
- const price = normalizePrice(book[names[i]]);
257
- if (price === null) continue;
258
- const total = price.peak.input + price.peak.cacheRead + price.peak.cacheWrite + price.peak.output
259
- + price.offPeak.input + price.offPeak.cacheRead + price.offPeak.cacheWrite + price.offPeak.output;
260
- if (total > 0) return false;
502
+ if (hasAnyPriceValue(book[names[i]]) === true) return false;
261
503
  }
262
504
  return true;
263
505
  }
@@ -316,6 +558,50 @@ window.__ModuleLoader__.load({
316
558
  return currency === "USD" ? "$" : "¥";
317
559
  }
318
560
 
561
+ /**
562
+ * 当前会话的**主模型**:消耗最多(四项 token 之和最大)的那个模型名。
563
+ *
564
+ * 为什么用累计 token 而不是"最近一次调用":一次调用就让币种来回跳会晃;
565
+ * 累计量只增不减,币种因此是稳的。并列时按名字排序定一个,保证同输入同输出。
566
+ * 没有任何正用量(拿不到会话聚合、或全是 0)时返回 `null` —— 调用方退回默认币种。
567
+ */
568
+ function mainModelOf(sessionUsage) {
569
+ if (sessionUsage === null || sessionUsage === undefined || typeof sessionUsage !== "object") return null;
570
+ const models = sessionUsage.models;
571
+ if (models === null || models === undefined || typeof models !== "object") return null;
572
+ let best = null;
573
+ let bestTotal = 0;
574
+ Object.keys(models).forEach((name) => {
575
+ const one = models[name];
576
+ if (one === null || one === undefined || typeof one !== "object") return;
577
+ const total = (Number(one.input) || 0) + (Number(one.cacheRead) || 0)
578
+ + (Number(one.cacheWrite) || 0) + (Number(one.output) || 0);
579
+ if (total <= 0) return;
580
+ if (total > bestTotal || (total === bestTotal && best !== null && name < best)) {
581
+ best = name;
582
+ bestTotal = total;
583
+ }
584
+ });
585
+ return best;
586
+ }
587
+
588
+ /**
589
+ * 底栏(总计 / 本轮)的显示币种 = **主模型那个条目的币种**;认不出主模型就退回
590
+ * "新增条目的默认币种"(`config.currency`)。与"我还没用过那个模型"无关的全局币种,
591
+ * 到这里彻底退出底栏显示。
592
+ */
593
+ function displayCurrencyOf(book, defaultCurrency, sessionUsage) {
594
+ const main = mainModelOf(sessionUsage);
595
+ if (main === null) return normalizeCurrency(defaultCurrency);
596
+ return entryCurrencyOf(book, main, defaultCurrency);
597
+ }
598
+
599
+ /** 按某模型条目单价算出来的金额 → 折成显示币种(只用于显示,不回写任何地方)。 */
600
+ function toDisplayCurrency(amount, book, model, displayCurrency, defaultCurrency) {
601
+ if (amount === null || amount === undefined) return amount;
602
+ return convertCurrency(amount, entryCurrencyOf(book, model, defaultCurrency), displayCurrency);
603
+ }
604
+
319
605
  /** 模型名显示用:deepseek → DeepSeek、glm → GLM、gpt → GPT,其余按 - 分段首字母大写。 */
320
606
  const MODEL_NAME_SPECIAL = { deepseek: "DeepSeek", glm: "GLM", gpt: "GPT" };
321
607
  function displayModelName(name) {
@@ -333,6 +619,22 @@ window.__ModuleLoader__.load({
333
619
  return entry === null || entry === undefined || entry.tiered !== false;
334
620
  }
335
621
 
622
+ /**
623
+ * 旧版本遗留的"全 0 条目":八个价格格全是**数字 0**、没有任何 `null`。
624
+ * 那时的数据分不清"没填"与"填了 0",只能按没填过处理(编辑时整栏留空)。
625
+ * 新版数据里没填的格子是 `null`,所以只要有一格是 `null` 就不会被误判。
626
+ */
627
+ function isLegacyZeroEntry(entry) {
628
+ const price = normalizePrice(entry);
629
+ if (price === null) return false;
630
+ const tiers = [price.peak, price.offPeak];
631
+ for (let i = 0; i < tiers.length; i += 1) {
632
+ const one = tiers[i];
633
+ if (one.input !== 0 || one.cacheRead !== 0 || one.cacheWrite !== 0 || one.output !== 0) return false;
634
+ }
635
+ return true;
636
+ }
637
+
336
638
 
337
639
  /**
338
640
  * 按价格库算一段用量的费用(按给定时刻定峰谷),返回**未舍入的数值**。
@@ -342,7 +644,23 @@ window.__ModuleLoader__.load({
342
644
  function costOf(bucket, model, at) {
343
645
  const price = priceOf(model);
344
646
  if (price === null || bucket === undefined || bucket === null) return null;
345
- const tier = isPeakTime(at) ? price.peak : price.offPeak;
647
+ /* 没勾「峰谷计价」= 全天一个价:两档都取 peak,别在空闲时段挑到空档、把费用算成 0 */
648
+ const tier = price.tiered === false ? price.peak : (isPeakTime(at) ? price.peak : price.offPeak);
649
+ return ((bucket.input || 0) * tier.input
650
+ + (bucket.cacheRead || 0) * tier.cacheRead
651
+ + (bucket.cacheWrite || 0) * tier.cacheWrite
652
+ + (bucket.output || 0) * tier.output) / 1e6;
653
+ }
654
+
655
+ /**
656
+ * 按**指定档位**算一段用量的费用(不按时刻判断)。返回未舍入数值;没单价返回 null。
657
+ * 专供 host 回传的 overflow 用 —— 那部分已经按档归好类(host 侧判的峰谷),
658
+ * 这里不能再拿一个时刻去猜档位。没勾「峰谷计价」的条目两档同价,取 peak 即可。
659
+ */
660
+ function costOfTier(bucket, model, peak) {
661
+ const price = priceOf(model);
662
+ if (price === null || bucket === undefined || bucket === null) return null;
663
+ const tier = price.tiered === false ? price.peak : (peak === true ? price.peak : price.offPeak);
346
664
  return ((bucket.input || 0) * tier.input
347
665
  + (bucket.cacheRead || 0) * tier.cacheRead
348
666
  + (bucket.cacheWrite || 0) * tier.cacheWrite
@@ -360,6 +678,207 @@ window.__ModuleLoader__.load({
360
678
  return n.toFixed(2);
361
679
  }
362
680
 
681
+ /**
682
+ * 队友那一段的花费 → 折成显示币种。
683
+ *
684
+ * host 新版本会额外给 `total.costByCurrency`(按每个队友自己的条目币种分桶汇总),
685
+ * 这里逐桶折算再相加 —— 队友用了另一种币种时这才准。
686
+ * 老版本只有 `total.cost`(没带币种信息),就当作它已经是显示币种、不做折算:
687
+ * 队友与会话用同一个模型是最常见的情形,那时两者币种本来就相同。
688
+ */
689
+ function subagentDisplayCost(total, displayCurrency) {
690
+ if (total === null || total === undefined || typeof total !== "object") return 0;
691
+ const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
692
+ const byCurrency = total.costByCurrency;
693
+ if (byCurrency !== null && byCurrency !== undefined && typeof byCurrency === "object") {
694
+ let sum = 0;
695
+ Object.keys(byCurrency).forEach((code) => {
696
+ sum += convertCurrency(positive(byCurrency[code]), code, displayCurrency);
697
+ });
698
+ return sum;
699
+ }
700
+ return positive(total.cost);
701
+ }
702
+
703
+ /** 从队友的原始响应里取某一段的费用,并折成显示币种(拿不到就是 0)。 */
704
+ function subagentCostOf(subagent, which, displayCurrency) {
705
+ const payload = subagent === null || subagent === undefined ? null : subagent[which];
706
+ if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return 0;
707
+ return subagentDisplayCost(payload.total, displayCurrency);
708
+ }
709
+
710
+ /**
711
+ * host 返回的队友(Agent Teams 子智能体)用量 → 界面要的两个数:费用与令牌三段。
712
+ * 队友是**独立会话**,它的用量 host 按 parentSession 现算;拿不到(还在加载、请求失败、
713
+ * 后端老版本、形状不对)时一律给 0 —— 界面就退回"只算本会话",不报错也不显示额外文字。
714
+ * 注意:这个形状(cost / cached / missInput / output)是既有约定,别往里塞别的字段 ——
715
+ * 「首字 / 解码」那两组的累计走 subagentStatsOf,两者互不影响。
716
+ */
717
+ function subagentSummary(payload) {
718
+ const none = { cost: 0, cached: 0, missInput: 0, output: 0 };
719
+ if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return none;
720
+ const total = payload.total;
721
+ if (total === null || total === undefined || typeof total !== "object") return none;
722
+ const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
723
+ const tokens = total.tokens !== null && total.tokens !== undefined && typeof total.tokens === "object"
724
+ ? total.tokens
725
+ : {};
726
+ /* 令牌三段按本面板自己的口径合并:命中缓存 = cacheRead;未命中 = 未缓存输入 + 缓存写入
727
+ (与 billableParts 一致,否则总计用量会对不上三行之和);输出 = output。 */
728
+ return {
729
+ cost: positive(total.cost),
730
+ cached: positive(tokens.cacheRead),
731
+ missInput: positive(tokens.input) + positive(tokens.cacheWrite),
732
+ output: positive(tokens.output)
733
+ };
734
+ }
735
+
736
+ /**
737
+ * 队友的首字 / 解码累计(`total.stats`)—— 只给"会话级"那三项(首字平均、综合速度)用。
738
+ * 单独一个函数而不是并进 subagentSummary:后者那个四字段形状是既有约定,
739
+ * 混进去会让别处(含测试)的深比较全变。拿不到就全 0,等于不加。
740
+ */
741
+ function subagentStatsOf(subagentData) {
742
+ const none = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 };
743
+ const payload = subagentData === null || subagentData === undefined ? null : subagentData.session;
744
+ if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return none;
745
+ const total = payload.total;
746
+ if (total === null || total === undefined || typeof total !== "object") return none;
747
+ const stats = total.stats;
748
+ if (stats === null || stats === undefined || typeof stats !== "object") return none;
749
+ const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
750
+ return {
751
+ ttftMs: positive(stats.ttftMs),
752
+ ttftSteps: positive(stats.ttftSteps),
753
+ decodeMs: positive(stats.decodeMs),
754
+ decodeTokens: positive(stats.decodeTokens)
755
+ };
756
+ }
757
+
758
+ /**
759
+ * 会话级那三项(首字平均 / 综合速度 / 缓存命中率)用的合并量 = 本会话 + 全部子会话。
760
+ * 「本轮」那一份**只算本页面**(用户明确要求),所以不走这里。
761
+ *
762
+ * 首字与解码都是"总量 / 样本数"的比值,所以**按组**合并:某一组只要缺了分子或分母
763
+ * (拿不到、为 0、非数)就整组不计 —— 只加半组会把平均值算歪(例如只有 ttftMs 没有
764
+ * ttftSteps)。host 只会整组给,这里是防坏响应/手改数据。
765
+ * 纯函数、只做几次加法:客户端每秒重算也不会成为负担。
766
+ */
767
+ function mergedSessionStats(stats, sub) {
768
+ const value = (one, key) => {
769
+ const raw = one === null || one === undefined ? 0 : Number(one[key]);
770
+ return Number.isFinite(raw) === true && raw > 0 ? raw : 0;
771
+ };
772
+ const sessionTtftMs = value(stats, "ttftMs");
773
+ const sessionTtftSteps = value(stats, "ttftSteps");
774
+ const sessionDecodeMs = value(stats, "decodeMs");
775
+ const sessionDecodeTokens = value(stats, "decodeTokens");
776
+ const sessionTtft = sessionTtftMs > 0 && sessionTtftSteps > 0;
777
+ const sessionDecode = sessionDecodeMs > 0 && sessionDecodeTokens > 0;
778
+ const subTtft = sub.ttftMs > 0 && sub.ttftSteps > 0;
779
+ const subDecode = sub.decodeMs > 0 && sub.decodeTokens > 0;
780
+ return {
781
+ ttftMs: (sessionTtft ? sessionTtftMs : 0) + (subTtft ? sub.ttftMs : 0),
782
+ ttftSteps: (sessionTtft ? sessionTtftSteps : 0) + (subTtft ? sub.ttftSteps : 0),
783
+ decodeMs: (sessionDecode ? sessionDecodeMs : 0) + (subDecode ? sub.decodeMs : 0),
784
+ decodeTokens: (sessionDecode ? sessionDecodeTokens : 0) + (subDecode ? sub.decodeTokens : 0)
785
+ };
786
+ }
787
+
788
+ /** 从 host 拉回来的队友数据里取一段:`session` = 整会话(总计用),`turn` = 本轮窗口(本轮用)。 */
789
+ function subagentOf(subagent, which) {
790
+ return subagentSummary(subagent === null || subagent === undefined ? null : subagent[which]);
791
+ }
792
+
793
+ /* ------------------------------------------- 「本轮」稳定性(同轮不跳、换轮归零) */
794
+ /* 底栏每秒问两个窗口(整会话 / 本轮窗口),任一问失败时旧实现会把队友那段当 0,
795
+ 于是「本轮」在同轮内跳小、下一次成功又跳大(用户实测)。下面两件纯函数负责把它稳住:
796
+ `nextSubagentState` 管"这次失败要不要沿用上一次成功的值",`applyTurnFloor` 管
797
+ "同一轮内显示值只增不减"。两者都以**轮标识**(turnKey)为界:换了轮一律从头来。 */
798
+
799
+ /** 空状态:没有会话、没有轮、两段都没有值。 */
800
+ const SUBAGENT_STATE_EMPTY = { sessionId: null, turnKey: null, sessionPayload: null, turnPayload: null };
801
+
802
+ /**
803
+ * 队友两段取值的状态机(纯函数,组件只负责调用)。
804
+ *
805
+ * 一次"客户端到期"问两个窗口,每个窗口各自维护"上一次成功的值 + 它属于哪一轮"。
806
+ * `ok` 的判定由调用方统一折算:网络失败、非 200、**HTTP 200 但 `ok:false`**(后端
807
+ * `scan-failed` 之类)、以及形状异常(拿到的不是 `{ok:true,…}`)**都算"这一次没拿到"**。
808
+ *
809
+ * 三种情形(用户明确要求的三态):
810
+ * · 同一会话、同一轮、之前成功过 → **保留上一次成功的值**(stale-while-revalidate,
811
+ * 宁可用稍旧的值,也不要在同轮内把已经显示的数字打回 0);
812
+ * · 首次加载(手里没有旧值)→ `null` ⇒ 界面按"仅本会话"显示(没有旧值可留);
813
+ * · 换了轮(`turnKey` 变了)或换了会话 → **先整体清空**,绝不让上一轮的旧值顶上
814
+ * (这就是用户说的"归零重算以轮次结束为准")。
815
+ * 返回值是**新对象**,不原地改入参。
816
+ */
817
+ function nextSubagentState(prev, action) {
818
+ const base = prev === null || prev === undefined ? SUBAGENT_STATE_EMPTY : prev;
819
+ const sameSession = base.sessionId === action.sessionId;
820
+ const sameTurn = sameSession === true && base.turnKey === action.turnKey;
821
+ /* 整会话那一段与"轮"无关(它就是本会话有史以来的队友合计,只随会话累积):
822
+ 只要会话没换就留着 —— 否则每次换轮都会把「总计」里的队友清一下再填回来(抖一下)。
823
+ 「本轮窗口」那一段必须跟轮走:换轮就作废,不许让上一轮的窗口当这一轮的。 */
824
+ const carried = sameSession === true
825
+ ? {
826
+ sessionId: action.sessionId,
827
+ turnKey: action.turnKey,
828
+ sessionPayload: base.sessionPayload,
829
+ turnPayload: sameTurn === true ? base.turnPayload : null
830
+ }
831
+ : { sessionId: action.sessionId, turnKey: action.turnKey, sessionPayload: null, turnPayload: null };
832
+ const sliceKey = action.slice === "session" ? "sessionPayload" : "turnPayload";
833
+ /* 这一段的"归属"判定:整会话只看会话,本轮窗口要看"会话 + 轮" */
834
+ const ownerOk = sliceKey === "sessionPayload" ? sameSession : sameTurn;
835
+ if (action.ok !== true) {
836
+ /* 没拿到:同一归属且这一段手里已经有值 → 原样留着;否则这一段就是 null
837
+ (首次加载、或者刚换轮/换会话 —— 前者没有旧值可留,后者不允许留) */
838
+ if (ownerOk === true && carried[sliceKey] !== null) return carried;
839
+ carried[sliceKey] = null;
840
+ return carried;
841
+ }
842
+ carried[sliceKey] = action.value;
843
+ return carried;
844
+ }
845
+
846
+ /**
847
+ * 「本轮」显示值的**同轮粘性下限**(纯函数):一轮之内只增不减。
848
+ *
849
+ * `hold` 形如 `{ sessionId, turnKey, value }`;返回 `{ value, hold }`,调用方把新 `hold`
850
+ * 存回自己的盒子。规则:
851
+ * · 同一个会话、同一个轮 → `value = max(amount, hold.value)`(`amount` 为 null 时用旧值);
852
+ * · 换了轮/换了会话、或认不出轮次(`turnKey` 为 null)→ 不作粘性(`value = amount`)。
853
+ * 为什么需要它:一轮内窗口起点会移动(父会话日志多出一条 `user/message`、节点折叠的
854
+ * 最早时间戳变化、压缩替换节点、host 收敛起点),窗口一变数字就会小一截;用户要的是
855
+ * **一轮之内单调不减**,只有换轮才归零重算。
856
+ */
857
+ function applyTurnFloor(hold, sessionId, turnKey, amount) {
858
+ const usable = hold !== null && hold !== undefined && turnKey !== null
859
+ && hold.turnKey === turnKey && hold.sessionId === sessionId;
860
+ const floor = usable === true && typeof hold.value === "number" && Number.isFinite(hold.value) === true
861
+ ? hold.value
862
+ : 0;
863
+ /* `amount === null` 是"这一拍没有可显示的金额"(界面给横杠),不是 0:
864
+ 没有下限时要把 null 原样传出去(否则"横杠"会变成 ¥0.00,那是另一回事);
865
+ 有下限时用下限顶住(同轮内已经显示过金额,就不该退回横杠)。 */
866
+ const value = amount === null || amount === undefined
867
+ ? (floor > 0 ? floor : null)
868
+ : Math.max(amount, floor);
869
+ return { value: value, hold: { sessionId: sessionId, turnKey: turnKey, value: value === null ? floor : value } };
870
+ }
871
+
872
+ /** 本会话的令牌三段 + 队友的三段:同一个数字直接相加,不新增行。 */
873
+ function mergeTokenParts(parts, sub) {
874
+ const base = parts === null || parts === undefined ? { cached: 0, missInput: 0, output: 0 } : parts;
875
+ return {
876
+ cached: base.cached + sub.cached,
877
+ missInput: base.missInput + sub.missInput,
878
+ output: base.output + sub.output
879
+ };
880
+ }
881
+
363
882
  /** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
364
883
  function diffUsage(current, base) {
365
884
  if (current === null || current === undefined || base === null || base === undefined) return null;
@@ -374,21 +893,32 @@ window.__ModuleLoader__.load({
374
893
  function normalizeTier(rawTier) {
375
894
  const entry = rawTier === null || rawTier === undefined || typeof rawTier !== "object" ? {} : rawTier;
376
895
  return {
377
- input: Number(entry.input) || 0,
378
- cacheRead: Number(entry.cacheRead) || 0,
379
- cacheWrite: Number(entry.cacheWrite) || 0,
380
- output: Number(entry.output) || 0
896
+ input: tierValue(entry.input),
897
+ cacheRead: tierValue(entry.cacheRead),
898
+ cacheWrite: tierValue(entry.cacheWrite),
899
+ output: tierValue(entry.output)
381
900
  };
382
901
  }
383
- function normalizeModels(rawModels) {
902
+ /**
903
+ * 规范化价格库。`defaultCurrency` 是"这条目没写币种时用哪个"——
904
+ * 老配置里币种是**全局**的(`config.currency`),迁移时把它当默认值传给每个条目,
905
+ * 于是"原来全部按人民币计价"的语义原样保留、一个条目都不丢币种。
906
+ */
907
+ function normalizeModels(rawModels, defaultCurrency) {
384
908
  const out = {};
385
909
  if (rawModels === null || rawModels === undefined || typeof rawModels !== "object") return out;
910
+ const fallback = normalizeCurrency(defaultCurrency);
386
911
  const names = Object.keys(rawModels);
387
912
  for (let i = 0; i < names.length; i += 1) {
388
913
  const price = normalizePrice(rawModels[names[i]]);
389
914
  if (price === null) continue;
915
+ const raw = rawModels[names[i]];
390
916
  out[names[i]] = {
391
- tiered: isTiered(rawModels[names[i]]),
917
+ tiered: isTiered(raw),
918
+ /* 条目自带的币种;没写就落到默认币种(迁移语义) */
919
+ currency: raw !== null && raw !== undefined && raw.currency !== undefined && raw.currency !== null
920
+ ? normalizeCurrency(raw.currency)
921
+ : fallback,
392
922
  peak: normalizeTier(price.peak),
393
923
  offPeak: normalizeTier(price.offPeak)
394
924
  };
@@ -396,11 +926,45 @@ window.__ModuleLoader__.load({
396
926
  return out;
397
927
  }
398
928
 
929
+ /* 节点上"这条记录发生在什么时候"在不同来源里可能叫不同名字:几个都试一遍。
930
+ 拿不到就返回 null —— 调用方据此判断"本轮起点未知"(`turnStartMs` 为 null),
931
+ 于是本轮不并队友(本轮只能小不能大),总计照旧按整会话并。 */
932
+ const NODE_TIME_KEYS = ["at", "time", "timestamp", "createdAt", "startedAt", "updatedAt", "ts", "timeMs", "timestampMs"];
933
+
934
+ /** 一个节点上的时间戳(连常见嵌套位置一起找);找不到返回 null。 */
935
+ function nodeTimeOf(node) {
936
+ const holders = [node, node.data, node.message, node.meta];
937
+ for (let i = 0; i < holders.length; i += 1) {
938
+ const holder = holders[i];
939
+ if (holder === null || holder === undefined || typeof holder !== "object") continue;
940
+ for (let k = 0; k < NODE_TIME_KEYS.length; k += 1) {
941
+ const value = holder[NODE_TIME_KEYS[k]];
942
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
943
+ }
944
+ }
945
+ return null;
946
+ }
947
+
948
+ /** 这一轮第一条记录的时间(该轮所有节点里最早的那个);一个都拿不到返回 null。 */
949
+ function turnStartOf(nodes, turn) {
950
+ let min = null;
951
+ for (let i = 0; i < nodes.length; i += 1) {
952
+ const node = nodes[i];
953
+ if (node === null || node === undefined) continue;
954
+ if (node.kind !== "assistant" || node.turn !== turn) continue;
955
+ const at = nodeTimeOf(node);
956
+ if (at === null) continue;
957
+ if (min === null || at < min) min = at;
958
+ }
959
+ return min;
960
+ }
961
+
399
962
  /**
400
963
  * 把会话节点里**最后一轮**的用量折出来(节点带 turn 号与这条调用的 usage)。
401
- * 返回 `{ turn, parts }`,没有可用节点时返回 null —— 轮号一起返回是为了让调用方
964
+ * 返回 `{ turn, parts, at }`,没有可用节点时返回 null —— 轮号一起返回是为了让调用方
402
965
  * 判断这份数据属于当前进行中的那一轮,还是上一轮(不能靠"往后跳一轮"猜,
403
- * 当前轮还没产出节点时那样会错位到上上轮)。
966
+ * 当前轮还没产出节点时那样会错位到上上轮)。`at` 是这一轮第一条记录的时间,
967
+ * 用来问 host"本轮窗口内队友用了多少";节点不带时间戳时为 null。
404
968
  */
405
969
  function foldTurnUsage(nodes) {
406
970
  if (!Array.isArray(nodes)) return null;
@@ -427,7 +991,7 @@ window.__ModuleLoader__.load({
427
991
  acc.output += usage.outputTokens || 0;
428
992
  found = true;
429
993
  }
430
- return found ? { turn: target, parts: acc } : null;
994
+ return found ? { turn: target, parts: acc, at: turnStartOf(nodes, target) } : null;
431
995
  }
432
996
 
433
997
  /* ---------------------------------------------------------------- i18n */
@@ -437,6 +1001,11 @@ window.__ModuleLoader__.load({
437
1001
  updateTo: "更新 {version} 版本",
438
1002
  updateRunning: "正在更新中",
439
1003
  updateFailed: "更新失败",
1004
+ updateDone: "更新成功,重启 DSH 后生效",
1005
+ /* 开发模式:连点版本号 5 次进入。不做成可见控件 —— 它面向作者调试,
1006
+ 普通用户装了插件也不该看到一个「预览」勾选框。进入后标题右侧挂一枚胶囊。 */
1007
+ devModeBadge: "开发模式",
1008
+ devModeHint: "开发模式:点更新按钮只演示状态,不会真的安装",
440
1009
  sectionTitle: "状态栏设置",
441
1010
  intro: "接管对话区底部的统计行。勾选要显示的字段、拖动调整顺序或配置模型费用单价。",
442
1011
  segContext: "上下文占用",
@@ -493,6 +1062,7 @@ window.__ModuleLoader__.load({
493
1062
  f_cost: "总计 {symbol}{cost}",
494
1063
  priceTierPeak: "高峰时段",
495
1064
  priceTierOffPeak: "空闲时段",
1065
+ priceTierAllDay: "全时段",
496
1066
  f_balanceDash: "余额 {symbol}-",
497
1067
  f_lastCost: "本轮 {symbol}{cost}",
498
1068
  f_balance: "余额 {symbol}{amount}",
@@ -501,7 +1071,7 @@ window.__ModuleLoader__.load({
501
1071
  secSegments: "统计字段",
502
1072
  secSegmentsHint: "勾选显示;按住拖动调整顺序。",
503
1073
  secPrices: "自定义模型价格",
504
- secPricesHint: "填写你使用的模型单价(每百万 tokens / {currency})。",
1074
+ secPricesHint: "填写你使用的模型单价(每百万 tokens)。",
505
1075
  segStatusHint: "自动判断当前的峰谷时段。",
506
1076
  tipWeekTitle: "活跃总览",
507
1077
  hoverCalls: "API请求次数",
@@ -547,6 +1117,9 @@ window.__ModuleLoader__.load({
547
1117
  updateTo: "Update to {version}",
548
1118
  updateRunning: "Updating",
549
1119
  updateFailed: "Update failed",
1120
+ updateDone: "Update succeeded — restart DSH to apply",
1121
+ devModeBadge: "Dev mode",
1122
+ devModeHint: "Dev mode: the update button only simulates states — nothing is installed",
550
1123
  sectionTitle: "Status Bar Settings",
551
1124
  intro: "Replaces the stats line under the message input. Select the fields to show, drag to reorder, or set model prices.",
552
1125
  segContext: "Context usage",
@@ -603,6 +1176,7 @@ window.__ModuleLoader__.load({
603
1176
  f_cost: "Total {symbol}{cost}",
604
1177
  priceTierPeak: "Peak",
605
1178
  priceTierOffPeak: "Off-peak",
1179
+ priceTierAllDay: "All day",
606
1180
  f_lastCost: "This turn {symbol}{cost}",
607
1181
  f_balance: "Balance {symbol}{amount}",
608
1182
  f_balanceSubCent: "Balance <{symbol}0.01",
@@ -610,7 +1184,7 @@ window.__ModuleLoader__.load({
610
1184
  secSegments: "Stats fields",
611
1185
  secSegmentsHint: "Select to show; drag to reorder.",
612
1186
  secPrices: "Custom model prices",
613
- secPricesHint: "Prices per million tokens, in {currency}.",
1187
+ secPricesHint: "Prices per million tokens.",
614
1188
  segStatusHint: "Automatically determines the current peak / off-peak window.",
615
1189
  tipWeekTitle: "Activity overview",
616
1190
  hoverCalls: "API calls",
@@ -689,7 +1263,7 @@ window.__ModuleLoader__.load({
689
1263
  segments: DEFAULT_SEGMENTS.slice(),
690
1264
  hidden: [],
691
1265
  currency: "CNY",
692
- models: Object.assign({}, DEFAULT_PRICES),
1266
+ models: normalizeModels(DEFAULT_PRICES, "CNY"),
693
1267
  modelOrder: Object.keys(DEFAULT_PRICES),
694
1268
  holidayDays: []
695
1269
  };
@@ -718,16 +1292,22 @@ window.__ModuleLoader__.load({
718
1292
  if (stored.indexOf(id) === -1) stored.push(id);
719
1293
  });
720
1294
  const hidden = hiddenStored.filter((id) => stored.indexOf(id) !== -1);
721
- /* 计价单位:只认 CNY / USD,其它值一律当人民币 */
1295
+ /* 计价单位:只认 CNY / USD,其它值一律当人民币。
1296
+ 迁移后它只是"**新增条目的默认币种**"—— 已有条目各自带币种
1297
+ (`models[name].currency`),任何选择器都不许再改写它们。 */
722
1298
  const currency = parsed.currency === "USD" ? "USD" : "CNY";
723
1299
  const reference = referencePrices(currency);
724
1300
  /* 价格库落后于参考价版本时并入官方价;用户自己改过的条目不覆盖 */
725
1301
  /* 价格库为空或全 0 时直接用内嵌官方价(自愈,无需手动填) */
726
- if (pricesMissing(normalizeModels(parsed.models))) parsed.models = Object.assign({}, reference);
1302
+ if (pricesMissing(normalizeModels(parsed.models, currency))) parsed.models = Object.assign({}, reference);
727
1303
  const priceVersion = typeof parsed.priceVersion === "number" ? parsed.priceVersion : 0;
1304
+ /* 价格库落后于参考价版本时并入官方价:**官方价打底、用户条目优先** ——
1305
+ 官方新增的模型补进来,但用户已经存在的条目(改过价的、明确填 0 的)一律不动。
1306
+ 原来把 reference 写在后面,结果是官方价盖掉用户改过的同名条目,
1307
+ 与上面那句"用户自己改过的条目不覆盖"正好相反(实测缺陷,用户确认要修)。 */
728
1308
  const models = priceVersion < PRICE_VERSION
729
- ? normalizeModels(Object.assign({}, parsed.models, reference))
730
- : normalizeModels(parsed.models);
1309
+ ? normalizeModels(Object.assign({}, reference, parsed.models), currency)
1310
+ : normalizeModels(parsed.models, currency);
731
1311
  /* 旧版取消勾选「峰谷计价」时会把空闲价写成高峰价的副本,再勾回来也回不去。
732
1312
  这里按参考价补回:只修"勾着峰谷计价、两档却完全相同"的官方模型。 */
733
1313
  Object.keys(reference).forEach((name) => {
@@ -748,14 +1328,13 @@ window.__ModuleLoader__.load({
748
1328
  });
749
1329
  return {
750
1330
  version: CONFIG_VERSION,
751
- priceConfigured: parsed.priceConfigured === true,
752
1331
  contextMeter: parsed.contextMeter !== false,
753
1332
  segments: stored,
754
1333
  hidden: hidden,
755
1334
  currency: currency,
756
1335
  priceVersion: PRICE_VERSION,
757
1336
  priceConfigured: priceConfigured,
758
- models: Object.keys(models).length > 0 ? models : normalizeModels(reference),
1337
+ models: Object.keys(models).length > 0 ? models : normalizeModels(reference, currency),
759
1338
  modelOrder: modelOrder.length > 0 ? modelOrder : Object.keys(reference),
760
1339
  /* 抓来的放假日历:字符串数组,按年分组在 refreshHolidays() 里做 */
761
1340
  holidayDays: Array.isArray(parsed.holidayDays)
@@ -790,31 +1369,126 @@ window.__ModuleLoader__.load({
790
1369
  if (on !== true) hidden.push(id);
791
1370
  setConfig({ hidden: hidden });
792
1371
  }
1372
+
793
1373
  /**
794
- * 换计价单位:内置参考价模型整体换到另一种币种的那套官方价,
795
- * 只动"还没被改过"的条目 —— 手改过的价格保留,别覆盖用户的输入。
1374
+ * 官方内置价:某个价格键在**两套官方参考价表**里都有时,给出"该币种那一套定死的数字"。
1375
+ *
1376
+ * **判定规则**(与用户确认过):键名必须**逐字**同时命中 `DEFAULT_PRICES` 与
1377
+ * `DEFAULT_PRICES_USD`(不做模糊匹配 —— 用户自建的键、或官方表里没有的键一律算"非内置")。
1378
+ * 返回 `{ CNY: { peak, offPeak }, USD: { peak, offPeak } }`,找不到返回 `null`。
796
1379
  */
797
- function switchCurrency(code) {
798
- const next = code === "USD" ? "USD" : "CNY";
799
- if (next === config.currency) return null;
800
- const from = referencePrices(config.currency);
801
- const to = referencePrices(next);
1380
+ function officialPair(name) {
1381
+ const cny = DEFAULT_PRICES[name];
1382
+ const usd = DEFAULT_PRICES_USD[name];
1383
+ if (cny === undefined || cny === null || usd === undefined || usd === null) return null;
1384
+ return {
1385
+ CNY: { peak: normalizeTier(cny.peak), offPeak: normalizeTier(cny.offPeak) },
1386
+ USD: { peak: normalizeTier(usd.peak), offPeak: normalizeTier(usd.offPeak) }
1387
+ };
1388
+ }
1389
+
1390
+ /**
1391
+ * 改**某一个模型条目**的币种。**不乘除任何比率** —— 数值只有两种去向:
1392
+ *
1393
+ * ① **官方内置且没被改过**(键同时命中两套官方表,且当前两档数字逐格等于该币种那一套官方值)
1394
+ * → 直接换成**官方另一套定死的数字**(逐字段等于目标币种的官方值)。用户的说法:
1395
+ * "默认两个模型的美元价格和人民币价格是定死的呀,你怎么能自己去按汇率切换呢"。
1396
+ * ② 其余(用户自建的键、官方没有的键、或**被改过数值**的条目)→ **数值一个字都不动**,
1397
+ * 只改 `currency` 标记 —— 另一种币种该填多少由用户自己填,工具不替他算。
1398
+ * "改过数值"就是这么识别的:拿当前两档跟当前币种的官方值逐格比,不一致即视为改过。
1399
+ *
1400
+ * 只动这一个条目:其它条目、台账、历史数字、`config.currency` 全都不动。
1401
+ *
1402
+ * `draftNow` 是**正在编辑的草稿**(可省)。"改没改过"必须看**用户此刻在框里看到的值**,
1403
+ * 不能只看库里的值:用户改了价还没保存就切币种时,库里的值仍是旧的官方值,
1404
+ * 会误判成"没改过"→ 整条换成官方另一套 → 用户刚填的数字被清空(实测反馈)。
1405
+ *
1406
+ * 返回 `{ entry, replaced }`;没变化返回 `null`。
1407
+ * `replaced === true`(官方内置、且草稿仍是该币种的官方值)→ 整条换成官方另一套,
1408
+ * 调用方必须按新数字重建草稿;否则数值一个字没动,调用方只该换草稿的币种标记,
1409
+ * **保住用户还没保存的输入**。
1410
+ */
1411
+ function setModelCurrency(name, code, draftNow) {
1412
+ const entry = config.models === undefined || config.models === null ? null : config.models[name];
1413
+ if (entry === null || entry === undefined) return null;
1414
+ const from = entry.currency === undefined || entry.currency === null ? normalizeCurrency(config.currency) : normalizeCurrency(entry.currency);
1415
+ const to = normalizeCurrency(code);
1416
+ if (from === to) return null;
1417
+ const official = officialPair(name);
1418
+ const current = { peak: normalizeTier(entry.peak), offPeak: normalizeTier(entry.offPeak) };
1419
+ /* 草稿里的格子是字符串(`""` = 没填),`normalizeTier` 用同一套 `tierValue` 口径转成
1420
+ null / 数字 —— 与保存时落库的口径必须一致,否则"算不算改过"会和落库结果打架。 */
1421
+ const shown = draftNow === null || draftNow === undefined
1422
+ ? current
1423
+ : { peak: normalizeTier(draftNow.peak), offPeak: normalizeTier(draftNow.offPeak) };
1424
+ const isPristineOfficial = official !== null
1425
+ && sameTier(shown.peak, official[from].peak) && sameTier(shown.offPeak, official[from].offPeak);
1426
+ const next = {
1427
+ tiered: isPristineOfficial === true ? true : entry.tiered !== false,
1428
+ currency: to,
1429
+ peak: isPristineOfficial === true ? Object.assign({}, official[to].peak) : current.peak,
1430
+ offPeak: isPristineOfficial === true ? Object.assign({}, official[to].offPeak) : current.offPeak
1431
+ };
802
1432
  const models = Object.assign({}, config.models);
803
- Object.keys(to).forEach((name) => {
804
- const current = models[name];
805
- const before = from[name];
806
- if (current === undefined || current === null || before === undefined) return;
807
- if (!sameTier(current.peak, before.peak) || !sameTier(current.offPeak, before.offPeak)) return;
1433
+ models[name] = next;
1434
+ setConfig({ models: models });
1435
+ return { entry: next, replaced: isPristineOfficial === true };
1436
+ }
1437
+
1438
+ /**
1439
+ * 「恢复默认」要写回的价格库(**分两类**,两条要求同时满足):
1440
+ *
1441
+ * ① **官方内置**(键在 `DEFAULT_PRICES` 里命中)→ 恢复成**官方默认值**:逐字段等于
1442
+ * 官方表(`tiered` 与峰谷两档一起,连官方写的 `0`/`null` 也照抄),币种回 **CNY**;
1443
+ * **被删掉的官方条目会被加回来**;顺序按官方表顺序排在**前面**。
1444
+ * **取舍**:用户改过某个官方模型的价格,恢复默认会把它覆盖回官方价 —— 这就是
1445
+ * "恢复默认"的语义;想留住自定义价就别用官方键(换个名字建自建条目)。
1446
+ * ② **用户自建**(键不在官方表里)→ **原样保留**:数值、档位、`currency` 逐字段不变
1447
+ * (含用户明确填的 0),顺序跟在官方条目之后、保持原来的相对顺序。
1448
+ *
1449
+ * 判定与顺序(三条口径):
1450
+ * · "官方内置"以 **`DEFAULT_PRICES` 的键**为准 —— 它才代表"官方默认价"这一集合;
1451
+ * (`DEFAULT_PRICES` 与 `DEFAULT_PRICES_USD` 的键集合**完全一致**,见 lib 顶部两张表,
1452
+ * 所以不需要取并集。哪天两张表键集合不同了,这里要改成并集。)
1453
+ * · 官方顺序 = **`Object.keys(DEFAULT_PRICES)` 的字面顺序**,不另外定义一套;
1454
+ * · `modelOrder` = 官方键(官方顺序)在前 + 保留下来的自建键(原相对顺序)在后。
1455
+ * **用户在官方键上的排序调整会被覆盖**("恢复默认"的语义),自建键的相对顺序保留。
1456
+ *
1457
+ * 只产出配置内容,不碰台账、不换算任何历史数字。
1458
+ */
1459
+ function resetPriceBook() {
1460
+ const models = {};
1461
+ /* ① 官方:固定顺序、固定值 */
1462
+ Object.keys(DEFAULT_PRICES).forEach((name) => {
1463
+ const official = DEFAULT_PRICES[name];
808
1464
  models[name] = {
809
- tiered: current.tiered !== false,
810
- peak: Object.assign({}, to[name].peak),
811
- offPeak: Object.assign({}, to[name].offPeak)
1465
+ tiered: true,
1466
+ currency: "CNY",
1467
+ peak: normalizeTier(official.peak),
1468
+ offPeak: normalizeTier(official.offPeak)
812
1469
  };
813
1470
  });
814
- setConfig({ currency: next, models: models });
815
- /* 把换完的价格回给调用方:正在编辑的草稿要拿它重建,否则框里还是旧币种的数字 */
816
- return { currency: next, models: models };
1471
+ /* ② 自建:原样搬过来(顺序取 modelOrder 的相对顺序,不在其中的补在后面) */
1472
+ const source = config.models === undefined || config.models === null ? {} : config.models;
1473
+ const modelOrder = Object.keys(DEFAULT_PRICES).slice();
1474
+ const walk = (Array.isArray(config.modelOrder) ? config.modelOrder : []).concat(Object.keys(source));
1475
+ walk.forEach((name) => {
1476
+ if (models[name] !== undefined || modelOrder.indexOf(name) !== -1) return;
1477
+ const entry = source[name];
1478
+ if (entry === undefined || entry === null) return;
1479
+ models[name] = {
1480
+ tiered: entry.tiered !== false,
1481
+ currency: entry.currency === undefined || entry.currency === null
1482
+ ? normalizeCurrency(config.currency)
1483
+ : normalizeCurrency(entry.currency),
1484
+ peak: normalizeTier(entry.peak),
1485
+ offPeak: normalizeTier(entry.offPeak)
1486
+ };
1487
+ modelOrder.push(name);
1488
+ });
1489
+ return { models: models, modelOrder: modelOrder };
817
1490
  }
1491
+
818
1492
  /** 该段是否勾选显示:在段序里且不在 hidden 里。 */
819
1493
  function isSegmentOn(cfg, id) {
820
1494
  if (Array.isArray(cfg.segments) && cfg.segments.indexOf(id) === -1) return false;
@@ -1005,6 +1679,26 @@ window.__ModuleLoader__.load({
1005
1679
  };
1006
1680
  }
1007
1681
 
1682
+ /**
1683
+ * 没有占用数据时的圆环与面板:空环 + 横杠(`empty` 标记让面板区分"没数据"与"0%")。
1684
+ *
1685
+ * 圆环是固定在栏首的底栏项,**不能因为拿不到数据就整块消失**:会话刚发出去就被
1686
+ * 中断时最容易撞上 —— 那一刻 provider 还没报过上下文压力,整块一没,底栏就少一项,
1687
+ * 看上去像插件坏了(实测 2026-10-09)。缺数据的面板与令牌面板同口径:一律横杠,
1688
+ * 保证任何状态下都点得开。
1689
+ */
1690
+ function dashMeter() {
1691
+ return {
1692
+ percent: 0,
1693
+ used: "-",
1694
+ window: "-",
1695
+ rows: [],
1696
+ segments: [],
1697
+ compact: null,
1698
+ empty: true
1699
+ };
1700
+ }
1701
+
1008
1702
  /** 条的轨道底色,与 .dsb-bar 的 CSS 同一变量,内联覆盖时不留色差。 */
1009
1703
  const BAR_TRACK = "var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15))";
1010
1704
 
@@ -1113,7 +1807,7 @@ window.__ModuleLoader__.load({
1113
1807
  .split(READING_SLOT).map((part) => part.trim());
1114
1808
  const head = [];
1115
1809
  if (headBefore.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__hb" }, headBefore));
1116
- head.push(h("span", { className: "dsb-meter-percent", key: "__hp" }, meter.percent + "%"));
1810
+ head.push(h("span", { className: "dsb-meter-percent", key: "__hp" }, meter.empty === true ? "-" : meter.percent + "%"));
1117
1811
  if (headAfter.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__ha" }, headAfter));
1118
1812
  head.push(h("span", { className: "dsb-meter-figures", key: "__hf" }, meter.used + " / " + meter.window));
1119
1813
  const rows = meter.rows.map((row, index) => h("span", { className: "dsb-meter-row", key: "__mr" + index }, [
@@ -1206,7 +1900,16 @@ window.__ModuleLoader__.load({
1206
1900
  tool: "var(--dsw-static-blue-450)"
1207
1901
  };
1208
1902
 
1209
- /** 本地日期键,用来认「今天」那根柱子。 */
1903
+ /**
1904
+ * 日期键(YYYY-MM-DD),用来认「今天」那根柱子。
1905
+ *
1906
+ * 按**装机者所在时区**取,与 host 的 localDateKey 同一口径:使用者看的是自己
1907
+ * 日历上的今天/昨天。曾经固定用北京时间,UTC+8 装机时看不出差别,非 UTC+8 的
1908
+ * 机器上整块活跃总览会错一天(「今天」高亮成昨天、未来/过去的灰阶也偏)。
1909
+ *
1910
+ * 注意与 isPeakTime 区分:**峰谷计价**仍按北京时间,那是 DeepSeek 的官方规则;
1911
+ * 只有「日期归属」跟着装机者时区走。
1912
+ */
1210
1913
  function dateKeyOf(ms) {
1211
1914
  const d = new Date(ms === undefined || ms === null ? Date.now() : ms);
1212
1915
  const pad = (n) => (n < 10 ? "0" + n : String(n));
@@ -1459,7 +2162,6 @@ window.__ModuleLoader__.load({
1459
2162
  const hasPeak = pkHit + pkMiss + pkOut > 0;
1460
2163
  return h("span", {
1461
2164
  className: "dsb-day"
1462
- + (day.date === tip.today ? " dsb-day-today" : "")
1463
2165
  + (activeBar === index ? " dsb-day-active" : ""),
1464
2166
  key: "__wd" + index,
1465
2167
  /* 悬停整列都让判定区变灰 —— 没有用量的天也一样;浮层只在有用量时弹 */
@@ -1760,16 +2462,67 @@ window.__ModuleLoader__.load({
1760
2462
  return { title: title, rows: names.map((name) => ({ label: name })) };
1761
2463
  }
1762
2464
 
1763
- /** 「工具调用」子视图:按调用次数降序,同次数按名字排(顺序稳定,不随插入次序跳)。
2465
+ /**
2466
+ * 工具名的中文显示名。工具名本身是会话日志里的键(英文标识),这里换成读者看得懂的词;
2467
+ * **没列到的工具照原样显示** —— 插件工具、MCP 工具随时可能出现,不能因为查不到就留白。
2468
+ * 只在中文界面用;英文界面直接用原名。
2469
+ */
2470
+ const TOOL_LABELS_ZH = {
2471
+ pwsh: "命令行",
2472
+ bash: "命令行",
2473
+ read: "读取文件",
2474
+ write: "写入文件",
2475
+ edit: "修改文件",
2476
+ glob: "查找文件",
2477
+ grep: "搜索内容",
2478
+ todo_write: "待办清单",
2479
+ skill: "技能",
2480
+ subagent: "子智能体",
2481
+ subagent_fork: "子智能体(继承)",
2482
+ ask_user_question: "询问用户",
2483
+ create_goal: "新建目标",
2484
+ update_goal: "更新目标",
2485
+ get_goal: "查看目标",
2486
+ present: "展示文件",
2487
+ web_search: "联网搜索",
2488
+ web_fetch: "抓取网页",
2489
+ job_list: "后台任务列表",
2490
+ job_output: "后台任务输出",
2491
+ job_kill: "取消后台任务",
2492
+ spawn_teammate: "新增队友",
2493
+ send_message: "发送消息",
2494
+ wait_agent: "等待队友",
2495
+ interrupt_agent: "中断队友",
2496
+ list_agents: "队友列表",
2497
+ team_task_create: "新建任务",
2498
+ team_task_list: "任务列表",
2499
+ team_task_get: "查看任务",
2500
+ team_task_update: "更新任务",
2501
+ exit_plan_mode: "退出计划模式",
2502
+ load_workspace_dependencies: "加载工作区依赖",
2503
+ find_dsh_plugin: "查找插件",
2504
+ workflow: "工作流"
2505
+ };
2506
+
2507
+ /** 工具名 → 显示名:中文界面查表(查不到给原名),英文界面直接用原名。 */
2508
+ function toolLabel(name, t) {
2509
+ if (detectLocale(t) !== "zh") return name;
2510
+ const label = TOOL_LABELS_ZH[name];
2511
+ return label === undefined ? name : label;
2512
+ }
2513
+
2514
+ /** 「工具调用」子视图:**先按显示名长短(短的在前),同名长短再按调用次数降序**。
1764
2515
  名字短、列多,标 tight 让名字与次数之间的间隙收到 1ch。 */
1765
2516
  function toolList(counts, t) {
1766
2517
  const title = t("tipCountsToolCalls");
1767
2518
  if (counts === null || typeof counts !== "object") return Object.assign(emptyList(title), { tight: true });
1768
2519
  const rows = Object.keys(counts)
1769
- .map((name) => ({ name: name, count: Number(counts[name]) || 0 }))
2520
+ .map((name) => ({ label: toolLabel(name, t), count: Number(counts[name]) || 0 }))
1770
2521
  .filter((one) => one.count > 0)
1771
- .sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)))
1772
- .map((one) => ({ label: one.name, value: formatTokensExact(one.count) + t("unitCountCalls") }));
2522
+ .sort((a, b) => a.label.length - b.label.length
2523
+ || b.count - a.count
2524
+ || (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)))
2525
+ .map((one) => ({ label: one.label, value: formatTokensExact(one.count) + t("unitCountCalls") }));
1773
2526
  if (rows.length === 0) return Object.assign(emptyList(title), { tight: true });
1774
2527
  return { title: title, rows: rows, tight: true };
1775
2528
  }
@@ -1896,15 +2649,17 @@ window.__ModuleLoader__.load({
1896
2649
  month: ok && daily.month !== undefined && daily.month !== null ? daily.month : null,
1897
2650
  /* 当年 12 个月聚合:标题下面那 12 个方块用 */
1898
2651
  year: ok && daily.year !== undefined && daily.year !== null ? daily.year : null,
1899
- today: dateKeyOf(src.now),
1900
- empty: ok !== true
2652
+ today: dateKeyOf(src.now)
1901
2653
  };
1902
2654
  }
1903
2655
 
1904
2656
  function segmentView(id, src, t) {
1905
2657
  const stats = src.stats;
1906
2658
  const usage = src.usage;
1907
- const symbol = currencySymbol(src.currency);
2659
+ /* 底栏费用用的币种 = **显示币种**(主模型条目的币种,见 displayCurrencyOf),
2660
+ 不再是那个全局的"新增条目默认币种" —— 用户报的正是"没用过的模型也能改底栏币种"。 */
2661
+ const symbolCurrency = normalizeCurrency(src.currency);
2662
+ const symbol = currencySymbol(symbolCurrency);
1908
2663
 
1909
2664
  if (id === "status") {
1910
2665
  /* 拆分后这一段只出峰谷两个字:上下文占用圆环是独立开关,固定挂在栏首。
@@ -1926,6 +2681,7 @@ window.__ModuleLoader__.load({
1926
2681
  ? null
1927
2682
  : progress.globalInstruction;
1928
2683
  const numOf = (key) => progress === null || typeof progress[key] !== "number" ? null : progress[key];
2684
+ /* 全局提示词(AGENTS.md 这类)也算一次注入,所以并进技能注入那个数里 */
1929
2685
  const skillCount = skills === null && globalInstruction === null
1930
2686
  ? null
1931
2687
  : (skills === null ? 0 : skills.length) + (globalInstruction === null ? 0 : 1);
@@ -1957,8 +2713,12 @@ window.__ModuleLoader__.load({
1957
2713
  }
1958
2714
 
1959
2715
  if (id === "ttft") {
1960
- if (stats === undefined || stats === null || !(stats.ttftSteps > 0) || !(stats.ttftMs > 0)) return null;
1961
- const duration = formatSeconds(stats.ttftMs / stats.ttftSteps);
2716
+ /* 会话级首字平均 = (本会话 ttftMs + 全部子会话 ttftMs) / (本会话 ttftSteps + 子 ttftSteps):
2717
+ 用户要求「首字平均」把子智能体算上。**气泡里那个"本轮平均首字"仍只算本页面**
2718
+ (用户明确要求),所以它照旧读 host 的 timing 投影(那本身就是 per-session 的)。 */
2719
+ const merged = mergedSessionStats(stats, subagentStatsOf(src.subagent));
2720
+ if (!(merged.ttftSteps > 0) || !(merged.ttftMs > 0)) return null;
2721
+ const duration = formatSeconds(merged.ttftMs / merged.ttftSteps);
1962
2722
  if (duration === null) return null;
1963
2723
  const timing = src.timing === undefined || src.timing === null ? null : src.timing;
1964
2724
  return {
@@ -1978,9 +2738,14 @@ window.__ModuleLoader__.load({
1978
2738
 
1979
2739
  if (id === "cacheHit") {
1980
2740
  if (usage === undefined || usage === null) return null;
1981
- const denominator = billedInputTokens(usage);
2741
+ /* 会话级命中率用**合并后的用量**重算:分母口径与本面板一致
2742
+ (命中 + 未命中,未命中 = 未缓存输入 + 缓存写入),只是把队友那一份也加进来。
2743
+ 气泡里"本轮命中率"照旧只算本页面(用户明确要求)。 */
2744
+ const sub = subagentOf(src.subagent, "session");
2745
+ const denominator = billedInputTokens(usage) + sub.cached + sub.missInput;
1982
2746
  if (denominator <= 0) return null;
1983
- const percent = Math.min(99.99, ((usage.cacheReadTokens || 0) / denominator) * 100).toFixed(2);
2747
+ const numerator = (usage.cacheReadTokens || 0) + sub.cached;
2748
+ const percent = Math.min(99.99, (numerator / denominator) * 100).toFixed(2);
1984
2749
  const extremes = cacheHitExtremes(src.sessionUsage);
1985
2750
  return {
1986
2751
  id: id,
@@ -1999,8 +2764,11 @@ window.__ModuleLoader__.load({
1999
2764
  }
2000
2765
 
2001
2766
  if (id === "tps") {
2002
- if (stats === undefined || stats === null || !(stats.decodeMs > 0) || !(stats.decodeTokens > 0)) return null;
2003
- const throughput = formatThroughput(stats.decodeTokens / (stats.decodeMs / 1000));
2767
+ /* 会话级综合速度 = (本会话 decodeTokens + 子 decodeTokens) / ((本会话 decodeMs + 子 decodeMs)/1000):
2768
+ 用户要求「综合速度」把子智能体算上。气泡里"本轮平均速度"仍只算本页面(明确要求)。 */
2769
+ const merged = mergedSessionStats(stats, subagentStatsOf(src.subagent));
2770
+ if (!(merged.decodeMs > 0) || !(merged.decodeTokens > 0)) return null;
2771
+ const throughput = formatThroughput(merged.decodeTokens / (merged.decodeMs / 1000));
2004
2772
  const timing = src.timing === undefined || src.timing === null ? null : src.timing;
2005
2773
  return {
2006
2774
  id: id,
@@ -2069,7 +2837,9 @@ window.__ModuleLoader__.load({
2069
2837
  ? []
2070
2838
  : sessionUsage.calls;
2071
2839
  /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
2072
- 累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。 */
2840
+ 累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。
2841
+ **每条还要按它自己条目的币种折到显示币种**:会话里 A 是人民币、B 是美元时,
2842
+ 底栏给的是一个数字(折到主模型那种币),不是两种货币相加。 */
2073
2843
  const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
2074
2844
  let total = 0;
2075
2845
  let priced = false;
@@ -2078,14 +2848,42 @@ window.__ModuleLoader__.load({
2078
2848
  const one = costOf(calls[i], callModel, calls[i].at);
2079
2849
  if (one === null) continue;
2080
2850
  priced = true;
2081
- total += one;
2851
+ total += toDisplayCurrency(one, src.models, callModel, symbolCurrency, src.defaultCurrency);
2852
+ }
2853
+ /* 被 host 的 MAX_CALLS 截掉的那部分(host 按「模型 + 峰谷档」累计好给回来)。
2854
+ 不补这一段的话,长会话的费用只算了最近 4000 条 —— 实测 5000 次少 20%、
2855
+ 2 万次少 80%,而「总计用量」用的是官方全量数据,两者会在同一个界面上对不上。
2856
+ 档位由 host 按北京时间判定(与逐条计价同一套规则),所以这里直接按档取价即可。 */
2857
+ const overflow = sessionUsage === undefined || sessionUsage === null
2858
+ || sessionUsage.overflow === undefined || sessionUsage.overflow === null
2859
+ || typeof sessionUsage.overflow !== "object"
2860
+ ? {}
2861
+ : sessionUsage.overflow;
2862
+ const overflowNames = Object.keys(overflow);
2863
+ for (let i = 0; i < overflowNames.length; i += 1) {
2864
+ const overflowModel = overflowNames[i];
2865
+ const byTier = overflow[overflowModel];
2866
+ if (byTier === undefined || byTier === null) continue;
2867
+ const peakCost = costOfTier(byTier.peak, overflowModel, true);
2868
+ const offPeakCost = costOfTier(byTier.offPeak, overflowModel, false);
2869
+ if (peakCost === null && offPeakCost === null) continue;
2870
+ priced = true;
2871
+ total += toDisplayCurrency((peakCost === null ? 0 : peakCost) + (offPeakCost === null ? 0 : offPeakCost),
2872
+ src.models, overflowModel, symbolCurrency, src.defaultCurrency);
2082
2873
  }
2874
+ /* 队友(子智能体)是**独立会话**,它的用量不在上面这份本会话调用表里,
2875
+ host 按 parentSession 算好单独给回来;这里直接把数字并进「总计」,
2876
+ 不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。
2877
+ 两段数据(整会话 / 本轮窗口)各自拿到才用,没拿到的那段当 0。 */
2878
+ const sub = subagentOf(src.subagent, "session");
2879
+ const subCost = subagentCostOf(src.subagent, "session", symbolCurrency);
2880
+ const grand = total + subCost;
2083
2881
  return {
2084
2882
  id: id,
2085
- /* 算不出价(价格库里没这个模型)时给 ¥-:与"这笔真的是 0"区分开 */
2086
- text: t("f_cost", { symbol: symbol, cost: priced ? costText(total) : "-" }),
2087
- /* token 计数并进总计:点这一段看会话的 token 明细 */
2088
- tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
2883
+ /* 算不出价(本会话与队友都没价)时给横杠:与"这笔真的是 0"区分开 */
2884
+ text: t("f_cost", { symbol: symbol, cost: priced === true || subCost > 0 ? costText(grand) : "-" }),
2885
+ /* token 计数并进总计:本会话的 + 队友的,同一个数字相加,不新增行 */
2886
+ tip: tokensTip(mergeTokenParts(sessionTokenParts(src.usage), sub), t("f_tokensTotal"), t)
2089
2887
  };
2090
2888
  }
2091
2889
 
@@ -2113,13 +2911,39 @@ window.__ModuleLoader__.load({
2113
2911
  turnParts.output += turnList[i].output || 0;
2114
2912
  if (one === null) continue;
2115
2913
  turnPriced = true;
2116
- turnTotal += Number(one);
2914
+ /* 同样按每条自己的条目币种折到显示币种(见「总计」那段的注释) */
2915
+ turnTotal += toDisplayCurrency(Number(one), src.models, callModel, symbolCurrency, src.defaultCurrency);
2117
2916
  }
2118
- const turnTip = tokensTip(billableParts(turnParts), t("f_tokensTurn"), t);
2119
- if (turnPriced) {
2917
+ /* 本轮也要含队友,但**只取"本轮窗口"那一份**(host 按客户端传的起点、
2918
+ 或它自己从父会话日志推的起点算出来的)。两处起点都没有时 host 返回全 0 ⇒
2919
+ 本轮一点队友都不并:用户明确要求「本轮的数值只能小不能大」,宁可少算,
2920
+ 也绝不能把队友的整会话花费当成"本轮"塞进来。总计那一项不受影响 ——
2921
+ 它自己那一问显式带 since=0,按整会话口径并队友。 */
2922
+ const subTurn = subagentOf(src.subagent, "turn");
2923
+ /* 队友这一段折到显示币种后的金额(逐桶折算,见 subagentDisplayCost) */
2924
+ const subTurnCost = subagentCostOf(src.subagent, "turn", symbolCurrency);
2925
+ const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t);
2926
+ /* 「本轮」同轮内只增不减(用户实测要求):把这次的金额过一遍粘性下限。
2927
+ 窗口起点在一轮内可能移动(父会话日志多一条 user/message、折叠出的最早时间戳变化、
2928
+ host 收敛起点),窗口一变数字就会小一截;这里把已经显示过的最大值兜住,
2929
+ 换轮/换会话则作废重算(见 applyTurnFloor)。下限只作用于**金额**,
2930
+ 气泡里的 token 明细照实显示本次真正拿到的那份。 */
2931
+ const holdBox = src.lastCostHold;
2932
+ const holdKey = src.lastCostTurn === undefined ? null : src.lastCostTurn;
2933
+ const settleAmount = (amount) => {
2934
+ const next = applyTurnFloor(holdBox, src.sessionId, holdKey, amount);
2935
+ if (holdBox !== null && holdBox !== undefined && typeof holdBox === "object") {
2936
+ holdBox.sessionId = next.hold.sessionId;
2937
+ holdBox.turnKey = next.hold.turnKey;
2938
+ holdBox.value = next.hold.value;
2939
+ }
2940
+ return next.value;
2941
+ };
2942
+ const amountText = (amount) => (amount === null || amount === undefined ? "-" : costText(amount));
2943
+ if (turnPriced === true || subTurnCost > 0) {
2120
2944
  return {
2121
2945
  id: id,
2122
- text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
2946
+ text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(turnTotal + subTurnCost)) }),
2123
2947
  /* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
2124
2948
  tip: turnTip
2125
2949
  };
@@ -2139,39 +2963,66 @@ window.__ModuleLoader__.load({
2139
2963
  if (active === true && bucket !== null && sameTurn === true) {
2140
2964
  const runningCost = costOf(bucket, fallbackModel, src.now);
2141
2965
  if (runningCost !== null) {
2142
- return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip: turnTip };
2966
+ return { id: id, text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(toDisplayCurrency(runningCost, src.models, fallbackModel, symbolCurrency, src.defaultCurrency) + subTurnCost)) }), tip: turnTip };
2143
2967
  }
2144
2968
  }
2145
2969
  /* 新一轮已经开跑、但还没有属于它的调用:必须归零,不能退回上一轮
2146
2970
  —— 节点折叠曾错位一轮,底栏显示成上上轮的金额,这条就是为堵它留的。
2147
- 一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠。
2148
- 等这一轮真的产生了调用,上面那条精确口径就接管了。 */
2971
+ 一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠;
2972
+ 但队友已经在本轮里花了钱的话,就把那一份显示出来。
2973
+ 等这一轮真的产生了调用,上面那条精确口径就接管了。
2974
+ 「粘性下限」在这里也生效:同轮内刚才显示过更大的数字,就不因为这一拍拿不到
2975
+ 本轮数据而跳回 0(换轮时 holdKey 变了,下限自然作废)。 */
2149
2976
  if (active === true) {
2150
2977
  const settled = src.sessionUsage !== undefined && src.sessionUsage !== null
2151
2978
  && Array.isArray(src.sessionUsage.calls) === true
2152
2979
  && src.sessionUsage.calls.length > 0;
2980
+ const live = settled === true || subTurnCost > 0 ? subTurnCost : null;
2153
2981
  return {
2154
2982
  id: id,
2155
- text: t("f_lastCost", { symbol: symbol, cost: settled ? costText(0) : "-" }),
2983
+ text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(live)) }),
2156
2984
  tip: turnTip
2157
2985
  };
2158
2986
  }
2159
2987
  /* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额。
2160
2988
  没有可用数据时也给 ¥0.00:本轮不整段消失,底栏长度稳定,
2161
- 也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。 */
2989
+ 也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。
2990
+ 队友那一份只有"本轮窗口"(拿不到起点就是 0),所以这里的数字只会
2991
+ 比"只算本会话"时更大一点点、绝不会变成整会话的花费。 */
2162
2992
  const cost = bucket === null ? null : costOf(bucket, fallbackModel, src.now);
2993
+ const idleCost = cost === null ? null : toDisplayCurrency(cost, src.models, fallbackModel, symbolCurrency, src.defaultCurrency);
2994
+ const idleValue = idleCost === null && subTurnCost === 0 ? null : (idleCost === null ? 0 : idleCost) + subTurnCost;
2163
2995
  return {
2164
2996
  id: id,
2165
- text: t("f_lastCost", { symbol: symbol, cost: cost === null ? "-" : costText(cost) }),
2997
+ text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(idleValue)) }),
2166
2998
  tip: turnTip
2167
2999
  };
2168
3000
  }
2169
3001
 
2170
3002
  if (id === "balance") {
3003
+ /* 这个会话的余额是不是「积分」口径(WorkBuddy 等):积分不带货币符号,
3004
+ 气泡里也只给总数、不列构成。成功响应自己带 isCredits;拿不到响应时
3005
+ (查询中 / 查询失败)按会话 provider 判断,免得积分会话在失败态下又冒出个 ¥。 */
3006
+ const balanceIsCredits = (value) => {
3007
+ if (value !== null && value !== undefined && value.isCredits === true) return true;
3008
+ const model = src.sessionModel;
3009
+ const provider = model === null || model === undefined || model.provider === undefined || model.provider === null
3010
+ ? ""
3011
+ : String(model.provider).toLowerCase();
3012
+ return provider.includes("workbuddy") || provider.includes("codebuddy");
3013
+ };
2171
3014
  /* 查不到余额(还没查到 / 该平台没配 key / 查询失败)时用同一套两行结构,值都是横杠:
2172
- 气泡里仍然是「充值余额 / 赠金余额」两行,只是没有数字。 */
3015
+ 气泡里仍然是「充值余额 / 赠金余额」两行,只是没有数字。
3016
+ 积分口径没有构成可分,这两行不给 —— 而且它整个气泡都不该出现(见下)。 */
2173
3017
  const dashBalance = () => {
2174
- const dashSymbol = currencySymbol(src.currency);
3018
+ const credits = balanceIsCredits(src.balance);
3019
+ const dashSymbol = credits ? "" : currencySymbol(src.currency);
3020
+ if (credits === true) {
3021
+ /* 积分口径不给 tip:段没有 tip 就不会变成可点击项(见渲染处的 tip !== null),
3022
+ 点不出、悬停也不弹。积分只有一个总数、没有可展开的内容,
3023
+ 弹一个只有标题的空气泡只会让人以为能点。 */
3024
+ return { id: id, text: t("f_balanceDash", { symbol: dashSymbol }) };
3025
+ }
2175
3026
  return {
2176
3027
  id: id,
2177
3028
  text: t("f_balanceDash", { symbol: dashSymbol }),
@@ -2192,8 +3043,19 @@ window.__ModuleLoader__.load({
2192
3043
  if (balance.ok !== true) return dashBalance();
2193
3044
  if (typeof balance.total !== "string") return dashBalance();
2194
3045
  /* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
2195
- /* 百分比口径(GLM 配额)不带货币符号,否则会显示成 ¥37.50% */
2196
- const balSymbol = balance.isPercent === true ? "" : currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
3046
+ /* 百分比口径(GLM 配额)与积分口径(WorkBuddy)都不带货币符号,
3047
+ 否则会显示成 ¥37.50% / ¥177 这种把积分当钱的写法 */
3048
+ const isCredits = balanceIsCredits(balance);
3049
+ const balSymbol = balance.isPercent === true || isCredits === true
3050
+ ? ""
3051
+ : currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
3052
+ /* 积分口径:只有一个总积分,没有「充值 / 赠金」构成可展开 ⇒ 不给 tip。
3053
+ 段没有 tip 就不会被渲染成可点击项(渲染处按 tip !== null 决定 className
3054
+ 与 onClick),所以气泡点不开、悬停也不弹 —— 这正是要的:
3055
+ 弹一个只有标题、没有任何内容的空气泡只会让人以为还能点。 */
3056
+ if (isCredits === true) {
3057
+ return { id: id, text: t("f_balance", { symbol: balSymbol, amount: balance.total }) };
3058
+ }
2197
3059
  /* 分隔线换成色条,两种情况各一套:
2198
3060
  金额余额(DeepSeek 等)= 充值 : 赠金,按金额占比分两段(紫 / 蓝);
2199
3061
  百分比配额(GLM 等)= 已用 : 剩余,未用那段用「系统提示词」的同款灰。
@@ -2253,21 +3115,23 @@ window.__ModuleLoader__.load({
2253
3115
  function detectLocale(t) {
2254
3116
  return t("segCounts") === zh.segCounts ? "zh" : "en";
2255
3117
  }
2256
- function segmentText(id, view, t) {
3118
+ function segmentText(id, view, t, currency) {
2257
3119
  if (view !== null && view !== undefined) return view.text;
2258
3120
  const segment = SEGMENTS.filter((s) => s.id === id)[0];
2259
3121
  if (segment === undefined || segment.short === undefined) return "";
2260
3122
  const locale = detectLocale(t);
2261
3123
  const label = segment.short[locale] !== undefined ? segment.short[locale] : segment.short.zh;
2262
3124
  const dash = "-";
3125
+ /* 横杠占位也用**显示币种**的符号(与有数据时一致);没传就退回默认币种 */
3126
+ const symbol = currencySymbol(normalizeCurrency(currency === undefined || currency === null ? config.currency : currency));
2263
3127
  if (id === "counts") return dash + t("unitCountTurns") + " " + dash + t("unitCountSteps");
2264
3128
  if (id === "cacheHit") return label + " " + dash + " %";
2265
3129
  if (id === "ttft") return label + " " + dash + " s";
2266
3130
  /* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
2267
3131
  if (id === "tps") return label + " " + dash;
2268
3132
  if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
2269
- if (id === "cost" || id === "lastCost") return label + " " + currencySymbol(config.currency) + dash;
2270
- if (id === "balance") return t("f_balanceDash", { symbol: currencySymbol(config.currency) });
3133
+ if (id === "cost" || id === "lastCost") return label + " " + symbol + dash;
3134
+ if (id === "balance") return t("f_balanceDash", { symbol: symbol });
2271
3135
  return dash;
2272
3136
  }
2273
3137
  /* ---------------------------------------------------------------- css */
@@ -2276,8 +3140,9 @@ window.__ModuleLoader__.load({
2276
3140
  * 两条不依赖官方构建 hash 的要点:
2277
3141
  * 1. 用 .dsb-root 认自己,而不是官方组件的类名(hash 会随 DSH 更新变,2.0.10 踩过);
2278
3142
  * 2. 自己带标记与“后代里带标记”都要判 —— :has() 只看后代,漏掉前者会把自己的行也藏掉。
2279
- * 整条规则挂在 html.dsb-on 上:设置页取消勾选「启用状态栏」后这个类会被摘掉,
2280
- * 官方原本的统计行与上下文按钮随即恢复(不然关掉插件只剩一片空白)。 */
3143
+ * 整条规则挂在 html.dsb-on 上:这个类由插件挂载/卸载时增删(设置页里
3144
+ * **没有**「启用状态栏」开关,别按那个理解)。卸下后官方原本的统计行与
3145
+ * 上下文按钮随即恢复(否则关掉插件只剩一片空白)。 */
2281
3146
  'html.dsb-on [data-slot="conversation.composer.dock"] > *:not(.dsb-root):not(:has(.dsb-root)){display:none !important}',
2282
3147
  /* 官方新版在输入卡片下方右侧另有一个上下文占用按钮:已被本插件的圆环接管,藏掉它。
2283
3148
  * 用 aria-label 认它(中文含"上下文"、英文含"context used",随语言变),不碰官方 hash 类名。 */
@@ -2434,6 +3299,8 @@ window.__ModuleLoader__.load({
2434
3299
  ".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:40;pointer-events:auto}",
2435
3300
  ".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
2436
3301
  /* 设置页 */
3302
+ /* 这里的 14px 是面板内所有文字的继承来源;挂到 body 的跟手浮层继承不到它,
3303
+ 那边(.dsb-drag-card)自己写死了一份 —— 改这里要同步那边。 */
2437
3304
  ".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
2438
3305
  /* 页头:与官方 Agent 预设设置页同款(18px/600,标题与说明间距 12px) */
2439
3306
  ".dsb-settings .dsb-head{display:flex;flex-direction:column;gap:12px}",
@@ -2458,8 +3325,29 @@ window.__ModuleLoader__.load({
2458
3325
  ".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
2459
3326
  /* 被拿起来的那一行:原位留个淡影,跟手的是下面那张卡 */
2460
3327
  ".dsb-settings .dsb-row.dsb-dragging{opacity:.32}",
2461
- /* 跟手的浮层:气泡同款材质(只有 .5px 描边,没有柔光),不吃指针事件 */
2462
- ".dsb-settings .dsb-drag-card{position:fixed;z-index:40;pointer-events:none;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:var(--dsw-radius-md,12px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
3328
+ /* 跟手浮层:portal 到 body 之后就不再是 .dsb-settings 的后代,
3329
+ 所以样式挂它自己的类(.dsb-drag-card),不再写后代选择器。
3330
+ z-index 取 1100:DSH 里面板类浮层(stat-dialog 等)就是这个量级,
3331
+ 必须高过设置遮罩的 1000,否则浮层会沉到设置面板背后。
3332
+ 背景色用 var(--dsw-specific-menu) 的兜底链:portal 到 body 后依然继承得到
3333
+ :root 上的主题变量,玻璃材质与气泡保持一致。
3334
+ **字号必须自己写死 14px**:面板那 14px 是设在 .dsb-settings 容器上的,
3335
+ 浮层挂到 body 后就继承不到,会掉回 body 的 16px —— 一按拖动手柄字就变大
3336
+ (实测 2026-10-09)。user-select 同理:面板是 .dsb-row 上关掉的。
3337
+ 改 .dsb-settings 的字号时,这里要同步。 */
3338
+ ".dsb-drag-card{position:fixed;z-index:1100;pointer-events:none;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:var(--dsw-radius-md,12px);font-size:14px;user-select:none;-webkit-user-select:none;background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
3339
+ /* 浮层被 portal 到 body 之后就不是 .dsb-settings 的后代了,
3340
+ 它内部那几件(勾选框 / 名称 / 描述 / 握把点阵)得再写一份挂在自己名下,
3341
+ 否则浮层里的行会退化成无样式的裸文本。规则内容与上面那组逐字一致。 */
3342
+ ".dsb-drag-card .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
3343
+ ".dsb-drag-card .dsb-check input{flex:none;margin:2px 0 0}",
3344
+ ".dsb-drag-card input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
3345
+ ".dsb-drag-card .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
3346
+ ".dsb-drag-card .dsb-name{color:var(--dsw-alias-label-primary)}",
3347
+ ".dsb-drag-card .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
3348
+ ".dsb-drag-card .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
3349
+ '.dsb-drag-card .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
3350
+ ".dsb-drag-card .dsb-handle-off{opacity:.25}",
2463
3351
  ".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
2464
3352
  '.dsb-settings .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
2465
3353
  ".dsb-settings .dsb-handle:active{cursor:grabbing}",
@@ -2477,7 +3365,7 @@ window.__ModuleLoader__.load({
2477
3365
  /* 编辑区:毛玻璃浮层,从卡片头部下方展开,盖在下面的内容上 */
2478
3366
  ".dsb-settings .dsb-edit-pop{position:absolute;top:calc(100% - 2px);left:0;right:0;z-index:55;display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-radius:var(--dsw-radius-lg,16px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
2479
3367
  ".dsb-settings .dsb-tier{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
2480
- ".dsb-settings .dsb-tier-name{flex:none;width:48px;font-size:12px;color:var(--dsw-alias-label-secondary)}",
3368
+ ".dsb-settings .dsb-tier-name{flex:none;width:48px;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}",
2481
3369
  ".dsb-settings .dsb-model:first-child{border-top:0;padding-top:0}",
2482
3370
  ".dsb-settings .dsb-model:last-child{padding-bottom:0}",
2483
3371
  ".dsb-settings .dsb-model-name{color:var(--dsw-alias-label-primary);font-weight:600;font-size:13px}",
@@ -2487,6 +3375,8 @@ window.__ModuleLoader__.load({
2487
3375
  ".dsb-settings .dsb-inline-check{display:flex;align-self:flex-start;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}",
2488
3376
  /* 勾选在左、计价单位在右 */
2489
3377
  ".dsb-settings .dsb-tier-head{display:flex;align-items:center;justify-content:space-between;gap:8px}",
3378
+ /* 币种只有条目这一个下拉:CSS 给它的位置留白(条目级选择器与"峰谷计价"同一行) */
3379
+ ".dsb-settings .dsb-currency-pick{display:flex;align-items:center;gap:8px}",
2490
3380
  ".dsb-settings .dsb-select{position:relative}",
2491
3381
  /* 就一个窄框,字号跟旁边的「峰谷计价」一致,不带下拉箭头 */
2492
3382
  ".dsb-settings .dsb-select-btn{display:inline-flex;align-items:center;height:22px;padding:0 8px;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:6px;cursor:pointer}",
@@ -2497,11 +3387,17 @@ window.__ModuleLoader__.load({
2497
3387
  ".dsb-settings .dsb-select-item{display:block;width:100%;padding:6px 10px;border:0;border-radius:var(--dsw-radius-sm,8px);background:transparent;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer}",
2498
3388
  ".dsb-settings .dsb-select-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}",
2499
3389
  ".dsb-settings .dsb-select-item-on{color:var(--dsw-alias-brand-primary,#4d6bfe)}",
2500
- /* 三组字段整体贴右边缘:左边留给时段名,右边一列对齐 */
3390
+ /* 三组字段整体贴右边缘:左边留给时段名,右边一列对齐。
3391
+ 未勾选「峰谷计价」时那行也走同一套 —— 左边是「全时段」标签,
3392
+ 不再用"单档那行没有档位名"时的那条双边对齐规则(用户嫌它跟勾选后的两行不一致)。 */
2501
3393
  ".dsb-settings .dsb-fields{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}",
2502
3394
  ".dsb-settings .dsb-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
2503
3395
  /* 够放 xx.xx 就行,输入框不再拉一大条 */
2504
- ".dsb-settings .dsb-field input{width:56px;text-align:right}",
3396
+ /* 价格框:只有它 24px。选择器必须带 input[type=...] —— 通用规则
3397
+ `.dsb-settings input[type=number]{height:28px}` 的权重与 `.dsb-field input` 相等、
3398
+ 又排在后面,会把高度覆盖回去(实测踩过:用户看到"又变高了")。
3399
+ 自定义模型的名称框走通用规则,保持 28px。 */
3400
+ ".dsb-settings .dsb-field input[type=text],.dsb-settings .dsb-field input[type=number]{width:52px;height:24px;text-align:right}",
2505
3401
  ".dsb-settings input[type=number]{text-align:right}",
2506
3402
  ".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
2507
3403
  ".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
@@ -2530,6 +3426,20 @@ window.__ModuleLoader__.load({
2530
3426
  ".dsb-settings .dsb-star-wrap:hover .dsb-star-tip,.dsb-settings .dsb-star-wrap:focus-within .dsb-star-tip{visibility:visible;opacity:1}",
2531
3427
  /* 导航项后面的「发现新版本」胶囊:细描边圆角胶囊 + 次级文字色,字号刻意比导航文字小一档 */
2532
3428
  ".dsb-nav-pill{box-sizing:border-box;margin-left:4px;padding:0 5px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:999px;font-size:8px;line-height:1.4;font-weight:400;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
3429
+ /* 开发模式**不给可见开关**:入口是页脚版本号的「连点 5 次」。
3430
+ 胶囊做成标题的上标角标(像 x² 的那个 2):
3431
+ · 行内 flex + 靠左对齐 → 胶囊紧跟着标题文字,不甩到面板最右;
3432
+ · align-items:flex-start → 胶囊浮在标题文字的右上角,而不是垂直居中。
3433
+ 三个刻意的取值:
3434
+ · font-size 与「发现新版本」那枚导航胶囊**逐字一致**(都走 .dsb-nav-pill 的 8px),
3435
+ 所以这里不覆盖字号 —— 覆盖过一次(10px)就显得比导航那枚大,两处观感不统一。
3436
+ · **不做负 margin-top**:父级 .dsb-head 是纵向 flex,把胶囊往上顶出容器会被裁掉
3437
+ (实测缺陷:胶囊上边框被切)。
3438
+ · 用 `margin-top:2px` **往下压一点**:标题是 18px、行盒有约 2px 的行距余量,
3439
+ 胶囊要落在这段余量里,顶端才不高于标题文字的上边界。 */
3440
+ ".dsb-settings .dsb-titlerow{display:flex;align-items:flex-start;justify-content:flex-start;gap:4px}",
3441
+ ".dsb-settings .dsb-dev-pill{margin-left:0;margin-top:2px;padding:0 5px;cursor:default}",
3442
+ ".dsb-settings .dsb-version{user-select:none;-webkit-user-select:none}",
2533
3443
  ].join("");
2534
3444
 
2535
3445
  /* 导航图标使用外部传入的 gauge(24x24 lucide,stroke=currentColor,随主题变色) */
@@ -2542,15 +3452,39 @@ window.__ModuleLoader__.load({
2542
3452
  /* 导航项里挂「发现新版本」胶囊用:标记本插件那一项,避免胶囊文字混进文案后匹配不上。 */
2543
3453
  const NAV_MARK = "data-dsb-nav";
2544
3454
 
2545
- /* 宿主上下文(apply 时存下来):一键更新要用它的 remote 命名空间。 */
2546
- let pluginCtx = null;
2547
-
2548
3455
  /**
2549
3456
  * 版本自检结果 —— 导航胶囊和设置页那颗「更新」按钮共用这一份,两边同进同出。
2550
3457
  * 默认什么都没有:只有 registry 上确实更新才显示,查不到就安静地不显示。
2551
- * note 是更新动作的结果:null / "done" / "failed" / "no-manager"。
3458
+ * note 是更新动作的结果:null / "done" / "failed"。
2552
3459
  */
2553
3460
  let updateState = { hasUpdate: false, latest: null, busy: false, note: null, reason: null };
3461
+
3462
+ /**
3463
+ * 开发模式(连点版本号 5 次进入):让作者在没有新版本的时候也能看到更新按钮的
3464
+ * **全部**状态与动效,用来检查「点下去有没有反馈、文案看不看得懂、失败会不会静默」。
3465
+ *
3466
+ * 打开后:
3467
+ * - 按钮**常驻**显示(不看 registry 有没有新版),版本号用一个假的演示版本;
3468
+ * - 每点一次,依次演示「更新中 → 已更新 → 更新失败 → 回到空闲」四种状态;
3469
+ * - **绝不发安装请求**:走的是纯本地的状态机,不碰任何网络与子进程。
3470
+ *
3471
+ * 为什么不给一个可见的开关:普通用户装了这插件,界面上不该多出一个面向作者的调试项。
3472
+ * 入口做成「连点版本号 5 次」——记得住、够隐蔽,也不会被误触(这是常见的开发者模式手势)。
3473
+ * 进入后标题右侧挂一枚「开发模式」胶囊,明确告诉你当前处于这个状态;再连点 5 次退出。
3474
+ *
3475
+ * 它只影响本插件的界面,不改变任何真实配置;退出即恢复原样。
3476
+ */
3477
+ let devMode = false;
3478
+ /** 演示用的假版本号:一眼看出不是真版本,避免被误当成真实更新。 */
3479
+ const DEV_VERSION = "9.9.9";
3480
+ /** 演示用的假失败原因,走的就是真实失败时那条显示路径。 */
3481
+ const DEV_FAIL_REASON = "release-age";
3482
+ /** 「更新中」演示的时长:够看清三个点的动效,又不至于让人等。 */
3483
+ const DEV_BUSY_MS = 2200;
3484
+ /** 进入/退出开发模式需要连点几次版本号。 */
3485
+ const DEV_CLICKS = 5;
3486
+ /** 连点计数:两次点击间隔超过这个毫秒数就重新计数(否则慢慢点 5 次也会进去)。 */
3487
+ const DEV_CLICK_WINDOW_MS = 1500;
2554
3488
  const updateListeners = new Set();
2555
3489
  function subscribeUpdate(fn) {
2556
3490
  updateListeners.add(fn);
@@ -2572,7 +3506,13 @@ window.__ModuleLoader__.load({
2572
3506
  if (has !== null && typeof has.remove === "function") has.remove();
2573
3507
  return;
2574
3508
  }
2575
- if (has !== null) { has.textContent = text; return; }
3509
+ /* 已经挂上就只更新文字。**必须先比值**:写 textContent 即使内容一样也会
3510
+ 替换文本节点、产生 childList 记录,而上面那个 observer 监听的正是
3511
+ childList+subtree —— 不比较就会「写 → 触发 → 下一帧再写」一直空转。 */
3512
+ if (has !== null) {
3513
+ if (has.textContent !== text) has.textContent = text;
3514
+ return;
3515
+ }
2576
3516
  const pill = document.createElement("span");
2577
3517
  pill.className = "dsb-nav-pill";
2578
3518
  pill.textContent = text;
@@ -2595,38 +3535,144 @@ window.__ModuleLoader__.load({
2595
3535
  .catch(() => null);
2596
3536
  }
2597
3537
 
2598
- /** 从安装结果里抠出失败原因;拿不到就给空串。 */
3538
+ /** 从更新结果里抠出人能看懂的原因;拿不到就给空串。 */
2599
3539
  function reasonOf(result) {
2600
3540
  const raw = result === null || result === undefined ? null : result.reason;
2601
3541
  return typeof raw === "string" && raw.length > 0 ? raw : "";
2602
3542
  }
2603
3543
 
2604
3544
  /**
2605
- * 一键更新:调 DSH 官方的插件安装接口(和插件管理页同一条路,内部跑 pnpm)。
2606
- * 装完由 DSH 自己接管生效;客户端这半边是旧代码,得刷新页面才是新的。
3545
+ * 一键更新:调**本插件自己的** host 路由,由它自己跑 pnpm 把新版本装上。
3546
+ *
3547
+ * 为什么不用 DSH 官方的 `ctx.remote.pluginManager.installBundle`:
3548
+ * 1. 那个服务由「插件管理器」这个**别的插件**提供。没装它的 profile 上,
3549
+ * 读 `ctx.remote` 会直接抛错(DSH 的依赖注入对未声明服务就这个行为),
3550
+ * 异常冒出 onClick 没人接 —— 界面表现就是「点了完全没反应」。
3551
+ * 2. 它跑裸 `pnpm add pkg@版本`,会被 pnpm 11 的 24 小时发布年龄策略拦下
3552
+ * (报 NO_MATURE_MATCHING_VERSION),新版本刚发布时装不上。
3553
+ *
3554
+ * 所以更新必须由插件自己完成:host 端先把这个精确版本写进 profile 的
3555
+ * `minimumReleaseAgeExclude` 放行,再用精确版本安装。
3556
+ *
3557
+ * **装完要重启 DSH,光刷新页面不生效**:插件包元数据(含 client.js 的 revision)
3558
+ * 在 DSH 里「缓存至重启」(见 dsh-client-modules 的说明),刷新只会重新拉同一份
3559
+ * 旧 bundle。官方插件管理器也是这个口径(「已安装,下次启动后加载」)。
3560
+ * 按钮文案据此提示重启,而不是刷新。
2607
3561
  */
2608
3562
  function startPluginUpdate() {
2609
- const remote = pluginCtx === null || pluginCtx === undefined ? null : pluginCtx.remote;
2610
- const manager = remote === null || remote === undefined ? null : remote.pluginManager;
2611
- /* 官方桌面端一定带插件管理器;真取不到就按失败处理,不另开一个用户看不懂的分支 */
2612
- if (manager === null || manager === undefined || typeof manager.installBundle !== "function") {
2613
- setUpdateState({ busy: false, note: "failed", reason: null });
2614
- return;
2615
- }
3563
+ /* 开发模式下只演示:不真实安装(见 runDevPreview) */
3564
+ if (devMode === true) { runDevPreview(); return; }
2616
3565
  if (updateState.latest === null || updateState.busy === true) return;
2617
3566
  const target = updateState.latest;
2618
3567
  setUpdateState({ busy: true, note: null, reason: null });
2619
- Promise.resolve(manager.installBundle(
2620
- "dsh-desktop-statusbar@" + target,
2621
- { requestId: "dsh-desktop-statusbar-" + String(Date.now()) }
2622
- )).then((result) => {
2623
- const ok = result !== null && result !== undefined && result.ok === true;
2624
- setUpdateState({ busy: false, note: ok ? "done" : "failed", reason: ok ? null : reasonOf(result) });
2625
- }).catch(() => {
2626
- setUpdateState({ busy: false, note: "failed", reason: null });
2627
- });
3568
+ window.fetch(SELF_UPDATE_URL, {
3569
+ method: "POST",
3570
+ headers: { "content-type": "application/json" },
3571
+ body: JSON.stringify({ version: target })
3572
+ }).then((response) => (response.ok === true ? response.json() : null))
3573
+ .then((payload) => {
3574
+ /* 响应都解不出来(服务端崩了、被代理拦了):按失败处理并说清楚 */
3575
+ if (payload === null || payload === undefined) {
3576
+ setUpdateState({ busy: false, note: "failed", reason: "no-response" });
3577
+ return;
3578
+ }
3579
+ const ok = payload.ok === true;
3580
+ setUpdateState({
3581
+ busy: false,
3582
+ note: ok ? "done" : "failed",
3583
+ reason: ok ? null : reasonOf(payload)
3584
+ });
3585
+ })
3586
+ .catch((error) => {
3587
+ setUpdateState({ busy: false, note: "failed", reason: "network" });
3588
+ });
3589
+ }
3590
+
3591
+ /**
3592
+ * 开发模式下的一次点击:纯本地状态机,走完「更新中 → 已更新 → 更新失败 → 空闲」。
3593
+ *
3594
+ * 用真的 setUpdateState / 真的渲染分支,所以看到的动效、文案、按钮禁用行为
3595
+ * 与真实更新**完全一致**;唯一的区别是不发请求、不安装。
3596
+ */
3597
+ function runDevPreview() {
3598
+ const phase = updateState.note;
3599
+ if (updateState.busy === true) return;
3600
+ if (phase === null) {
3601
+ /* ① 更新中:亮起那三个点 */
3602
+ setUpdateState({ busy: true, note: null, reason: null, latest: DEV_VERSION });
3603
+ devTimer = setTimeout(() => {
3604
+ devTimer = null;
3605
+ /* ② 已更新 */
3606
+ setUpdateState({ busy: false, note: "done", reason: null });
3607
+ }, DEV_BUSY_MS);
3608
+ return;
3609
+ }
3610
+ if (phase === "done") {
3611
+ /* ③ 更新失败:走真实失败那条显示路径 */
3612
+ setUpdateState({ busy: false, note: "failed", reason: DEV_FAIL_REASON });
3613
+ return;
3614
+ }
3615
+ /* ④ 回到空闲,可以再点一遍 */
3616
+ setUpdateState({ busy: false, note: null, reason: null, latest: DEV_VERSION });
3617
+ }
3618
+
3619
+ /** 开发模式里「更新中」那一步的定时器;退出开发模式或卸载时要清掉。 */
3620
+ let devTimer = null;
3621
+
3622
+ /** 开发模式当前是否开着(给测试与自检读)。 */
3623
+ function isDevMode() {
3624
+ return devMode;
3625
+ }
3626
+
3627
+ /**
3628
+ * 开关开发模式。
3629
+ * 打开时把状态摆成「有新版本可更新」的样子(但版本号是假的一眼可辨),
3630
+ * 关闭时清掉定时器并恢复成「没有更新」。
3631
+ */
3632
+ function setDevMode(on) {
3633
+ devMode = on === true;
3634
+ if (devTimer !== null) { clearTimeout(devTimer); devTimer = null; }
3635
+ if (devMode === true) {
3636
+ setUpdateState({ hasUpdate: true, latest: DEV_VERSION, busy: false, note: null, reason: null });
3637
+ } else {
3638
+ /* 关掉时**同步**把演示相关状态一次清干净:忙碌态、结论、演示版本、按钮可见性。
3639
+ 这几项都先落到「没有更新」,再去问真实的 registry 结果 —— 反过来(先等网络)
3640
+ 会让按钮在关掉之后还多显示一会儿,看起来像没关掉。
3641
+ 清 busy 尤其必要:演示可能正停在「更新中」,只撤按钮不撤 busy,
3642
+ 界面会留在一个永远转下去的「正在更新中」。 */
3643
+ setUpdateState({ hasUpdate: false, latest: null, busy: false, note: null, reason: null });
3644
+ loadUpdateFlag().then((result) => {
3645
+ if (result !== null) setUpdateState(result);
3646
+ });
3647
+ }
2628
3648
  }
2629
3649
 
3650
+ /**
3651
+ * 版本号被点了一下:连点 DEV_CLICKS 次进入(或退出)开发模式。
3652
+ *
3653
+ * 为什么用「连点」而不是一个可见开关:普通用户装了这插件,界面上不该多出
3654
+ * 一个面向作者的调试项。连点是常见的开发者模式手势,记得住、不会被误触。
3655
+ * 两次点击间隔超过 DEV_CLICK_WINDOW_MS 就重新计数 —— 否则慢慢点 5 次也会进去。
3656
+ *
3657
+ * @returns {object} 供测试断言的计数快照
3658
+ */
3659
+ function onVersionClick() {
3660
+ const now = Date.now();
3661
+ if (devClickAt !== 0 && now - devClickAt > DEV_CLICK_WINDOW_MS) devClickCount = 0;
3662
+ devClickAt = now;
3663
+ devClickCount += 1;
3664
+ if (devClickCount >= DEV_CLICKS) {
3665
+ devClickCount = 0;
3666
+ devClickAt = 0;
3667
+ setDevMode(devMode !== true);
3668
+ }
3669
+ return { count: devClickCount, on: devMode };
3670
+ }
3671
+
3672
+ /** 连点计数与最近一次点击时刻。 */
3673
+ let devClickCount = 0;
3674
+ let devClickAt = 0;
3675
+
2630
3676
  /**
2631
3677
  * 把设置导航里本插件那一项的前导图标换成 gauge。
2632
3678
  * 定位方式是按导航文案匹配(DSH 没给导航项留 id 属性),匹配不到就什么都不做。
@@ -2664,10 +3710,13 @@ window.__ModuleLoader__.load({
2664
3710
  observer.observe(document.body, { childList: true, subtree: true });
2665
3711
  /* 版本状态一变就重挂一次胶囊:查完发现没新版(撤掉)、或有新版(挂上) */
2666
3712
  const stopWatch = subscribeUpdate(apply);
2667
- /* 启动自检一次。卸载之后结果才到的话直接丢弃,别再往 DOM 里塞东西。 */
3713
+ /* 启动自检一次。卸载之后结果才到的话直接丢弃,别再往 DOM 里塞东西。
3714
+ 开发模式期间也要丢弃:它是模块级开关,用户可能在这次网络请求返回前就连点
3715
+ 进去了,此时真实结果会把演示状态冲掉 —— 胶囊还在、按钮却没了。 */
2668
3716
  let alive = true;
2669
3717
  loadUpdateFlag().then((result) => {
2670
3718
  if (alive !== true || result === null) return;
3719
+ if (devMode === true) return;
2671
3720
  setUpdateState(result);
2672
3721
  });
2673
3722
  return () => {
@@ -2705,7 +3754,6 @@ window.__ModuleLoader__.load({
2705
3754
  const t = props.t;
2706
3755
  const useProjection = props.useProjection;
2707
3756
  const useSession = props.useSession;
2708
- const useSessions = props.useSessions;
2709
3757
  const sessionId = props.sessionId;
2710
3758
  const cfg = useConfig();
2711
3759
 
@@ -2737,6 +3785,40 @@ window.__ModuleLoader__.load({
2737
3785
  }).catch(() => { /* 上报失败不影响底栏 */ });
2738
3786
  }, [reportedModel, reportedProvider]);
2739
3787
 
3788
+ /* 价格库上报给 host:活跃总览的 cost 是 host 记账时算的,
3789
+ 它拿不到 localStorage,必须由这里把当前价格库送过去,
3790
+ 否则用户在设置页加/改的单价只对底栏生效(实测 2026-10-07)。
3791
+ models 一变就重报;host 侧比对指纹后只结算"欠账"——
3792
+ 即当时没有单价、还没算钱的那部分用量,已经算好的历史费用不动。 */
3793
+ const reportedPrices = react.useMemo(() => JSON.stringify(cfg.models === undefined || cfg.models === null ? {} : cfg.models), [cfg.models]);
3794
+ react.useEffect(() => {
3795
+ let models = null;
3796
+ try {
3797
+ models = JSON.parse(reportedPrices);
3798
+ } catch (error) {
3799
+ return;
3800
+ }
3801
+ if (models === null || Object.keys(models).length === 0) return;
3802
+ window.fetch(PRICES_URL, {
3803
+ method: "POST",
3804
+ headers: { "content-type": "application/json" },
3805
+ body: JSON.stringify({ models: models })
3806
+ }).then((response) => {
3807
+ /* 失败要说出来。原先只有 `.catch(() => {})`,而 fetch 对 4xx/5xx 是
3808
+ **resolve** 不是 reject —— 上报被拒(如体积超限返回 400)时既不进
3809
+ catch 也没人看 response,于是用户的价目表一直送不到 host。
3810
+ 后果分两种:**新产生**的用量会按内置官方价计费(不是用户填的价);
3811
+ 只在自定义表里的模型则永远算不出钱、一直留在欠费明细里。
3812
+ 已算好的费用不受影响,也不会被改写 —— 那是既定语义。
3813
+ 底栏本身不受影响,所以只告警、不改界面。 */
3814
+ if (response.ok !== true) {
3815
+ console.warn("[dsh-desktop-statusbar] 价格库上报被拒 HTTP " + String(response.status) + ",host 会继续用内置参考价给新用量记账");
3816
+ }
3817
+ }).catch((error) => {
3818
+ console.warn("[dsh-desktop-statusbar] 价格库上报失败:" + String(error && error.message));
3819
+ });
3820
+ }, [reportedPrices]);
3821
+
2740
3822
  const running = pick(useSession, (s) => s.running, undefined);
2741
3823
  const partial = pick(useSession, (s) => s.partial, undefined);
2742
3824
  const runningCalls = pick(useSession, (s) => s.runningCalls, undefined);
@@ -2748,6 +3830,16 @@ window.__ModuleLoader__.load({
2748
3830
  const [now, setNow] = react.useState(() => Date.now());
2749
3831
  const [balance, setBalance] = react.useState(null);
2750
3832
  const [daily, setDaily] = react.useState(null);
3833
+ /* 子智能体(Agent Teams 队友)的用量汇总:host 按 parentSession 现算,异步取。
3834
+ 状态里带着"这份值属于哪个会话、哪一轮"(`nextSubagentState` 的契约,见那里的注释):
3835
+ 同一轮里某次请求失败就沿用上一次成功的值,换了轮/换了会话一律清空重算,
3836
+ 界面上不出现任何提示文字。段落取值用 `subagentState.sessionPayload / turnPayload`。 */
3837
+ const [subagentState, setSubagentState] = react.useState(SUBAGENT_STATE_EMPTY);
3838
+ /* 「本轮」显示值的粘性下限盒子(同轮内只增不减,换轮作废):一个可变盒子,
3839
+ 由 lastCost 段在渲染时更新,跨渲染靠这个 ref 留住(见 applyTurnFloor)。 */
3840
+ const lastCostHoldRef = react.useRef({ sessionId: null, turnKey: null, value: 0 });
3841
+ /* 点开「总计」气泡时 +1:强制重拉一次队友用量(别的触发条件都带 SUBAGENT_REFRESH_MS 节流) */
3842
+ const [subagentTick, setSubagentTick] = react.useState(0);
2751
3843
  const [tipId, setTipId] = react.useState(null);
2752
3844
  const [tipPos, setTipPos] = react.useState(null);
2753
3845
  /* 气泡里的下钻层:null = 根视图,数字 = 点开了第几行(会话数据那三行的子视图)。
@@ -2889,7 +3981,9 @@ window.__ModuleLoader__.load({
2889
3981
  }, [wantsBalance, balanceProvider, balanceModel]);
2890
3982
 
2891
3983
  /* 活跃总览(峰谷段的气泡):不轮询。挂载时取一次(走 host 缓存),
2892
- 之后每次点开气泡都带 force=1 现扫 —— 跨天、跨月靠"关掉再点开"自然跟上。 */
3984
+ 之后每次点开气泡都带 force=1 —— force 只绕过 host 那 30 秒缓存;
3985
+ 没变动的日志文件按 mtime+size 跳过,历史数字也不会被重算,
3986
+ 跨天、跨月靠"关掉再点开"自然跟上。 */
2893
3987
  const wantsWeekly = isSegmentOn(cfg, "status");
2894
3988
  const dailyReloadRef = react.useRef(null);
2895
3989
  react.useEffect(() => {
@@ -2910,6 +4004,114 @@ window.__ModuleLoader__.load({
2910
4004
  };
2911
4005
  }, [wantsWeekly]);
2912
4006
 
4007
+ /* 队友用量(「总计」「本轮」都要算上它):换会话、对话新增了节点、本轮换了、
4008
+ 或距上次超过 3 秒(活跃时)才重拉;点开气泡再强制拉一次。
4009
+ 失败 / 超时一律静默:拿不到就退回"只算本会话",界面不该因为队友数据出问题而报错或卡住。
4010
+ 分三个 effect:一个算本轮起点、一个决定"该不该拉"(跟着每秒的 now 数那 3 秒)、
4011
+ 一个只负责拉(只在真的决定要拉时才换 query)。合成一个的话,时钟每跳一下
4012
+ 就会把在途请求的 alive 清掉,结果永远落不了地。 */
4013
+ const foldedTurnNow = foldTurnUsage(chatNodes);
4014
+ /* 本轮起点 = 当前轮第一条记录的时间;节点不带时间戳、或折叠还停在上轮时为 null。
4015
+ 为 null 时本轮那一问**照发**(不带 since),由 host 读父会话日志代推(host 那边还会
4016
+ 按父会话日志再收敛一道);两边都拿不到就返回全 0 ⇒ 本轮只显示本会话值(只能小不能大)。 */
4017
+ const foldedTurnNum = foldedTurnNow !== null ? foldedTurnNow.turn : null;
4018
+ /* host 投影说的当前轮号(与下面 lastCost 用的是同一个来源)。折叠出来的轮号跟它对不上,
4019
+ 说明新一轮已经开跑、而对话节点还没跟上 —— 这时折叠出的起点是**上一轮的**,
4020
+ 拿它当 since 会把上一轮的队友花费算进本轮(偏大),所以直接当拿不到起点。 */
4021
+ const hostTurn = sessionUsage !== undefined && sessionUsage !== null && typeof sessionUsage.turn === "number"
4022
+ ? sessionUsage.turn
4023
+ : null;
4024
+ const turnIsCurrent = foldedTurnNum === null || hostTurn === null || foldedTurnNum === hostTurn;
4025
+ const turnStartMs = turnIsCurrent === true && foldedTurnNow !== null && typeof foldedTurnNow.at === "number"
4026
+ ? foldedTurnNow.at
4027
+ : null;
4028
+ /* 「同一轮」的轮标识(给队友状态机与"本轮只增不减"用)。两个来源都只会在真的换轮时前进:
4029
+ host 投影的 turn 随用量事件走,节点折叠的轮号随助手节点走。取**较大者**是为了尽早认定
4030
+ "换轮了"——两者谁先看到新一轮,就从那一刻起归零重算;认不出轮次(两个都拿不到)时为 null,
4031
+ 此时既不沿用旧值也不做粘性,避免把两轮的数字混在一起。 */
4032
+ const subagentTurnKey = hostTurn !== null && foldedTurnNum !== null
4033
+ ? Math.max(hostTurn, foldedTurnNum)
4034
+ : (hostTurn !== null ? hostTurn : foldedTurnNum);
4035
+ const chatNodeCount = Array.isArray(chatNodes) ? chatNodes.length : -1;
4036
+ const subagentReqRef = react.useRef({ sessionId: null, nodes: -1, since: null, at: 0 });
4037
+ const subagentForceRef = react.useRef(false);
4038
+ const subagentReloadRef = react.useRef(null);
4039
+ /* 一次"要拉取"的凭据:nonce 变一次就拉一次(sessionId / since 跟着它一起走,读的时候不会串会话) */
4040
+ const [subagentQuery, setSubagentQuery] = react.useState(null);
4041
+ react.useEffect(() => {
4042
+ subagentReloadRef.current = () => {
4043
+ subagentForceRef.current = true;
4044
+ setSubagentTick((n) => n + 1);
4045
+ };
4046
+ return () => { subagentReloadRef.current = null; };
4047
+ }, []);
4048
+ react.useEffect(() => {
4049
+ if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
4050
+ const last = subagentReqRef.current;
4051
+ const switched = last.sessionId !== sessionId;
4052
+ const forced = subagentForceRef.current;
4053
+ subagentForceRef.current = false;
4054
+ const due = switched === true || last.nodes !== chatNodeCount || last.since !== turnStartMs
4055
+ || Date.now() - last.at >= SUBAGENT_REFRESH_MS;
4056
+ if (forced !== true && due !== true) return undefined;
4057
+ subagentReqRef.current = { sessionId: sessionId, nodes: chatNodeCount, since: turnStartMs, at: Date.now() };
4058
+ /* 换了会话:上一个会话的队友数字不属于这里,先清掉,新的回来再填 */
4059
+ if (switched === true) setSubagentState(SUBAGENT_STATE_EMPTY);
4060
+ setSubagentQuery((prev) => ({
4061
+ sessionId: sessionId,
4062
+ since: turnStartMs,
4063
+ turnKey: subagentTurnKey,
4064
+ nonce: prev === null ? 1 : prev.nonce + 1
4065
+ }));
4066
+ return undefined;
4067
+ }, [sessionId, chatNodeCount, turnStartMs, now, subagentTick, subagentTurnKey]);
4068
+ react.useEffect(() => {
4069
+ if (subagentQuery === null) return undefined;
4070
+ if (typeof window === "undefined" || typeof window.fetch !== "function") return undefined;
4071
+ let alive = true;
4072
+ /* 超时自己兜:后端卡住时不能把「总计」一直吊着。用 AbortSignal.timeout 而不是
4073
+ 自己挂 setTimeout —— 底栏本该只有"时钟"那一个定时器(已有测试盯着),
4074
+ 多一个窗口定时器会打乱它的计数;环境不支持就算了,失败照样被 catch 静默。 */
4075
+ const timeoutSignal = typeof AbortSignal === "object" && AbortSignal !== null
4076
+ && typeof AbortSignal.timeout === "function"
4077
+ ? AbortSignal.timeout(SUBAGENT_TIMEOUT_MS)
4078
+ : undefined;
4079
+ /* 两条请求:整会话那一份显式带 `since=0`(表示"从最开始",即总计口径),
4080
+ 本轮那一份优先用客户端自己的起点;**自己拿不到起点也照发**(不带 since),
4081
+ 让 host 读父会话日志代推 —— host 推不出来时它会返回 sinceSource='none'
4082
+ 与全 0,本轮因此只显示本会话值(只能小不能大)。 */
4083
+ const load = (since) => {
4084
+ const url = SUBAGENT_URL + "?sessionId=" + encodeURIComponent(subagentQuery.sessionId)
4085
+ + (since === null ? "" : "&since=" + encodeURIComponent(String(since)));
4086
+ return window.fetch(url, { cache: "no-store", signal: timeoutSignal })
4087
+ .then((response) => response.json())
4088
+ .then((data) => (data !== null && typeof data === "object" && data.ok === true ? data : null))
4089
+ .catch(() => null);
4090
+ };
4091
+ Promise.all([
4092
+ load(0),
4093
+ subagentQuery.since === null ? load(null) : load(subagentQuery.since)
4094
+ ]).then((both) => {
4095
+ if (alive !== true) return;
4096
+ /* 两段各自过一遍状态机(`nextSubagentState`,规则见那里的注释):
4097
+ 这一次没拿到(网络失败 / 非 200 / HTTP 200 但 ok:false / 形状异常 —— 上面 `load`
4098
+ 已经把后三种统一折算成 null)时,**同轮内沿用上一次成功的值**;从没成功过就保持
4099
+ 空(界面按"仅本会话"显示);轮次变了则以 action 里的 turnKey 为准整体清空重算。 */
4100
+ const action = (slice, value) => ({
4101
+ sessionId: subagentQuery.sessionId,
4102
+ turnKey: subagentQuery.turnKey,
4103
+ slice: slice,
4104
+ ok: value !== null && value !== undefined,
4105
+ value: value
4106
+ });
4107
+ setSubagentState((prev) => nextSubagentState(
4108
+ nextSubagentState(prev, action("session", both[0])),
4109
+ action("turn", both[1])
4110
+ ));
4111
+ });
4112
+ return () => { alive = false; };
4113
+ }, [subagentQuery]);
4114
+
2913
4115
  /* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
2914
4116
  不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
2915
4117
  跟着 scroll 收起的话,气泡会在这些时刻莫名其妙消失。 */
@@ -3047,9 +4249,9 @@ window.__ModuleLoader__.load({
3047
4249
  return () => { root.classList.remove("dsb-on"); };
3048
4250
  }, []);
3049
4251
 
3050
- /* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底 */
3051
- /* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底 */
3052
- const foldedTurn = foldTurnUsage(chatNodes);
4252
+ /* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底。
4253
+ 这份在上面算队友的「本轮」窗口时已经折过一次(foldedTurnNow),这里直接复用。 */
4254
+ const foldedTurn = foldedTurnNow;
3053
4255
  const turnUsage = foldedTurn !== null ? foldedTurn.parts : turnLastRef.current;
3054
4256
  /* 这份用量属于哪一轮(节点折叠才有;差分兜底没有轮号) */
3055
4257
  const turnUsageTurn = foldedTurn !== null ? foldedTurn.turn : null;
@@ -3057,15 +4259,30 @@ window.__ModuleLoader__.load({
3057
4259
  const currentTurn = sessionUsage !== undefined && sessionUsage !== null && typeof sessionUsage.turn === "number"
3058
4260
  ? sessionUsage.turn
3059
4261
  : null;
4262
+ /* 段的入参形状照旧:{ session, turn } 各自可能是 null(null = 这段没有可用的值 → 当 0)。
4263
+ `session` 由状态机管着"失败沿用上一次成功的值"(整会话与轮无关,跨轮也留着);
4264
+ `turn` 必须**确认这份窗口属于当前这一轮**才用:换轮那一拍新数据还没回来时,
4265
+ 状态里还是上一轮的值,直接拿来加会把上一轮的队友钱算进本轮(实测会让本轮虚高
4266
+ 并被粘性下限锁住)。轮次认不出来(turnKey 为 null)时同样不用它 —— 宁少不多。 */
4267
+ const turnSliceUsable = subagentTurnKey !== null && subagentState.turnKey === subagentTurnKey;
4268
+ const subagent = {
4269
+ session: subagentState.sessionPayload,
4270
+ turn: turnSliceUsable === true ? subagentState.turnPayload : null
4271
+ };
4272
+ /* 底栏币种:主模型那个条目的币种(消耗最多的模型),认不出主模型才退回默认币种。
4273
+ 段里的「总计 / 本轮 / 余额」都读这一个值。 */
4274
+ const mainModel = mainModelOf(sessionUsage);
4275
+ const displayCurrency = displayCurrencyOf(config.models, config.currency, sessionUsage);
3060
4276
 
3061
4277
  const debugSrc = {
3062
4278
  stats: stats, usage: usage,
3063
4279
  sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRangeNow,
3064
4280
  running: running, partial: partial, runningCalls: runningCalls,
3065
4281
  lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
3066
- now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency,
4282
+ now: now, balance: balance, turnUsage: turnUsage, currency: displayCurrency,
4283
+ models: config.models, defaultCurrency: config.currency,
3067
4284
  timing: timing, turnUsageTurn: turnUsageTurn, currentTurn: currentTurn,
3068
- progress: progress
4285
+ progress: progress, subagent: subagent
3069
4286
  };
3070
4287
  window.__dsbDebug = {
3071
4288
  now: now, running: running, clockActive: clockActive, timeRange: timeRangeNow,
@@ -3073,6 +4290,11 @@ window.__ModuleLoader__.load({
3073
4290
  sessionModel: sessionModel === undefined ? null : sessionModel,
3074
4291
  priceBook: config.models,
3075
4292
  peakNow: isPeakTime(now),
4293
+ /* 币种相关:底栏符号的实际来源(测试成员照这三个字段取) */
4294
+ displayCurrency: displayCurrency,
4295
+ defaultCurrency: config.currency,
4296
+ mainModel: mainModel,
4297
+ referenceRate: REFERENCE_RATE,
3076
4298
  calls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.length : 0,
3077
4299
  lastCalls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.slice(-3) : [],
3078
4300
  modelsAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.models,
@@ -3082,9 +4304,9 @@ window.__ModuleLoader__.load({
3082
4304
  turnUsageTurn: turnUsageTurn,
3083
4305
  currentTurn: currentTurn,
3084
4306
  timing: timing,
3085
- costText: segmentText("cost", segmentView("cost", debugSrc, t), t),
3086
- lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t),
3087
- sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t)
4307
+ costText: segmentText("cost", segmentView("cost", debugSrc, t), t, displayCurrency),
4308
+ lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t, displayCurrency),
4309
+ sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t, displayCurrency)
3088
4310
  };
3089
4311
 
3090
4312
  const src = {
@@ -3092,11 +4314,15 @@ window.__ModuleLoader__.load({
3092
4314
  sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRange,
3093
4315
  running: running, partial: partial, runningCalls: runningCalls,
3094
4316
  lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
3095
- now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency,
4317
+ now: now, balance: balance, turnUsage: turnUsage, currency: displayCurrency,
4318
+ models: config.models, defaultCurrency: config.currency,
3096
4319
  pressure: pressure, breakdown: breakdown, timing: timing,
3097
4320
  daily: daily,
3098
4321
  turnUsageTurn: turnUsageTurn, currentTurn: currentTurn,
3099
- progress: progress
4322
+ progress: progress, subagent: subagent,
4323
+ /* 「本轮」同轮粘性下限的载体:sessionId/turnKey 说明"这份下限属于谁",
4324
+ lastCostHold 是个可变盒子(ref 里那个对象),段里渲染时按需更新它 —— 见 applyTurnFloor。 */
4325
+ sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current
3100
4326
  };
3101
4327
 
3102
4328
  const views = [];
@@ -3105,7 +4331,7 @@ window.__ModuleLoader__.load({
3105
4331
  /* 保留 view 上的 state(状态点靠它上色),只把缺数据的段换成占位文本 */
3106
4332
  views.push(view !== null && view !== undefined
3107
4333
  ? view
3108
- : { id: id, text: segmentText(id, view, t) });
4334
+ : { id: id, text: segmentText(id, view, t, displayCurrency) });
3109
4335
  });
3110
4336
 
3111
4337
  /* 运行状态:只用来给上下文占用圆环上色(空闲灰 / 运行绿 / 错误红 / 待审批黄) */
@@ -3117,8 +4343,12 @@ window.__ModuleLoader__.load({
3117
4343
  && (Array.isArray(approvals) ? approvals.length > 0 : true);
3118
4344
  const agentFailed = !agentRunning && src.lastAgentError !== undefined && src.lastAgentError !== null;
3119
4345
  const agentState = agentRunning ? "running" : (agentFailed ? "error" : (approvalWaiting ? "approval" : "idle"));
3120
- /* 上下文占用是独立开关:固定在栏首、不参与排序;没有占用数据时整块不出现(不再退化成小圆点) */
3121
- const contextMeter = cfg.contextMeter === true ? meterView(pressure, breakdown, capacity, t) : null;
4346
+ /* 上下文占用是独立开关:固定在栏首、不参与排序。
4347
+ 拿不到占用数据时给**空环 + 横杠**,绝不整块消失 —— 会话刚发出就被中断时
4348
+ provider 还没报过压力,一整块没掉,底栏就少一项,看着像插件坏了(实测 2026-10-09)。 */
4349
+ const contextMeter = cfg.contextMeter === true
4350
+ ? (meterView(pressure, breakdown, capacity, t) || dashMeter())
4351
+ : null;
3122
4352
  /* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
3123
4353
  const toggleTip = (id, event) => {
3124
4354
  /* 换字段(含收起)一律回到根视图,别把上一个字段的下钻层带过来 */
@@ -3131,6 +4361,10 @@ window.__ModuleLoader__.load({
3131
4361
  /* 点开余额 / 峰谷用量:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
3132
4362
  if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
3133
4363
  if (id === "status" && dailyReloadRef.current !== null) dailyReloadRef.current();
4364
+ /* 总计:队友的数字也要重取一次 —— 节流靠的是那条每秒时钟(SUBAGENT_REFRESH_MS),
4365
+ 而会话完全空闲时
4366
+ 它不跳(睡到下一个峰谷边界),光靠它会让数字停在旧值 */
4367
+ if (id === "cost" && subagentReloadRef.current !== null) subagentReloadRef.current();
3134
4368
  const target = event !== undefined && event !== null ? event.currentTarget : null;
3135
4369
  /* 存节点而不是当次的矩形:窗口缩放后要重新量 */
3136
4370
  anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
@@ -3198,10 +4432,74 @@ window.__ModuleLoader__.load({
3198
4432
  }
3199
4433
 
3200
4434
  /* -------------------------------------------------------------- 设置页 */
4435
+
4436
+ /* 编辑浮层滚进视野时底部留的余量(像素):贴着容器底边太紧,看着像还被裁着 */
4437
+ const REVEAL_MARGIN = 12;
4438
+
4439
+ /**
4440
+ * 找一个元素"最近的可滚动祖先":沿 parent 链往上,第一个 overflow-y 为
4441
+ * auto/scroll 且内容确实比自身高(scrollHeight > clientHeight)的元素。
4442
+ * 找不到就退回 `document.scrollingElement`(整页滚动的情况);再没有就返回 null。
4443
+ *
4444
+ * 为什么要这么找:设置面板不一定由 window 在滚 —— 面板内部可能就是个可滚容器,
4445
+ * 那样 window.scrollY 一动不动,把浮层底部带进来的唯一办法是改那个容器的 scrollTop。
4446
+ */
4447
+ function scrollParentOf(node) {
4448
+ let one = node === null || node === undefined ? null : node.parentElement;
4449
+ while (one !== null && one !== undefined) {
4450
+ const style = typeof window.getComputedStyle === "function" ? window.getComputedStyle(one) : null;
4451
+ const overflowY = style === null || style === undefined ? "" : String(style.overflowY);
4452
+ if ((overflowY === "auto" || overflowY === "scroll")
4453
+ && typeof one.scrollHeight === "number" && typeof one.clientHeight === "number"
4454
+ && one.scrollHeight > one.clientHeight) return one;
4455
+ one = one.parentElement;
4456
+ }
4457
+ if (document !== undefined && document !== null && document.scrollingElement !== undefined) {
4458
+ return document.scrollingElement;
4459
+ }
4460
+ return null;
4461
+ }
4462
+
4463
+ /** 把 node 的**底边**带进滚动容器的可视区:只补"被裁掉的那一段 + 余量",没被裁就不动。 */
4464
+ function revealBottom(node) {
4465
+ if (node === null || node === undefined || typeof node.getBoundingClientRect !== "function") return;
4466
+ const box = node.getBoundingClientRect();
4467
+ if (box.height <= 0) return;
4468
+ const scroller = scrollParentOf(node);
4469
+ if (scroller === null || scroller === undefined) return;
4470
+ const rect = typeof scroller.getBoundingClientRect === "function" ? scroller.getBoundingClientRect() : null;
4471
+ const limit = rect === null || rect.bottom <= 0
4472
+ ? (typeof scroller.clientHeight === "number" ? scroller.clientHeight : 0)
4473
+ : rect.bottom;
4474
+ const overflow = box.bottom + REVEAL_MARGIN - limit;
4475
+ if (overflow <= 0) return; /* 本来就在视野里:一动不动(旧版 block:"end" 会平白上滚) */
4476
+ scroller.scrollTop = (typeof scroller.scrollTop === "number" ? scroller.scrollTop : 0) + overflow;
4477
+ }
4478
+
3201
4479
  function SettingsSection(props) {
3202
4480
  const t = props.t;
3203
4481
  const cfg = useConfig();
3204
4482
  const update = useUpdateState();
4483
+ /* 开发模式是本组件内的界面状态;它不写配置、不动真实更新。
4484
+ **不给它可见开关**:入口是页脚版本号的「连点 5 次」(见 onVersionClick)。
4485
+ 进入后标题右侧挂一枚「开发模式」胶囊,这是唯一的可见迹象。 */
4486
+ const [devOn, setDevOn] = react.useState(false);
4487
+ /* 版本号被点击:累计连点次数,够 5 次就切换开发模式。
4488
+ 计数与时间窗在模块级(onVersionClick),这里只负责把结果同步给 React。 */
4489
+ const onVersionClicked = () => {
4490
+ const state = onVersionClick();
4491
+ setDevOn(state.on === true);
4492
+ };
4493
+ /* 卸载时清掉演示的定时器,并把开发模式**一并关掉**。
4494
+ 只清定时器是不够的:devMode 是模块级变量,而 devOn 是本组件的 useState。
4495
+ 卸载后重挂时 devOn 回到 false,devMode 若还是 true,两者就不同步了 ——
4496
+ 界面看不见「开发模式」胶囊,更新按钮却仍显示演示版本号,点下去走
4497
+ startPluginUpdate 的 devMode 分支只播演示、不安装也不报错,用户无从判断。
4498
+ 只在**真的开着**时才复位:setDevMode(false) 会顺手重查一次 registry,
4499
+ 没开还调用等于每次卸载白打一次网络请求,并且会把已有的真实更新提示清掉。 */
4500
+ react.useEffect(() => () => {
4501
+ if (devMode === true) setDevMode(false);
4502
+ }, []);
3205
4503
 
3206
4504
  /* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
3207
4505
  原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
@@ -3215,15 +4513,98 @@ window.__ModuleLoader__.load({
3215
4513
  const [currencyOpen, setCurrencyOpen] = react.useState(false);
3216
4514
  const currencyRef = react.useRef(null);
3217
4515
  const editPopRef = react.useRef(null);
4516
+ /* 币种下拉的**菜单节点**(不是那个按钮):展开后要把它滚进视野 */
4517
+ const currencyMenuRef = react.useRef(null);
4518
+
4519
+ /* 设置页里会"向下展开"的浮层,统一走这一个钩子把底边带进视野。
4520
+ 旧实现用浏览器自带的"滚进视野"接口(block:nearest),而 ① 浮层自身比可视区高时
4521
+ 它只保证顶部对齐、底部永远进不来;② 设置面板外层是个可滚容器,滚 window 没用。
4522
+ 所以改成:找最近的可滚祖先 → 按"底边 + 12px 余量 − 容器可视底边"的差值加 scrollTop
4523
+ (没被裁就一动不动)。CSS/字体度量可能让高度到下一帧才稳定,所以再用一帧复测补滚
4524
+ 一次(rAF,不是定时器 —— 底栏的定时器数量有断言盯着)。
4525
+ 参数:`ref` 指向浮层节点本身;`active` 为 true 才起作用;`extraKey` 是"会让这个
4526
+ 浮层高度变化"的附加信号(例如勾上峰谷计价会多出一档)。 */
4527
+ const useRevealBottom = (ref, active, extraKey) => {
4528
+ react.useEffect(() => {
4529
+ if (active !== true) return undefined;
4530
+ const retry = () => revealBottom(ref.current);
4531
+ retry();
4532
+ let frame = 0;
4533
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
4534
+ frame = window.requestAnimationFrame(retry);
4535
+ }
4536
+ return () => {
4537
+ if (frame !== 0 && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
4538
+ window.cancelAnimationFrame(frame);
4539
+ }
4540
+ };
4541
+ }, [active, extraKey]);
4542
+ };
3218
4543
 
3219
- /* 编辑浮层打开后按需滚动:block:"nearest" 只补上被遮住的那一段,
3220
- 本来就在视野里就一动不动(旧版用 "end" 会平白往上滚,明明下面有地方)。 */
3221
- react.useEffect(() => {
3222
- if (editing === null) return;
3223
- const node = editPopRef.current;
3224
- if (node === null || node === undefined || typeof node.scrollIntoView !== "function") return;
3225
- node.scrollIntoView({ block: "nearest", behavior: "smooth" });
3226
- }, [editing]);
4544
+ /* 编辑浮层打开后把它的**底边**带进视野(不是顶部对齐,也不是平白上滚)。
4545
+ **实测根因**(本轮修的):旧实现用浏览器自带的"滚进视野"接口(block:nearest),
4546
+ 而 ① 浮层自身比可视区高时它只保证顶部对齐,底部永远进不来;② 设置面板外层
4547
+ 是个可滚容器,滚窗口没用。所以勾上「峰谷计价」多出一档、浮层变高之后,
4548
+ 底部就落在可视区外,用户还得手动往下滚一下(截图箭头处)。
4549
+ 现在:找最近的可滚祖先 → 按"底边 + 12px 余量 − 容器可视底边"的差值加 scrollTop。
4550
+ **依赖里带 draft.tiered**:勾选会撑高浮层,必须重算一次。
4551
+ CSS/字体度量可能让高度到下一帧才稳定,所以再用一帧复测补滚一次
4552
+ (rAF,不是定时器 —— 底栏的定时器数量有断言盯着)。 */
4553
+ /* 价格编辑浮层:打开时、以及勾上「峰谷计价」把它撑高之后各滚一次 */
4554
+ useRevealBottom(editPopRef, editing !== null, draft === null ? null : draft.tiered);
4555
+ /* 币种下拉菜单(CNY/USD):展开后同样按底部溢出量滚进视野。
4556
+ 用户实测反馈"还要手动滚一下"的就是它 —— 上一轮只接到了价格编辑浮层。 */
4557
+ useRevealBottom(currencyMenuRef, currencyOpen === true, null);
4558
+ /**
4559
+ * 币种下拉控件(**只在条目的编辑浮层里用一处**:与三个价格框同一处、无文字标签)。
4560
+ *
4561
+ * 切换时只改**这一条**:`setModelCurrency` 按"官方内置 → 官方另一套定死值 /
4562
+ * 其它 → 数值不动只换标记"处理。草稿分两种接法:
4563
+ * · 官方内置且没被改过 → 按新币种的官方数字**重建草稿**(框里要显示新币种那一套);
4564
+ * · 其余(自建条目、或用户已改过价)→ 数值一个字没动,**只换草稿的币种标记**,
4565
+ * 用户已经填好、还没点保存的输入必须原样留在框里
4566
+ * (实测反馈:切一下币种,刚填的价格就被清空了)。
4567
+ */
4568
+ const currencySelect = (key, draftNow, open, setOpen, menuRef, boxRef) => {
4569
+ const value = normalizeCurrency(draftNow === null || draftNow === undefined ? cfg.currency : draftNow.currency);
4570
+ const pick = (code) => {
4571
+ setOpen(false);
4572
+ if (editing === null || draftNow === null || draftNow === undefined) return;
4573
+ const changed = setModelCurrency(editing, code, draftNow);
4574
+ if (changed === null) return;
4575
+ if (changed.replaced === true) {
4576
+ startEdit(editing, changed.entry);
4577
+ return;
4578
+ }
4579
+ /* 整份重建会连用户没保存的输入一起丢掉 —— 这里只换币种标记,三个价格框的值保持不变 */
4580
+ setDraft({
4581
+ tiered: draftNow.tiered,
4582
+ currency: normalizeCurrency(code),
4583
+ peak: draftNow.peak,
4584
+ offPeak: draftNow.offPeak
4585
+ });
4586
+ };
4587
+ return h("div", { className: "dsb-select", key: key, ref: boxRef }, [
4588
+ h("button", {
4589
+ type: "button",
4590
+ className: "dsb-select-btn",
4591
+ "aria-haspopup": "listbox",
4592
+ "aria-expanded": open === true ? "true" : "false",
4593
+ onClick: () => setOpen(open !== true)
4594
+ }, value),
4595
+ open === true
4596
+ ? h("div", { className: "dsb-select-menu", role: "listbox", ref: menuRef },
4597
+ ["CNY", "USD"].map((code) => h("button", {
4598
+ type: "button",
4599
+ key: code,
4600
+ role: "option",
4601
+ "aria-selected": value === code ? "true" : "false",
4602
+ className: value === code ? "dsb-select-item dsb-select-item-on" : "dsb-select-item",
4603
+ onClick: () => pick(code)
4604
+ }, code)))
4605
+ : null
4606
+ ]);
4607
+ };
3227
4608
 
3228
4609
  /* 编辑浮层:点别处收起并丢弃草稿。判断"里面"上三重保险 —— 浮层节点、价格卡节点,
3229
4610
  以及鼠标坐标是否落在卡片或币种下拉的矩形内。切币种时点在卡片内的控件上,
@@ -3289,13 +4670,22 @@ window.__ModuleLoader__.load({
3289
4670
  const name = newModel.trim();
3290
4671
  if (name.length === 0 || cfg.models[name] !== undefined) return;
3291
4672
  const next = Object.assign({}, cfg.models);
3292
- next[name] = { tiered: false, peak: Object.assign({}, EMPTY_TIER), offPeak: Object.assign({}, EMPTY_TIER) };
4673
+ /* 新增条目的币种一律 **CNY**:界面上已经没有全局币种选择器了(用户要求去掉),
4674
+ 所以"新增时用哪种币"这件事不再由界面决定。`config.currency` 只留作**内部默认值**
4675
+ (老配置迁移时给条目落初值、以及认不出主模型时底栏的后备显示币种),界面不暴露它;
4676
+ 用户要按美元记某个模型,就在该条目的编辑浮层里把币种选成 USD。 */
4677
+ next[name] = {
4678
+ tiered: false,
4679
+ currency: "CNY",
4680
+ peak: Object.assign({}, EMPTY_TIER),
4681
+ offPeak: Object.assign({}, EMPTY_TIER)
4682
+ };
3293
4683
  setNewModel("");
3294
4684
  setConfig({
3295
4685
  models: next,
3296
4686
  modelOrder: (Array.isArray(cfg.modelOrder) ? cfg.modelOrder : Object.keys(cfg.models)).concat([name])
3297
4687
  });
3298
- startEdit(name, next[name]); /* 新加的模型直接展开待填(默认单档) */
4688
+ startEdit(name, next[name], true); /* 新加的模型直接展开待填:框留空,不预填 0 */
3299
4689
  }
3300
4690
  function removeModel(name) {
3301
4691
  const next = Object.assign({}, cfg.models);
@@ -3306,18 +4696,28 @@ window.__ModuleLoader__.load({
3306
4696
  });
3307
4697
  if (editing === name) cancelEdit();
3308
4698
  }
3309
- /** 打开编辑:价格抄进草稿,数字转字符串,输入过程中不会被清零。 */
3310
- function startEdit(name, preset) {
4699
+ /**
4700
+ * 打开编辑:把价格抄进草稿,**没填的格子留空**(连 0 都不显示),别拿 0 冒充"已经有价"。
4701
+ * 判定逐格做:值是 `null` 就是没填过 → 空框;数字(含用户明确填的 0)→ 照实显示。
4702
+ * 只有两种情况让整栏都空:① 刚新增还没保存(blank=true);
4703
+ * ② 旧版本遗留的"全 0 条目"—— 那时没有 null,只能整体按没填过处理。
4704
+ */
4705
+ function startEdit(name, preset, blank) {
3311
4706
  const stored = preset === undefined ? cfg.models[name] : preset;
3312
4707
  const entry = normalizePrice(stored);
4708
+ const untouched = blank === true || isLegacyZeroEntry(stored);
3313
4709
  const asText = (tier) => ({
3314
- input: String(tier.input),
3315
- cacheRead: String(tier.cacheRead),
3316
- cacheWrite: String(tier.cacheWrite),
3317
- output: String(tier.output)
4710
+ input: untouched === true || tier.input === null || tier.input === undefined ? "" : String(tier.input),
4711
+ cacheRead: untouched === true || tier.cacheRead === null || tier.cacheRead === undefined ? "" : String(tier.cacheRead),
4712
+ cacheWrite: untouched === true || tier.cacheWrite === null || tier.cacheWrite === undefined ? "" : String(tier.cacheWrite),
4713
+ output: untouched === true || tier.output === null || tier.output === undefined ? "" : String(tier.output)
3318
4714
  });
3319
4715
  setDraft({
3320
4716
  tiered: isTiered(stored),
4717
+ /* 条目的币种也进草稿:它在浮层里与三个价格框并排可改(改哪条只动哪条) */
4718
+ currency: stored !== null && stored !== undefined && stored.currency !== undefined && stored.currency !== null
4719
+ ? normalizeCurrency(stored.currency)
4720
+ : normalizeCurrency(cfg.currency),
3321
4721
  peak: asText(entry === null ? EMPTY_TIER : entry.peak),
3322
4722
  offPeak: asText(entry === null ? EMPTY_TIER : entry.offPeak)
3323
4723
  });
@@ -3331,35 +4731,75 @@ window.__ModuleLoader__.load({
3331
4731
  function setDraftValue(tierKey, field, value) {
3332
4732
  setDraft((prev) => {
3333
4733
  if (prev === null) return prev;
3334
- /* 只改被编辑的那一格;另一档原样留着(切换计价方式不该丢数值) */
3335
- const next = { tiered: prev.tiered, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
4734
+ const next = { tiered: prev.tiered, currency: prev.currency, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
3336
4735
  next[tierKey][field] = value;
4736
+ /* 勾了「峰谷计价」就按官方比例联动:填高峰 → 空闲 ÷2;填空闲 → 高峰 ×2,
4737
+ 用户只需要填一档。没勾(全天同价)时两档没有关系,另一档不动。 */
4738
+ if (prev.tiered === true) {
4739
+ const other = tierKey === "peak" ? "offPeak" : "peak";
4740
+ next[other][field] = linkedTierValue(value, tierKey === "peak");
4741
+ }
3337
4742
  return next;
3338
4743
  });
3339
4744
  }
3340
4745
  function setDraftTiered(on) {
3341
4746
  setDraft((prev) => {
3342
4747
  if (prev === null) return prev;
3343
- /* 用哪一档由 tiered 决定,两档数值都保留:取消勾选再勾回来,空闲价还在 */
3344
- return {
3345
- tiered: on,
3346
- peak: Object.assign({}, prev.peak),
3347
- offPeak: Object.assign({}, prev.offPeak)
3348
- };
4748
+ if (prev.tiered === on) return prev;
4749
+ /* **只在勾上时清空**(用户要求:"勾选后全部留空、等重新填")。
4750
+ 为什么取消勾选不清:单档显示与计价用的就是 `peak` 那一行 —— 取消勾选是
4751
+ `peak → peak`,同一行、不存在串档;会串档的只有"单档 → 高峰行"这一次,
4752
+ 而勾上时清空已经把它挡住了。双向清空会把用户**没动过**的数据静默抹掉
4753
+ (实测:`peak.cacheWrite` 从 0 变 null、`peak.output` 从 8 变空),
4754
+ 那是用户可见的数据丢失,代价远大于"切换模式要重填"的收益。
4755
+ `cacheWrite` 连勾上时也不清:界面从来不渲染这一格(历史遗留字段),
4756
+ 清掉等于改用户看不到的数据。 */
4757
+ const cleared = (tier) => (on === true
4758
+ ? { input: "", cacheRead: "", output: "", cacheWrite: tier.cacheWrite }
4759
+ : tier);
4760
+ return { tiered: on, currency: prev.currency, peak: cleared(prev.peak), offPeak: cleared(prev.offPeak) };
3349
4761
  });
3350
4762
  }
3351
4763
  function saveDraft() {
3352
4764
  if (editing === null || draft === null) return;
3353
- const num = (value) => Number(value) || 0;
3354
- const asNumber = (tier) => ({ input: num(tier.input), cacheRead: num(tier.cacheRead), cacheWrite: num(tier.cacheWrite), output: num(tier.output) });
4765
+ /* 逐格转换:空字符串(没填)保留成 `null`,填了的值按数字存 —— 用户明确填的 0 就是 0。
4766
+ 这样"只填一格"保存后,其余格子仍然是空的,不会再被写成 0。 */
4767
+ /* 空字符串 = 没填 → null;非法输入(粘贴进字母)也当没填,不要压成 0 ——
4768
+ 压成 0 会被当成"用户明确把价定成了 0",第二轮匹配就会拿它算钱 */
4769
+ const asValue = (raw) => {
4770
+ if (raw === "") return null;
4771
+ const n = Number(raw);
4772
+ return Number.isFinite(n) === true ? n : null;
4773
+ };
4774
+ const asTier = (tier) => ({
4775
+ input: asValue(tier.input),
4776
+ cacheRead: asValue(tier.cacheRead),
4777
+ cacheWrite: asValue(tier.cacheWrite),
4778
+ output: asValue(tier.output)
4779
+ });
4780
+ const peak = asTier(draft.peak);
4781
+ const offPeak = asTier(draft.offPeak);
4782
+ /* 一个数都没填也**照常写入**(用户实测反馈:新建的自定义模型价格先空着,
4783
+ 点「保存」不该把整条删掉)。空条目不会把费用静默算成 0 —— 计价匹配层
4784
+ `hasAnyPriceValue` 已把八格全空的条目挡在两轮之外(见那里的注释)。
4785
+ 真要取消添加,用卡片上的「删除」。 */
4786
+ const hasValue = (tier) => tier.input !== null || tier.cacheRead !== null
4787
+ || tier.cacheWrite !== null || tier.output !== null;
3355
4788
  const next = Object.assign({}, cfg.models);
3356
4789
  next[editing] = {
3357
4790
  tiered: draft.tiered === true,
3358
- peak: asNumber(draft.peak),
4791
+ /* 条目自己的币种:界面上的换法见 currencySelect 那处(换完按新币种重建草稿) */
4792
+ currency: normalizeCurrency(draft.currency),
4793
+ peak: peak,
3359
4794
  /* 两档都照实存:计价用哪一档看 tiered,取消勾选不该把空闲价抹掉 */
3360
- offPeak: asNumber(draft.offPeak)
4795
+ offPeak: offPeak
3361
4796
  };
3362
- setConfig({ models: next, priceConfigured: true });
4797
+ /* 只有真填了数字才算"配置过价格":一条全空的条目不该把"请填写价格"的提示顶掉 */
4798
+ if (hasValue(peak) === true || hasValue(offPeak) === true) {
4799
+ setConfig({ models: next, priceConfigured: true });
4800
+ } else {
4801
+ setConfig({ models: next });
4802
+ }
3363
4803
  setEditing(null);
3364
4804
  setDraft(null);
3365
4805
  }
@@ -3367,7 +4807,15 @@ window.__ModuleLoader__.load({
3367
4807
  const sections = [];
3368
4808
 
3369
4809
  sections.push(h("div", { className: "dsb-head", key: "__head" }, [
3370
- h("h2", { className: "dsb-pagetitle" }, t("sectionTitle")),
4810
+ /* 标题那一行:开发模式开着时,标题文字右上角跟一枚胶囊 —— 像 x² 的上标那样
4811
+ 紧贴着标题,而不是甩到面板最右边(那样看不出是「这个标题的」标记)。
4812
+ 关着时这一行与原来**完全一样** —— 不多任何元素。 */
4813
+ h("div", { className: "dsb-titlerow", key: "__titlerow" },
4814
+ h("h2", { className: "dsb-pagetitle" }, t("sectionTitle")),
4815
+ devOn === true
4816
+ ? h("span", { className: "dsb-nav-pill dsb-dev-pill", key: "__devpill", title: t("devModeHint") }, t("devModeBadge"))
4817
+ : null
4818
+ ),
3371
4819
  h("p", { className: "dsb-intro" }, t("intro"))
3372
4820
  ]));
3373
4821
 
@@ -3604,8 +5052,16 @@ window.__ModuleLoader__.load({
3604
5052
  }, rowParts(segment, on));
3605
5053
  });
3606
5054
 
3607
- /* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy 走 */
3608
- const dragCard = drag === null || drag.at === null || drag.at === undefined ? null : h("div", {
5055
+ /* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy 走。
5056
+ *
5057
+ * **必须 portal 到 body**(2026-10-07 实测):壁纸引擎给设置面板挂了
5058
+ * `backdrop-filter`(body[data-we-glass-window] [role="dialog"]:has([data-slot="settings.section"])),
5059
+ * 而非 none 的 backdrop-filter 会让该元素成为 position:fixed 后代的**包含块**,
5060
+ * 于是这张本该相对视口定位的卡改成相对设置面板定位 —— 实测偏移 dx=220/dy=80;
5061
+ * 设置面板又自带 overflow:hidden(.wCInkW_panel),浮层随即被裁掉一截。
5062
+ * 挂到 body 下就没有这个祖先了,fixed 恢复视口锚定,也不受面板裁剪。
5063
+ * 取不到 createPortal 时退回就地渲染:功能照旧,只是那层玻璃在场时会偏。 */
5064
+ const dragCardNode = drag === null || drag.at === null || drag.at === undefined ? null : h("div", {
3609
5065
  className: "dsb-drag-card",
3610
5066
  key: "__dragcard",
3611
5067
  style: {
@@ -3615,6 +5071,11 @@ window.__ModuleLoader__.load({
3615
5071
  height: drag.at.height + "px"
3616
5072
  }
3617
5073
  }, rowParts(byId[drag.id], isSegmentOn(cfg, drag.id)));
5074
+ const dragCard = dragCardNode === null
5075
+ ? null
5076
+ : (canPortal === true && typeof document !== "undefined" && document !== null && document.body !== undefined && document.body !== null
5077
+ ? reactDom.createPortal(dragCardNode, document.body)
5078
+ : dragCardNode);
3618
5079
 
3619
5080
  sections.push(h("div", { className: "dsb-section", key: "__segments" },
3620
5081
  h("div", { className: "dsb-sectitle" }, t("secSegments")),
@@ -3626,7 +5087,9 @@ window.__ModuleLoader__.load({
3626
5087
 
3627
5088
  /* 自定义模型价格 */
3628
5089
  const priceCards = [];
3629
- priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint", { currency: cfg.currency })));
5090
+ priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint")));
5091
+ /* 价格区顶部**没有**全局币种选择器(用户明确要求去掉那个残留):
5092
+ 币种只在每个条目的编辑浮层里选,改哪条只动哪条。 */
3630
5093
  /* 「更新节假峰谷」+ 它的状态提示:按钮右边只在点过之后出现状态 ——
3631
5094
  搜索中 / 已写入概况,平时不占位。抓取失败的原因挪到按钮的悬停提示里 */
3632
5095
  const holidayStatus = holidaySummary(Date.now());
@@ -3739,7 +5202,8 @@ window.__ModuleLoader__.load({
3739
5202
  /* 编辑区做成毛玻璃浮层(与底栏气泡、计价单位下拉同一套材质),从卡片头部下方展开 */
3740
5203
  const body = open === true
3741
5204
  ? h("div", { className: "dsb-edit-pop", key: "__edit", ref: editPopRef }, [
3742
- /* 这一行左边是「峰谷计价」勾选,右边挂计价单位下拉(没有标题,就一个框) */
5205
+ /* 这一行左边是「峰谷计价」勾选,右边挂**本条目的**币种下拉:
5206
+ 改哪条只动哪条(换算也只换算这一条的数值),与三个价格框同一处。 */
3743
5207
  h("div", { className: "dsb-tier-head", key: "__tiered" },
3744
5208
  h("label", { className: "dsb-inline-check" },
3745
5209
  h("input", {
@@ -3749,33 +5213,13 @@ window.__ModuleLoader__.load({
3749
5213
  }),
3750
5214
  h("span", null, t("priceTiered"))
3751
5215
  ),
3752
- h("div", { className: "dsb-select", ref: currencyRef },
3753
- h("button", {
3754
- type: "button",
3755
- className: "dsb-select-btn",
3756
- "aria-haspopup": "listbox",
3757
- "aria-expanded": currencyOpen === true ? "true" : "false",
3758
- onClick: () => setCurrencyOpen(currencyOpen !== true)
3759
- }, cfg.currency),
3760
- currencyOpen === true
3761
- ? h("div", { className: "dsb-select-menu", role: "listbox" },
3762
- ["CNY", "USD"].map((code) => h("button", {
3763
- type: "button",
3764
- key: code,
3765
- role: "option",
3766
- "aria-selected": cfg.currency === code ? "true" : "false",
3767
- className: cfg.currency === code ? "dsb-select-item dsb-select-item-on" : "dsb-select-item",
3768
- onClick: () => {
3769
- const switched = switchCurrency(code);
3770
- setCurrencyOpen(false);
3771
- /* 草稿跟着换成新币种的价格:不然框里还留着旧币种的数字 */
3772
- if (switched !== null && editing !== null) startEdit(editing, switched.models[editing]);
3773
- }
3774
- }, code)))
3775
- : null
5216
+ h("div", { className: "dsb-currency-pick", key: "__curpick" },
5217
+ currencySelect("__entrycur", draft, currencyOpen, setCurrencyOpen, currencyMenuRef, currencyRef)
3776
5218
  )
3777
5219
  ),
3778
- draft.tiered === true ? tierRow("offPeak", t("priceTierOffPeak")) : tierRow("peak", null),
5220
+ /* 未勾选时那行也有档位名(「全时段」),与勾选后的高峰/空闲两行结构一致,
5221
+ 布局自然一致 —— 取代原先靠 CSS 做的双边对齐 */
5222
+ draft.tiered === true ? tierRow("offPeak", t("priceTierOffPeak")) : tierRow("peak", t("priceTierAllDay")),
3779
5223
  draft.tiered === true ? tierRow("peak", t("priceTierPeak")) : null
3780
5224
  ])
3781
5225
  : null;
@@ -3799,28 +5243,60 @@ window.__ModuleLoader__.load({
3799
5243
  type: "button",
3800
5244
  className: "dsb-action",
3801
5245
  key: "__star",
3802
- /* window.open 会被主窗口的 setWindowOpenHandler 接住,转 shell.openExternal → 默认浏览器;
5246
+ /* 点完**主动松开焦点**再跳转。气泡的显示条件是 `:hover` 或 `:focus-within`;
5247
+ 点击会让按钮获得 DOM 焦点,而跳去浏览器只让**窗口**失焦、DOM 焦点还留在按钮上 ——
5248
+ 于是 `:focus-within` 一直为真,用户关掉浏览器回来时气泡就卡住常显(实测缺陷)。
5249
+ blur 掉之后气泡回到只跟鼠标悬停走,行为才符合预期。
5250
+ window.open 会被主窗口的 setWindowOpenHandler 接住,转 shell.openExternal → 默认浏览器;
3803
5251
  noopener 只是不给新窗口留 window.opener(这里窗口本来就被拒了) */
3804
- onClick: () => window.open(REPO_URL, "_blank", "noopener")
5252
+ onClick: (event) => {
5253
+ const button = event === null || event === undefined ? null : event.currentTarget;
5254
+ if (button !== null && button !== undefined && typeof button.blur === "function") button.blur();
5255
+ window.open(REPO_URL, "_blank", "noopener");
5256
+ }
3805
5257
  }, t("starRepo"))
3806
5258
  ]),
3807
5259
  h("button", {
3808
5260
  type: "button",
3809
5261
  className: "dsb-action",
3810
5262
  key: "__reset",
3811
- onClick: () => setConfig({
3812
- contextMeter: true,
3813
- segments: DEFAULT_SEGMENTS.slice(),
3814
- hidden: [],
3815
- currency: "CNY",
3816
- models: Object.assign({}, DEFAULT_PRICES),
3817
- modelOrder: Object.keys(DEFAULT_PRICES)
3818
- })
5263
+ /* 「恢复默认设置」= 界面配置回出厂 + 价格库**分两类**重置:
5264
+ · **官方内置条目**(键命中 `DEFAULT_PRICES`)→ 恢复成官方默认值(逐字段等于
5265
+ 官方表,`tiered` 与两档一起、连官方写的 0/null 也照抄),币种回到 **CNY**;
5266
+ 被删掉的官方条目会**加回来**;顺序按官方表顺序排在**前面**。
5267
+ **取舍**:用户改过某个官方模型的价格,恢复默认会把它覆盖回官方价 ——
5268
+ 这正是"恢复默认"的语义(想留住自定义价就别用官方键)。
5269
+ · **用户自建条目**(键不在官方表里)→ **原样保留**(数值/档位/`currency`
5270
+ 一个字节都不动),顺序跟在官方条目后面。
5271
+ 其余键(`priceVersion` / `priceConfigured` / `holidayDays` 等)靠 `setConfig`
5272
+ 的**合并**语义原样留着。台账与历史数字一概不动(这里只写配置)。 */
5273
+ onClick: () => {
5274
+ const reset = resetPriceBook();
5275
+ setConfig({
5276
+ contextMeter: true,
5277
+ segments: DEFAULT_SEGMENTS.slice(),
5278
+ hidden: [],
5279
+ /* 内部默认币种(界面已不暴露它,只用于老配置迁移与底栏后备显示) */
5280
+ currency: "CNY",
5281
+ models: reset.models,
5282
+ modelOrder: reset.modelOrder
5283
+ });
5284
+ }
3819
5285
  }, t("reset")),
3820
- /* 有新版才出现:与导航胶囊同一个来源(updateState),点它走 DSH 官方的插件安装接口。
3821
- 三种状态都写在按钮自己身上:空闲报版本号、更新中带动效点、失败就说「更新失败」;
3822
- 装完直接撤掉 —— 末尾的版本号尾标会变成新版本,不必再多一句提示。 */
3823
- update.hasUpdate === true && update.note !== "done"
5286
+ /* 有新版才出现:与导航胶囊同一个来源(updateState)。
5287
+ 点它由本插件自己的 host 端执行更新(不依赖插件管理器,也不吃 pnpm 的
5288
+ 24 小时发布年龄限制)。三种状态都写在按钮自己身上:空闲报版本号、
5289
+ 更新中带动效点、失败就说「更新失败」。
5290
+
5291
+ **装完必须重启 DSH,刷新页面没有用** —— 这不是保守说法,是 DSH 的设计:
5292
+ · `@deepseek-ai/dsh-hmr` 的已知限制原文:「通过插件管理器替换已安装包版本
5293
+ 仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。」
5294
+ · `dsh-client-modules` 的包元数据(含 client.js 的 revision)
5295
+ 「缓存至重启」,所以刷新只会重新拉同一份旧 bundle。
5296
+ · 官方插件管理器的文案也是同一口径:「已安装,下次启动后加载。」
5297
+ 桌面宿主只提供退出能力、没有自我重启能力(`app.relaunch()` 只在开发模式
5298
+ 的菜单里),所以这里只如实提示,不假装能替用户重启。 */
5299
+ update.hasUpdate === true
3824
5300
  ? h("button", {
3825
5301
  type: "button",
3826
5302
  className: "dsb-action",
@@ -3833,21 +5309,56 @@ window.__ModuleLoader__.load({
3833
5309
  h("span", { key: "__d2" }, "."),
3834
5310
  h("span", { key: "__d3" }, ".")
3835
5311
  ])]
3836
- : (update.note === "failed"
3837
- ? t("updateFailed")
3838
- : t("updateTo", { version: update.latest === null ? "" : update.latest })))
5312
+ : (update.note === "done"
5313
+ ? t("updateDone")
5314
+ : (update.note === "failed"
5315
+ ? t("updateFailed")
5316
+ : t("updateTo", { version: update.latest === null ? "" : update.latest }))))
3839
5317
  : null,
3840
- /* 版本标记贴右、与行内底边对齐:改了客户端代码要刷新页面才生效,这行字让"跑的是哪版"一眼可见 */
3841
- h("span", { className: "dsb-version", key: "__version" }, "dsh-desktop-statusbar " + VERSION)
5318
+ /* 版本标记贴右、与行内底边对齐:这行字让「现在跑的是哪一版」一眼可见 ——
5319
+ 注意**光刷新页面看不出差别**:插件包元数据缓存至重启,刷新拿到的还是旧 bundle;
5320
+ 要换版本必须重启 DSH,重启后这行字才会变成新号。
5321
+
5322
+ 它同时是**开发模式的隐藏入口**:连点 5 次进入 / 退出(见 onVersionClick)。
5323
+ 挂在版本号上的原因:这个入口面向作者调试,普通用户的界面里不该多出
5324
+ 任何可见元素;连点是常见手势,记得住又不会被误触。
5325
+ 进入后的可见迹象是标题右侧那枚「开发模式」胶囊,不在这一行。 */
5326
+ h("span", {
5327
+ className: "dsb-version",
5328
+ key: "__version",
5329
+ onClick: onVersionClicked
5330
+ }, "dsh-desktop-statusbar " + VERSION)
3842
5331
  ]));
3843
5332
 
3844
5333
  return h("div", { className: "dsb-settings" }, sections);
3845
5334
  }
3846
5335
 
3847
5336
  /* --------------------------------------------------------------- apply */
5337
+
5338
+ /* 自测/测试成员可直接拿到的纯函数(跨请求场景要靠它们驱动,见 tests/ 里的 harness)。
5339
+ 注意:测试 harness 习惯在 `exports.apply = apply;` 那一行**前**插一份自己的
5340
+ `exports.__test = {...}`,位置在下面这行之后 ⇒ 它们那份会覆盖这一份(这是有意的,
5341
+ 免得两份列表互相打架);要测这里的东西,把它们加进 harness 自己那份列表即可。 */
5342
+ exports.__test = {
5343
+ nextSubagentState: nextSubagentState,
5344
+ applyTurnFloor: applyTurnFloor,
5345
+ SUBAGENT_STATE_EMPTY: SUBAGENT_STATE_EMPTY,
5346
+ /* 开发模式的驱动入口:测试要能直接触发连点、并读回状态,
5347
+ 不然这段只存在于设置页组件里的逻辑没法离线验证。 */
5348
+ setDevMode: setDevMode,
5349
+ runDevPreview: runDevPreview,
5350
+ isDevMode: isDevMode,
5351
+ onVersionClick: onVersionClick,
5352
+ DEV_VERSION: DEV_VERSION,
5353
+ DEV_FAIL_REASON: DEV_FAIL_REASON,
5354
+ DEV_CLICKS: DEV_CLICKS,
5355
+ DEV_CLICK_WINDOW_MS: DEV_CLICK_WINDOW_MS
5356
+ };
5357
+
3848
5358
  function apply(ctx) {
3849
- /* 存下来给「更新」按钮用:它要调 ctx.remote.pluginManager */
3850
- pluginCtx = ctx;
5359
+ /* 注意:这里**不保存 ctx**。更新走本插件自己的 host 路由
5360
+ (见 startPluginUpdate 的说明),界面其余部分也不需要宿主上下文 ——
5361
+ 曾经存过一份 `pluginCtx` 只写不读,已删除。 */
3851
5362
  ctx.effect(installStyles, "dsh-desktop-statusbar: styles");
3852
5363
  ctx.effect(() => ctx.locale.register(NS, { zh: zh, en: en }), "dsh-desktop-statusbar: locale");
3853
5364
  ctx.effect(() => installNavIcon(