dsh-desktop-statusbar 1.0.0 → 2.0.0

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
@@ -7,7 +7,7 @@
7
7
  * 3. 往 `settings.section` 注册设置页:基础开关 / 数据字段(拖拽排序)/ 自定义模型价格。
8
8
  *
9
9
  * 数据来源:
10
- * - 官方投影 sessionStats / tokenUsage / contextPressure
10
+ * - 官方投影 sessionStats / tokenUsage
11
11
  * - 本插件 host 侧注册的 desktopStatusbarModel / desktopStatusbarUsage / desktopStatusbarActiveTime
12
12
  * - 会话作用域槽的标准 props:useSession / useSessions / useChat / sessionId
13
13
  * - 本插件 host 侧路由 /dsh-desktop-statusbar/api/balance(余额)与 /active-model(当前模型上报)
@@ -31,8 +31,10 @@ window.__ModuleLoader__.load({
31
31
  const LEGACY_STORAGE_KEY = "dsh.miniStatusBar.v1";
32
32
  const BALANCE_URL = "/dsh-desktop-statusbar/api/balance";
33
33
  const ACTIVE_MODEL_URL = "/dsh-desktop-statusbar/api/active-model";
34
+ /* 余额轮询:host 端不再缓存,所以这个间隔就是底栏的更新粒度(1 分钟一次) */
34
35
  const BALANCE_POLL_MS = 60000;
35
36
  const PRICES_URL = "/dsh-desktop-statusbar/api/prices";
37
+ const HOLIDAYS_URL = "/dsh-desktop-statusbar/api/holidays";
36
38
  /**
37
39
  * 配置结构版本。
38
40
  * v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
@@ -43,7 +45,7 @@ window.__ModuleLoader__.load({
43
45
 
44
46
  /* ------------------------------------------------------------ 价格库 */
45
47
 
46
- /** DeepSeek 参考价(CNY / 1M tokens)。高峰时段为北京时间周一至周五 9:00-12:00、14:00-18:00,空闲价为高峰价的一半。 */
48
+ /** DeepSeek 参考价(CNY / 1M tokens)。高峰时段为北京时间周一至周五 9:00-12:00、14:00-18:00(法定节假日除外),空闲价为高峰价的一半。 */
47
49
  const DEFAULT_PRICES = {
48
50
  "deepseek-flash": {
49
51
  peak: { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
@@ -55,9 +57,33 @@ window.__ModuleLoader__.load({
55
57
  }
56
58
  };
57
59
 
60
+ /** 官方参考价的美元表:与人民币表同一页面(2026-09 核对)。空闲价同样是高峰价的一半。 */
61
+ const DEFAULT_PRICES_USD = {
62
+ "deepseek-flash": {
63
+ peak: { input: 0.3, cacheRead: 0.006, cacheWrite: 0, output: 1.2 },
64
+ offPeak: { input: 0.15, cacheRead: 0.003, cacheWrite: 0, output: 0.6 }
65
+ },
66
+ "deepseek-v4-pro": {
67
+ peak: { input: 1.32, cacheRead: 0.044, cacheWrite: 0, output: 3.96 },
68
+ offPeak: { input: 0.66, cacheRead: 0.022, cacheWrite: 0, output: 1.98 }
69
+ }
70
+ };
71
+
72
+ /** 取某个币种的官方参考价表。 */
73
+ function referencePrices(currency) {
74
+ return currency === "USD" ? DEFAULT_PRICES_USD : DEFAULT_PRICES;
75
+ }
76
+
58
77
  const EMPTY_TIER = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
59
78
 
60
79
  /** 把一条价格规范化成 { peak, offPeak };旧的平铺结构视为两档同价。 */
80
+ /** 两档数值是否逐项相同:用来识别被写坏的「空闲价 = 高峰价」。 */
81
+ function sameTier(left, right) {
82
+ if (left === null || left === undefined || right === null || right === undefined) return false;
83
+ return left.input === right.input && left.cacheRead === right.cacheRead
84
+ && left.cacheWrite === right.cacheWrite && left.output === right.output;
85
+ }
86
+
61
87
  function normalizePrice(entry) {
62
88
  if (entry === null || entry === undefined || typeof entry !== "object") return null;
63
89
  const flatten = (value) => ({
@@ -93,14 +119,102 @@ window.__ModuleLoader__.load({
93
119
 
94
120
  /**
95
121
  * 高峰时段判定:北京时间(UTC+8)周一至周五 9:00-12:00、14:00-18:00。
96
- * 官方口径是"其余为空闲时段",所以周末整天空闲价。
122
+ * 官方口径(2026-09-19 峰谷时间说明):周末、调休上班的周末、中国法定节假日全天都按空闲时段计费,
123
+ * 所以高峰只落在"工作日且放假安排里没有放假"的日子。
97
124
  */
98
125
  const PEAK_WINDOWS = [[9, 12], [14, 18]];
126
+ /**
127
+ * 内嵌的放假日历:年份 → 落在周一至周五的放假日(北京时间 YYYY-MM-DD)。
128
+ * 依据《国务院办公厅关于2026年部分节假日安排的通知》(国办发明电〔2025〕7号):
129
+ * 元旦 1/1-1/3、春节 2/15-2/23、清明 4/4-4/6、劳动节 5/1-5/5、端午 6/19-6/21、
130
+ * 中秋 9/25-9/27、国庆 10/1-10/7。
131
+ * 官方每年 10 月底到 11 月中发布下一年的安排;设置页常驻「更新节假峰谷」按钮,
132
+ * 点了就把下一年写进配置(holidayDays),内嵌这份只作出厂兜底。
133
+ */
134
+ const HOLIDAYS = {
135
+ "2026": [
136
+ "2026-01-01", "2026-01-02",
137
+ "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19", "2026-02-20", "2026-02-23",
138
+ "2026-04-06",
139
+ "2026-05-01", "2026-05-04", "2026-05-05",
140
+ "2026-06-19",
141
+ "2026-09-25",
142
+ "2026-10-01", "2026-10-02", "2026-10-05", "2026-10-06", "2026-10-07"
143
+ ]
144
+ };
145
+ const HOLIDAY_SETS = {};
146
+ Object.keys(HOLIDAYS).forEach((year) => { HOLIDAY_SETS[year] = new Set(HOLIDAYS[year]); });
147
+ /** 抓来的放假日(按年索引);setConfig 之后由 refreshHolidays() 重算。 */
148
+ let fetchedHolidays = {};
149
+ function refreshHolidays() {
150
+ const next = {};
151
+ const days = Array.isArray(config.holidayDays) ? config.holidayDays : [];
152
+ days.forEach((day) => {
153
+ if (typeof day !== "string" || day.length !== 10) return;
154
+ const year = day.slice(0, 4);
155
+ if (next[year] === undefined) next[year] = new Set();
156
+ next[year].add(day);
157
+ });
158
+ fetchedHolidays = next;
159
+ }
160
+ /** 这一天是不是放假安排里的假期(内嵌表 ∪ 抓来的表)。 */
161
+ function isHolidayDate(key) {
162
+ const year = key.slice(0, 4);
163
+ const builtin = HOLIDAY_SETS[year];
164
+ if (builtin !== undefined && builtin.has(key)) return true;
165
+ const fetched = fetchedHolidays[year];
166
+ return fetched !== undefined && fetched.has(key);
167
+ }
168
+ /**
169
+ * 「更新节假峰谷」的目标年份:永远是下一年。
170
+ * 官方一般 10 月底到 11 月中公布下一年的安排;按钮常驻,什么时候点都行。
171
+ */
172
+ function nextHolidayYear(now) {
173
+ const at = new Date((typeof now === "number" && now > 0 ? now : Date.now()) + 8 * 3600 * 1000);
174
+ return at.getUTCFullYear() + 1;
175
+ }
176
+ /** 某一年已录入多少个节假日(内嵌 + 抓取,去重),没有就是 0。 */
177
+ function holidayCount(year) {
178
+ const name = String(year);
179
+ const seen = new Set();
180
+ const builtin = HOLIDAY_SETS[name];
181
+ if (builtin !== undefined) builtin.forEach((day) => seen.add(day));
182
+ const fetched = fetchedHolidays[name];
183
+ if (fetched !== undefined) fetched.forEach((day) => seen.add(day));
184
+ return seen.size;
185
+ }
186
+ /**
187
+ * 按钮右边那行状态文案用的概况:有下一年的表就报下一年,否则报当年;
188
+ * 当年也没有(跨年还没更新)就用已录入里最靠后的一年,别显示 0 天。
189
+ */
190
+ function holidaySummary(now) {
191
+ const at = new Date((typeof now === "number" && now > 0 ? now : Date.now()) + 8 * 3600 * 1000);
192
+ const thisYear = at.getUTCFullYear();
193
+ const nextCount = holidayCount(thisYear + 1);
194
+ if (nextCount > 0) return { year: thisYear + 1, count: nextCount };
195
+ const count = holidayCount(thisYear);
196
+ if (count > 0) return { year: thisYear, count: count };
197
+ let latest = null;
198
+ Object.keys(HOLIDAY_SETS).concat(Object.keys(fetchedHolidays)).forEach((name) => {
199
+ const year = Number(name);
200
+ if (latest === null || year > latest) latest = year;
201
+ });
202
+ return latest === null ? { year: thisYear, count: 0 } : { year: latest, count: holidayCount(latest) };
203
+ }
204
+
205
+ /** 按北京时间取日期键(YYYY-MM-DD),用来查节假日。 */
206
+ function beijingDateKey(beijing) {
207
+ const month = String(beijing.getUTCMonth() + 1).padStart(2, "0");
208
+ const date = String(beijing.getUTCDate()).padStart(2, "0");
209
+ return beijing.getUTCFullYear() + "-" + month + "-" + date;
210
+ }
211
+
99
212
  function isPeakTime(ms) {
100
213
  const at = typeof ms === "number" && ms > 0 ? ms : Date.now();
101
214
  const beijing = new Date(at + 8 * 3600 * 1000);
102
215
  const day = beijing.getUTCDay();
103
216
  if (day === 0 || day === 6) return false;
217
+ if (isHolidayDate(beijingDateKey(beijing))) return false;
104
218
  const hour = beijing.getUTCHours() + beijing.getUTCMinutes() / 60;
105
219
  for (let i = 0; i < PEAK_WINDOWS.length; i += 1) {
106
220
  if (hour >= PEAK_WINDOWS[i][0] && hour < PEAK_WINDOWS[i][1]) return true;
@@ -142,12 +256,12 @@ window.__ModuleLoader__.load({
142
256
 
143
257
  /**
144
258
  * 价格缺失或全 0 时回退到官方参考价:优先向 host 要(那儿是最新的),
145
- * 拿不到就用内嵌的 DEFAULT_PRICES。用户填过的非 0 价格不会被覆盖。
259
+ * 拿不到就用内嵌的那套(按当前计价单位取人民币或美元表)。用户填过的非 0 价格不会被覆盖。
146
260
  */
147
261
  function ensurePrices() {
148
262
  if (!pricesMissing(config.models)) return Promise.resolve(false);
149
263
  const fallback = () => {
150
- setConfig({ models: normalizeModels(DEFAULT_PRICES), priceVersion: PRICE_VERSION, priceConfigured: true });
264
+ setConfig({ models: normalizeModels(referencePrices(config.currency)), priceVersion: PRICE_VERSION, priceConfigured: true });
151
265
  return false;
152
266
  };
153
267
  try {
@@ -165,6 +279,31 @@ window.__ModuleLoader__.load({
165
279
  }
166
280
  }
167
281
 
282
+ /** 抓下一年(或指定年)的放假安排并写进配置;返回给设置页提示用的结果(都带 year)。 */
283
+ function updateHolidays(year) {
284
+ return window.fetch(HOLIDAYS_URL + "?year=" + String(year), { cache: "no-store" })
285
+ .then((response) => {
286
+ /* 404 = 后台还是旧版(host 要重启才会挂上新路由),跟"官方还没发布"得分开说 */
287
+ if (response.ok !== true) {
288
+ return { ok: false, year: year, reason: response.status === 404 ? "no-route" : "http-" + String(response.status) };
289
+ }
290
+ return response.json().then((data) => {
291
+ const days = data !== null && data !== undefined && Array.isArray(data.days)
292
+ ? data.days.filter((day) => typeof day === "string" && day.length === 10)
293
+ : [];
294
+ if (days.length === 0) {
295
+ const reason = data !== null && data !== undefined && typeof data.reason === "string" ? data.reason : "not-found";
296
+ return { ok: false, year: year, reason: reason };
297
+ }
298
+ const known = Array.isArray(config.holidayDays) ? config.holidayDays : [];
299
+ const merged = known.concat(days.filter((day) => known.indexOf(day) === -1)).sort();
300
+ setConfig({ holidayDays: merged });
301
+ return { ok: true, year: year, count: days.length };
302
+ });
303
+ })
304
+ .catch(() => ({ ok: false, year: year, reason: "fetch-failed" }));
305
+ }
306
+
168
307
  function currencySymbol(currency) {
169
308
  return currency === "USD" ? "$" : "¥";
170
309
  }
@@ -187,17 +326,27 @@ window.__ModuleLoader__.load({
187
326
  }
188
327
 
189
328
 
190
- /** 按价格库算一段用量的费用(按给定时刻定峰谷),返回不带符号的两位小数字符串。 */
329
+ /**
330
+ * 按价格库算一段用量的费用(按给定时刻定峰谷),返回**未舍入的数值**。
331
+ * 不要在这里舍入:总计是逐条调用累加出来的,单条先砍到分会让几百次调用的误差累积成几毛钱
332
+ * (实测 171.8M token 的会话里少算了 0.75 元)。显示统一走 costText。
333
+ */
191
334
  function costOf(bucket, model, at) {
192
335
  const price = priceOf(model);
193
336
  if (price === null || bucket === undefined || bucket === null) return null;
194
337
  const tier = isPeakTime(at) ? price.peak : price.offPeak;
195
- const amount = ((bucket.input || 0) * tier.input
196
- + (bucket.cacheRead || 0) * tier.cacheRead
197
- + (bucket.cacheWrite || 0) * tier.cacheWrite
198
- + (bucket.output || 0) * tier.output) / 1e6;
199
- /* 大缓存会话单条调用可能不到一分钱,退到 4 位小数,避免一律显示 0.00 */
200
- return amount > 0 && amount < 0.01 ? amount.toFixed(4) : amount.toFixed(2);
338
+ return ((bucket.input || 0) * tier.input
339
+ + (bucket.cacheRead || 0) * tier.cacheRead
340
+ + (bucket.cacheWrite || 0) * tier.cacheWrite
341
+ + (bucket.output || 0) * tier.output) / 1e6;
342
+ }
343
+
344
+ /**
345
+ * 费用显示:一律两位小数,不满一分钱也进到 0.01(0.0075 → 0.01),不出现更长的小数位。
346
+ * 累加用 costOf 的原始值,只在这里做一次舍入。
347
+ */
348
+ function costText(amount) {
349
+ return amount.toFixed(2);
201
350
  }
202
351
 
203
352
  /** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
@@ -236,7 +385,13 @@ window.__ModuleLoader__.load({
236
385
  return out;
237
386
  }
238
387
 
239
- function foldTurnUsage(nodes, skip) {
388
+ /**
389
+ * 把会话节点里**最后一轮**的用量折出来(节点带 turn 号与这条调用的 usage)。
390
+ * 返回 `{ turn, parts }`,没有可用节点时返回 null —— 轮号一起返回是为了让调用方
391
+ * 判断这份数据属于当前进行中的那一轮,还是上一轮(不能靠"往后跳一轮"猜,
392
+ * 当前轮还没产出节点时那样会错位到上上轮)。
393
+ */
394
+ function foldTurnUsage(nodes) {
240
395
  if (!Array.isArray(nodes)) return null;
241
396
  const turns = [];
242
397
  for (const node of nodes) {
@@ -245,9 +400,9 @@ window.__ModuleLoader__.load({
245
400
  if (typeof node.turn !== "number") continue;
246
401
  if (turns.indexOf(node.turn) === -1) turns.push(node.turn);
247
402
  }
248
- if (turns.length <= skip) return null;
403
+ if (turns.length === 0) return null;
249
404
  turns.sort((a, b) => a - b);
250
- const target = turns[turns.length - 1 - skip];
405
+ const target = turns[turns.length - 1];
251
406
  const acc = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
252
407
  let found = false;
253
408
  for (const node of nodes) {
@@ -261,22 +416,20 @@ window.__ModuleLoader__.load({
261
416
  acc.output += usage.outputTokens || 0;
262
417
  found = true;
263
418
  }
264
- return found ? acc : null;
419
+ return found ? { turn: target, parts: acc } : null;
265
420
  }
266
421
 
267
422
  /* ---------------------------------------------------------------- i18n */
268
423
  const zh = {
269
424
  nav: "状态栏",
425
+ sectionTitle: "状态栏设置",
270
426
  intro: "接管对话区底部的统计行。勾选要显示的字段、拖动调整顺序或配置模型费用单价。",
271
- enabled: "启用状态栏",
272
- enabledHint: "关闭后底栏整体隐藏。",
273
- wrap: "允许换行",
274
- wrapHint: "开启后统计段自动折行;关闭时单行省略。",
275
- segStatus: "会话状态",
427
+ segContext: "会话状态",
428
+ segContextHint: "当前会话的运行状态与上下文占用。",
429
+ segStatus: "峰谷判断",
276
430
  segCounts: "轮次&步数",
277
431
  segTtft: "首字延迟",
278
432
  segCacheHit: "缓存命中率",
279
- segTokens: "Token计数",
280
433
  segTps: "输出速度",
281
434
  segSessionTime: "运行用时",
282
435
  segCost: "会话费用",
@@ -285,15 +438,27 @@ window.__ModuleLoader__.load({
285
438
  f_peak: "高峰",
286
439
  f_valley: "低谷",
287
440
  f_counts: "{turns} 轮 {steps} 步",
288
- f_llm: "模型 {duration}",
289
- f_tool: "工具 {duration}",
290
441
  f_ttft: "首字平均 {duration}",
291
- f_cacheHit: "缓存命中 {percent}%",
292
- f_tokens: "{total} tokens",
442
+ f_cacheHit: "综合命中 {percent}%",
293
443
  f_tokensHit: "输入(命中缓存)",
294
444
  f_tokensMiss: "输入(未命中缓存)",
295
445
  f_tokensOut: "输出",
296
- f_context: "上下文 {percent}%",
446
+ f_tokensTotal: "总计用量",
447
+ f_tokensTurn: "本轮用量",
448
+ tipModelTime: "模型用时",
449
+ tipToolTime: "工具调用",
450
+ tipTtftFast: "最快首字延迟",
451
+ tipTtftSlow: "最慢首字延迟",
452
+ tipTpsFast: "最快输出速度",
453
+ tipTpsSlow: "最慢输出速度",
454
+ tipTopUp: "充值余额",
455
+ tipGranted: "赠金余额",
456
+ tipHitHigh: "最高缓存命中率",
457
+ tipHitLow: "最低缓存命中率",
458
+ contextAria: "上下文已用 {percent}",
459
+ contextSystem: "系统提示词",
460
+ contextTools: "工具定义",
461
+ contextMessages: "对话消息",
297
462
  f_speed: "输出速度 {throughput}t/s",
298
463
  f_sessionTime: "总用时 {duration}",
299
464
  f_cost: "总计 {symbol}{cost}",
@@ -302,18 +467,17 @@ window.__ModuleLoader__.load({
302
467
  f_balanceDash: "余额 -",
303
468
  f_lastCost: "本轮 {symbol}{cost}",
304
469
  f_balance: "余额 {symbol}{amount}",
470
+ f_balanceSubCent: "余额 <{symbol}0.01",
305
471
  dragHint: "拖动排序",
306
- secBasic: "基础",
307
- secSegments: "数据字段",
308
- secSegmentsHint: "勾选显示;按住拖动可调整顺序。",
472
+ secSegments: "统计字段",
473
+ secSegmentsHint: "勾选显示;按住拖动调整顺序。",
309
474
  secPrices: "自定义模型价格",
310
- secPricesHint: "填写你使用的模型单价(每百万tokens/元)。",
311
- segStatusHint: "当前会话的运行状态及峰谷时段。",
475
+ secPricesHint: "填写你使用的模型单价(每百万 tokens / {currency})。",
476
+ segStatusHint: "自动判断当前的峰谷时段。",
312
477
  segCountsHint: "当前会话的总轮次与执行步数。",
313
478
  segTtftHint: "首字的平均延迟。",
314
479
  segCacheHitHint: "当前会话的平均缓存命中率。",
315
- segTokensHint: "当前会话累计消耗的模型token(鼠标悬停查看明细)。",
316
- segTpsHint: "token的每秒平均输出速度。",
480
+ segTpsHint: "token 的每秒平均输出速度。",
317
481
  segSessionTimeHint: "当前会话的总计运行时间。",
318
482
  segCostHint: "当前会话估算消耗费用,包含主模型、子代理和辅助调用。",
319
483
  segLastCostHint: "当前或最近一轮交流的估算消耗费用。",
@@ -322,7 +486,6 @@ window.__ModuleLoader__.load({
322
486
  modelUnknown: "未识别",
323
487
  unitCountTurns: " 轮",
324
488
  unitCountSteps: " 步",
325
- unitCost: "",
326
489
  unitSpeed: "t/s",
327
490
  priceSuggestedHint: "价格库正使用官方参考价。",
328
491
  priceNewModel: "自定义模型",
@@ -335,68 +498,82 @@ window.__ModuleLoader__.load({
335
498
  priceTiered: "峰谷计价",
336
499
  priceEdit: "修改",
337
500
  priceSave: "保存",
338
- reset: "恢复默认设置"
501
+ reset: "恢复默认设置",
502
+ holidayUpdate: "更新节假峰谷",
503
+ holidayUpdateBusy: "正在搜索节假安排...",
504
+ holidayUpdateOk: "已写入 {year} 年 {count} 个节假日",
505
+ holidayUpdateMissing: "国务院还没发布 {year} 年放假安排,过几天再试",
506
+ holidayUpdateFail: "抓取失败({reason}),检查网络后重试",
507
+ holidayUpdateNoRoute: "状态栏后台还是旧版:完全退出并重启 DSH 后再点"
339
508
  };
340
509
  const en = {
341
510
  nav: "Status Bar",
342
- intro: "Takes over the stats line under the composer. Tick the fields to show, drag to reorder, or set model prices.",
343
- enabled: "Enable status bar",
344
- enabledHint: "Hides the whole bar when off.",
345
- wrap: "Allow wrapping",
346
- wrapHint: "Segments wrap onto multiple lines; when off, a single elided line.",
347
- segStatus: "Session status",
511
+ sectionTitle: "Status Bar Settings",
512
+ intro: "Replaces the stats line under the message input. Select the fields to show, drag to reorder, or set model prices.",
513
+ segContext: "Session status",
514
+ segContextHint: "Run state and context occupancy of the current session.",
515
+ segStatus: "Peak / off-peak check",
348
516
  segCounts: "Turns & steps",
349
- segTtft: "First-token delay",
517
+ segTtft: "Time to first token",
350
518
  segCacheHit: "Cache hit rate",
351
- segTokens: "Session tokens",
352
- segTps: "Output speed",
519
+ segTps: "Tokens per second",
353
520
  segSessionTime: "Run time",
354
521
  segCost: "Session cost",
355
522
  segLastCost: "This turn cost",
356
523
  segBalance: "Balance",
357
- f_peak: "peak",
358
- f_valley: "off-peak",
359
- f_counts: "{turns} turns · {steps} steps",
360
- f_llm: "model {duration}",
361
- f_tool: "tool {duration}",
362
- f_ttft: "avg first token {duration}",
363
- f_cacheHit: "cache hit {percent}%",
364
- f_tokens: "{total} tokens",
524
+ f_peak: "Peak",
525
+ f_valley: "Off-peak",
526
+ f_counts: "{turns} Turns · {steps} Steps",
527
+ f_ttft: "TTFT {duration}",
528
+ f_cacheHit: "Combined hit {percent}%",
365
529
  f_tokensHit: "input (cache hit)",
366
530
  f_tokensMiss: "input (cache miss)",
367
531
  f_tokensOut: "output",
368
- f_context: "context {percent}%",
369
- f_speed: "output {throughput}t/s",
370
- f_sessionTime: "total {duration}",
371
- f_cost: "total {symbol}{cost}",
532
+ f_tokensTotal: "Total usage",
533
+ f_tokensTurn: "This turn usage",
534
+ tipModelTime: "Model time",
535
+ tipToolTime: "Tool calls",
536
+ tipTtftFast: "Fastest TTFT",
537
+ tipTtftSlow: "Slowest TTFT",
538
+ tipTpsFast: "Fastest TPS",
539
+ tipTpsSlow: "Slowest TPS",
540
+ tipTopUp: "Top-up balance",
541
+ tipGranted: "Granted balance",
542
+ tipHitHigh: "Highest cache hit rate",
543
+ tipHitLow: "Lowest cache hit rate",
544
+ contextAria: "{percent} of context used",
545
+ contextSystem: "System prompt",
546
+ contextTools: "Tool definitions",
547
+ contextMessages: "Messages",
548
+ f_speed: "TPS {throughput}t/s",
549
+ f_sessionTime: "Time {duration}",
550
+ f_cost: "Total {symbol}{cost}",
372
551
  priceTierPeak: "Peak",
373
552
  priceTierOffPeak: "Off-peak",
374
- f_lastCost: "this turn {symbol}{cost}",
375
- f_balance: "balance {symbol}{amount}",
553
+ f_lastCost: "This turn {symbol}{cost}",
554
+ f_balance: "Balance {symbol}{amount}",
555
+ f_balanceSubCent: "Balance <{symbol}0.01",
376
556
  dragHint: "Drag to reorder",
377
- secBasic: "Basics",
378
- secSegments: "Data fields",
379
- secSegmentsHint: "Tick to show; drag to reorder.",
557
+ secSegments: "Stats fields",
558
+ secSegmentsHint: "Select to show; drag to reorder.",
380
559
  secPrices: "Custom model prices",
381
- secPricesHint: "Prices per million tokens (CNY).",
382
- segStatusHint: "Run state and peak/off-peak period of the current session.",
560
+ secPricesHint: "Prices per million tokens, in {currency}.",
561
+ segStatusHint: "Automatically determines the current peak / off-peak window.",
383
562
  segCountsHint: "Total turns and executed steps of the current session.",
384
- segTtftHint: "Average delay of the first token.",
563
+ segTtftHint: "Average time to the first token.",
385
564
  segCacheHitHint: "Average cache hit rate of the current session.",
386
- segTokensHint: "Model tokens consumed by the current session (hover for the breakdown).",
387
- segTpsHint: "Average output speed in tokens per second.",
565
+ segTpsHint: "Average output rate, in tokens per second.",
388
566
  segSessionTimeHint: "Total run time of the current session.",
389
- segCostHint: "Estimated cost of the current session, including the main model, subagents, and helper calls.",
567
+ segCostHint: "Estimated session cost, including the main model, subagents, and helper calls.",
390
568
  segLastCostHint: "Estimated cost of the current or most recent turn.",
391
569
  segBalanceHint: "Account balance, refreshed every minute.",
392
570
  priceCurrent: "Current session:",
393
571
  modelUnknown: "unknown",
394
572
  unitCountTurns: " turns",
395
573
  unitCountSteps: " steps",
396
- unitCost: " $",
397
574
  unitSpeed: "t/s",
398
575
  priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
399
- f_balanceDash: "balance -",
576
+ f_balanceDash: "Balance -",
400
577
  priceNewModel: "Custom model",
401
578
  priceAdd: "Add",
402
579
  priceEmpty: "Price book is empty — type a model name (e.g. deepseek-flash) and click Add.",
@@ -407,23 +584,28 @@ window.__ModuleLoader__.load({
407
584
  priceTiered: "Peak / off-peak pricing",
408
585
  priceEdit: "Edit",
409
586
  priceSave: "Save",
410
- reset: "Reset settings"
587
+ reset: "Reset settings",
588
+ holidayUpdate: "Update holidays",
589
+ holidayUpdateBusy: "Searching the holiday schedule...",
590
+ holidayUpdateOk: "Wrote {count} holidays for {year}",
591
+ holidayUpdateMissing: "The {year} schedule is not published yet — try again in a few days",
592
+ holidayUpdateFail: "Fetch failed ({reason}); check the network and retry",
593
+ holidayUpdateNoRoute: "The status bar backend is still the old build — fully quit and restart DSH, then try again"
411
594
  };
412
595
 
413
596
  /* --------------------------------------------------------------- 段定义
414
597
  * array 顺序即底栏渲染顺序,也是出厂默认顺序;全部段默认勾选。
415
598
  * 「恢复默认设置」按此重排并全选,与设置页截图一致。 */
416
599
  const SEGMENTS = [
417
- { id: "status", label: "segStatus", hint: "segStatusHint", def: true, short: { zh: "峰谷", en: "period" } },
418
- { id: "counts", label: "segCounts", hint: "segCountsHint", def: true, short: { zh: "轮次/步数", en: "turns/steps" } },
419
- { id: "cacheHit", label: "segCacheHit", hint: "segCacheHitHint", def: true, short: { zh: "缓存命中", en: "cache hit" } },
420
- { id: "ttft", label: "segTtft", hint: "segTtftHint", def: true, short: { zh: "首字平均", en: "avg first token" } },
421
- { id: "tps", label: "segTps", hint: "segTpsHint", def: true, short: { zh: "输出速度", en: "output speed" } },
422
- { id: "sessionTime", label: "segSessionTime", hint: "segSessionTimeHint", def: true, short: { zh: "总用时", en: "total time" } },
423
- { id: "lastCost", label: "segLastCost", hint: "segLastCostHint", def: true, short: { zh: "本轮", en: "this turn" } },
424
- { id: "cost", label: "segCost", hint: "segCostHint", def: true, short: { zh: "总计", en: "total" } },
425
- { id: "balance", label: "segBalance", hint: "segBalanceHint", def: true, short: { zh: "余额", en: "balance" } },
426
- { id: "tokens", label: "segTokens", hint: "segTokensHint", def: true, short: { zh: "输入/输出", en: "in/out tokens" } }
600
+ { id: "status", label: "segStatus", hint: "segStatusHint", def: true, short: { zh: "峰谷", en: "Period" } },
601
+ { id: "counts", label: "segCounts", hint: "segCountsHint", def: true, short: { zh: "轮次/步数", en: "Turns/steps" } },
602
+ { id: "cacheHit", label: "segCacheHit", hint: "segCacheHitHint", def: true, short: { zh: "综合命中", en: "Combined hit" } },
603
+ { id: "ttft", label: "segTtft", hint: "segTtftHint", def: true, short: { zh: "首字平均", en: "TTFT" } },
604
+ { id: "tps", label: "segTps", hint: "segTpsHint", def: true, short: { zh: "输出速度", en: "TPS" } },
605
+ { id: "sessionTime", label: "segSessionTime", hint: "segSessionTimeHint", def: true, short: { zh: "总用时", en: "Time" } },
606
+ { id: "lastCost", label: "segLastCost", hint: "segLastCostHint", def: true, short: { zh: "本轮", en: "This turn" } },
607
+ { id: "cost", label: "segCost", hint: "segCostHint", def: true, short: { zh: "总计", en: "Total" } },
608
+ { id: "balance", label: "segBalance", hint: "segBalanceHint", def: true, short: { zh: "余额", en: "Balance" } }
427
609
  ];
428
610
  const KNOWN = {};
429
611
  SEGMENTS.forEach((s) => { KNOWN[s.id] = true; });
@@ -444,13 +626,13 @@ window.__ModuleLoader__.load({
444
626
  const fallback = {
445
627
  version: CONFIG_VERSION,
446
628
  priceConfigured: false,
447
- enabled: true,
448
- wrap: true,
629
+ contextMeter: true,
449
630
  segments: DEFAULT_SEGMENTS.slice(),
450
631
  hidden: [],
451
632
  currency: "CNY",
452
633
  models: Object.assign({}, DEFAULT_PRICES),
453
- modelOrder: Object.keys(DEFAULT_PRICES)
634
+ modelOrder: Object.keys(DEFAULT_PRICES),
635
+ holidayDays: []
454
636
  };
455
637
  try {
456
638
  const raw = readStored();
@@ -477,13 +659,25 @@ window.__ModuleLoader__.load({
477
659
  if (stored.indexOf(id) === -1) stored.push(id);
478
660
  });
479
661
  const hidden = hiddenStored.filter((id) => stored.indexOf(id) !== -1);
662
+ /* 计价单位:只认 CNY / USD,其它值一律当人民币 */
663
+ const currency = parsed.currency === "USD" ? "USD" : "CNY";
664
+ const reference = referencePrices(currency);
480
665
  /* 价格库落后于参考价版本时并入官方价;用户自己改过的条目不覆盖 */
481
666
  /* 价格库为空或全 0 时直接用内嵌官方价(自愈,无需手动填) */
482
- if (pricesMissing(normalizeModels(parsed.models))) parsed.models = Object.assign({}, DEFAULT_PRICES);
667
+ if (pricesMissing(normalizeModels(parsed.models))) parsed.models = Object.assign({}, reference);
483
668
  const priceVersion = typeof parsed.priceVersion === "number" ? parsed.priceVersion : 0;
484
669
  const models = priceVersion < PRICE_VERSION
485
- ? normalizeModels(Object.assign({}, parsed.models, DEFAULT_PRICES))
670
+ ? normalizeModels(Object.assign({}, parsed.models, reference))
486
671
  : normalizeModels(parsed.models);
672
+ /* 旧版取消勾选「峰谷计价」时会把空闲价写成高峰价的副本,再勾回来也回不去。
673
+ 这里按参考价补回:只修"勾着峰谷计价、两档却完全相同"的官方模型。 */
674
+ Object.keys(reference).forEach((name) => {
675
+ const one = models[name];
676
+ if (one === undefined || one === null || one.tiered !== true) return;
677
+ if (!sameTier(one.peak, one.offPeak)) return;
678
+ if (sameTier(reference[name].peak, reference[name].offPeak)) return;
679
+ one.offPeak = Object.assign({}, reference[name].offPeak);
680
+ });
487
681
  const priceConfigured = parsed.priceConfigured === true || priceVersion >= PRICE_VERSION;
488
682
  /* 模型顺序:显式数组(整数样式的键会被 Object.keys 排到最前,不能靠对象键序) */
489
683
  const modelOrder = [];
@@ -496,15 +690,18 @@ window.__ModuleLoader__.load({
496
690
  return {
497
691
  version: CONFIG_VERSION,
498
692
  priceConfigured: parsed.priceConfigured === true,
499
- enabled: parsed.enabled !== false,
500
- wrap: parsed.wrap !== false,
693
+ contextMeter: parsed.contextMeter !== false,
501
694
  segments: stored,
502
695
  hidden: hidden,
503
- currency: "CNY", /* 币种选择已移除:固定人民币 */
696
+ currency: currency,
504
697
  priceVersion: PRICE_VERSION,
505
698
  priceConfigured: priceConfigured,
506
- models: Object.keys(models).length > 0 ? models : normalizeModels(DEFAULT_PRICES),
507
- modelOrder: modelOrder.length > 0 ? modelOrder : Object.keys(DEFAULT_PRICES)
699
+ models: Object.keys(models).length > 0 ? models : normalizeModels(reference),
700
+ modelOrder: modelOrder.length > 0 ? modelOrder : Object.keys(reference),
701
+ /* 抓来的放假日历:字符串数组,按年分组在 refreshHolidays() 里做 */
702
+ holidayDays: Array.isArray(parsed.holidayDays)
703
+ ? parsed.holidayDays.filter((day) => typeof day === "string" && day.length === 10)
704
+ : []
508
705
  };
509
706
  } catch (error) {
510
707
  return fallback;
@@ -512,6 +709,7 @@ window.__ModuleLoader__.load({
512
709
  }
513
710
 
514
711
  let config = loadConfig();
712
+ refreshHolidays();
515
713
  const listeners = new Set();
516
714
  function subscribe(fn) {
517
715
  listeners.add(fn);
@@ -520,6 +718,7 @@ window.__ModuleLoader__.load({
520
718
  function snapshot() { return config; }
521
719
  function setConfig(patch) {
522
720
  config = Object.assign({ version: CONFIG_VERSION }, config, patch);
721
+ refreshHolidays();
523
722
  try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch (error) { /* 隐私模式下仅内存生效 */ }
524
723
  listeners.forEach((fn) => { try { fn(); } catch (error) { /* 单个订阅者失败不影响其余 */ } });
525
724
  }
@@ -532,6 +731,31 @@ window.__ModuleLoader__.load({
532
731
  if (on !== true) hidden.push(id);
533
732
  setConfig({ hidden: hidden });
534
733
  }
734
+ /**
735
+ * 换计价单位:内置参考价模型整体换到另一种币种的那套官方价,
736
+ * 只动"还没被改过"的条目 —— 手改过的价格保留,别覆盖用户的输入。
737
+ */
738
+ function switchCurrency(code) {
739
+ const next = code === "USD" ? "USD" : "CNY";
740
+ if (next === config.currency) return null;
741
+ const from = referencePrices(config.currency);
742
+ const to = referencePrices(next);
743
+ const models = Object.assign({}, config.models);
744
+ Object.keys(to).forEach((name) => {
745
+ const current = models[name];
746
+ const before = from[name];
747
+ if (current === undefined || current === null || before === undefined) return;
748
+ if (!sameTier(current.peak, before.peak) || !sameTier(current.offPeak, before.offPeak)) return;
749
+ models[name] = {
750
+ tiered: current.tiered !== false,
751
+ peak: Object.assign({}, to[name].peak),
752
+ offPeak: Object.assign({}, to[name].offPeak)
753
+ };
754
+ });
755
+ setConfig({ currency: next, models: models });
756
+ /* 把换完的价格回给调用方:正在编辑的草稿要拿它重建,否则框里还是旧币种的数字 */
757
+ return { currency: next, models: models };
758
+ }
535
759
  /** 该段是否勾选显示:在段序里且不在 hidden 里。 */
536
760
  function isSegmentOn(cfg, id) {
537
761
  if (Array.isArray(cfg.segments) && cfg.segments.indexOf(id) === -1) return false;
@@ -553,31 +777,293 @@ window.__ModuleLoader__.load({
553
777
  }
554
778
 
555
779
  /* ---------------------------------------------------------------- 工具 */
556
- /** token 精确到个位数,每三位一个逗号。 */
557
- function formatTokens(value) {
558
- const n = Math.round(Number(value) || 0);
559
- const sign = n < 0 ? "-" : "";
560
- const digits = String(Math.abs(n));
780
+ /** 精确 token 数:每三位一个逗号(面板明细用)。 */
781
+ function formatTokensExact(value) {
782
+ const n = Math.max(0, Math.round(Number(value) || 0));
783
+ const digits = String(n);
561
784
  let out = "";
562
785
  for (let i = 0; i < digits.length; i += 1) {
563
786
  if (i > 0 && (digits.length - i) % 3 === 0) out += ",";
564
787
  out += digits[i];
565
788
  }
566
- return sign + out;
789
+ return out;
567
790
  }
568
791
  /** 秒级时长,保留一位小数(首字延迟这类不会超过一分钟的指标用)。 */
792
+ /* ------------------------------------------------ 上下文占用(对齐官方口径) */
793
+ /** 圆环几何:与官方 ContextMeter 一致(14px viewBox、2px 描边、半径 5.5) */
794
+ const METER_RADIUS = 5.5;
795
+ const METER_CIRCUMFERENCE = 2 * Math.PI * METER_RADIUS;
796
+ /** 官方把本地化句子从占位槽切开,好让百分比单独上色(中英词序不同) */
797
+ const READING_SLOT = "\u0000";
798
+ /** 构成三行:系统提示词 / 工具定义 / 对话消息,颜色与官方 ContextMeter 相同 */
799
+ const METER_ROWS = [
800
+ { key: "systemTokens", label: "contextSystem", color: "var(--dsw-static-neutral-bluish-400)" },
801
+ { key: "toolsTokens", label: "contextTools", color: "#a78bfa" },
802
+ { key: "messageTokens", label: "contextMessages", color: "var(--dsw-static-blue-450)" }
803
+ ];
804
+
805
+ /**
806
+ * 官方口径(ui-conversation 的 contextOccupancy):
807
+ * 占用 = 下一次请求预计的 prompt(projectedTokens,缺省退回 provider 最近报告的 pressureTokens)
808
+ * ÷ 上下文窗口,四舍五入,封顶 100%;两个数缺一个就返回 null。
809
+ */
810
+ function contextOccupancy(pressure) {
811
+ if (pressure === null || pressure === undefined) return null;
812
+ const usedTokens = pressure.projectedTokens !== undefined && pressure.projectedTokens !== null
813
+ ? pressure.projectedTokens
814
+ : pressure.pressureTokens;
815
+ if (usedTokens === undefined || usedTokens === null) return null;
816
+ if (pressure.contextWindow === undefined || pressure.contextWindow === null) return null;
817
+ return {
818
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
819
+ usedTokens: usedTokens,
820
+ contextWindow: pressure.contextWindow
821
+ };
822
+ }
823
+
824
+ /**
825
+ * 圆环与分项面板要的数据;缺用量或缺窗口时返回 null(官方这时候也不显示)。
826
+ * 数字一律用千分位精确值(不缩写、不带约等于号),跟令牌面板保持同一套读法。
827
+ * @returns {{percent:number, used:string, window:string, rows:Array, segments:Array}|null}
828
+ */
829
+ function meterView(pressure, breakdown, t) {
830
+ const occupancy = contextOccupancy(pressure);
831
+ if (occupancy === null) return null;
832
+ const parts = breakdown === null || breakdown === undefined
833
+ ? []
834
+ : METER_ROWS.map((row) => ({
835
+ label: t(row.label),
836
+ color: row.color,
837
+ value: Number(breakdown[row.key]) || 0
838
+ }));
839
+ const total = parts.reduce((sum, one) => sum + one.value, 0);
840
+ const rows = parts.map((one) => ({
841
+ label: one.label,
842
+ color: one.color,
843
+ value: formatTokensExact(one.value)
844
+ }));
845
+ /* 官方口径:每段宽度 = 各段占比 × 总占用百分比,所以整条只填到占用比例,不会顶满 */
846
+ const segments = total > 0
847
+ ? parts
848
+ .map((one) => ({ color: one.color, width: occupancy.percent * one.value / total }))
849
+ .filter((one) => one.width > 0)
850
+ : [{ color: undefined, width: occupancy.percent }];
851
+ return {
852
+ percent: occupancy.percent,
853
+ used: formatTokensExact(occupancy.usedTokens),
854
+ window: formatTokensExact(occupancy.contextWindow),
855
+ rows: rows,
856
+ segments: segments
857
+ };
858
+ }
859
+
860
+ /** 官方那种分段小条(分项面板里那条)。 */
861
+ function meterBar(meter) {
862
+ return h("span", { className: "dsb-bar" },
863
+ meter.segments.map((segment, index) => h("span", {
864
+ className: "dsb-bar-seg",
865
+ key: "__bar" + index,
866
+ style: { width: segment.width + "%", background: segment.color }
867
+ })));
868
+ }
869
+
870
+ /** 状态位上的圆环:起点与官方一致在 12 点方向,进度是占用比例,颜色跟随运行状态。 */
871
+ function contextRing(percent, state) {
872
+ const stateClass = state === "running" ? " dsb-ring-running"
873
+ : (state === "error" ? " dsb-ring-error" : (state === "approval" ? " dsb-ring-approval" : ""));
874
+ const offset = METER_CIRCUMFERENCE * (1 - Math.max(0, Math.min(100, percent)) / 100);
875
+ return h("svg", {
876
+ className: "dsb-ring" + stateClass, key: "__ring", viewBox: "0 0 14 14", width: 14, height: 14
877
+ },
878
+ h("circle", { className: "dsb-ring-track", cx: 7, cy: 7, r: METER_RADIUS }),
879
+ h("circle", {
880
+ className: "dsb-ring-fill", cx: 7, cy: 7, r: METER_RADIUS,
881
+ /* SVG 的弧默认从 3 点方向起,整体转 -90° 才是官方那种"12 点起、顺时针长" */
882
+ transform: "rotate(-90 7 7)",
883
+ strokeDasharray: METER_CIRCUMFERENCE, strokeDashoffset: offset
884
+ }));
885
+ }
886
+
887
+ /** 气泡的 fixed 内联位置:JS 已按视口夹住;pos 为空时不写。宽度交给内容(CSS width:max-content)。 */
888
+ function panelStyle(pos) {
889
+ if (pos === null || pos === undefined) return undefined;
890
+ return { left: pos.left + "px", bottom: pos.bottom + "px" };
891
+ }
892
+
893
+ /** 悬停气泡里的分项面板:标题 + 分段条 + 三行构成(复刻官方 ContextMeter)。 */
894
+ function meterPanel(meter, t, pos, ref) {
895
+ const [headBefore = "", headAfter = ""] = t("contextAria", { percent: READING_SLOT })
896
+ .split(READING_SLOT).map((part) => part.trim());
897
+ const head = [];
898
+ if (headBefore.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__hb" }, headBefore));
899
+ head.push(h("span", { className: "dsb-meter-percent", key: "__hp" }, meter.percent + "%"));
900
+ if (headAfter.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__ha" }, headAfter));
901
+ head.push(h("span", { className: "dsb-meter-figures", key: "__hf" }, meter.used + " / " + meter.window));
902
+ const rows = meter.rows.map((row, index) => h("span", { className: "dsb-meter-row", key: "__mr" + index }, [
903
+ h("span", { className: "dsb-meter-swatch", key: "__ms", style: { background: row.color } }),
904
+ h("span", { className: "dsb-meter-name", key: "__mn" }, row.label),
905
+ h("span", { className: "dsb-meter-value", key: "__mv" }, row.value)
906
+ ]));
907
+ return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
908
+ h("span", { className: "dsb-meter-head", key: "__mh" }, head),
909
+ meterBar(meter),
910
+ rows.length > 0 ? h("span", { className: "dsb-meter-rows", key: "__mrows" }, rows) : null
911
+ ]);
912
+ }
913
+
914
+ /**
915
+ * 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
916
+ * 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
917
+ */
918
+ function tokenPanelView(cached, missInput, output, t) {
919
+ const total = cached + missInput + output;
920
+ if (total <= 0) return null;
921
+ const parts = [
922
+ { label: t("f_tokensHit"), color: "var(--dsw-static-neutral-bluish-400)", value: cached },
923
+ { label: t("f_tokensMiss"), color: "#a78bfa", value: missInput },
924
+ { label: t("f_tokensOut"), color: "var(--dsw-static-blue-450)", value: output }
925
+ ];
926
+ return {
927
+ totalText: formatTokensExact(total),
928
+ segments: parts
929
+ .map((one) => ({ color: one.color, width: (one.value / total) * 100 }))
930
+ .filter((one) => one.width > 0),
931
+ rows: parts.map((one) => ({
932
+ label: one.label,
933
+ color: one.color,
934
+ value: formatTokensExact(one.value)
935
+ }))
936
+ };
937
+ }
938
+
939
+ /** 令牌面板:标题 + 总量 + 顶满的三段条 + 三行明细,左右两边对齐(总计与本轮共用,只有标题不同)。 */
940
+ function tokenPanel(panel, title, pos, ref) {
941
+ const rows = panel.rows.map((row, index) => h("span", { className: "dsb-meter-row", key: "__tr" + index }, [
942
+ h("span", { className: "dsb-meter-swatch", key: "__ts", style: { background: row.color } }),
943
+ h("span", { className: "dsb-meter-name", key: "__tn" }, row.label),
944
+ h("span", { className: "dsb-meter-value", key: "__tv" }, row.value)
945
+ ]));
946
+ return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
947
+ h("span", { className: "dsb-meter-head", key: "__th" }, [
948
+ h("span", { className: "dsb-meter-headline", key: "__tl" }, title),
949
+ h("span", { className: "dsb-meter-figures", key: "__tt" }, panel.totalText)
950
+ ]),
951
+ meterBar({ segments: panel.segments }),
952
+ h("span", { className: "dsb-meter-rows", key: "__trows" }, rows)
953
+ ]);
954
+ }
955
+
956
+ /** 简单两列面板:标题 + 主数值,下面若干「名称 / 数值」行(用时、极值、命中率、余额这类段用)。 */
957
+ function rowsPanel(tip, t, pos, ref) {
958
+ const rows = (tip.rows === undefined ? [] : tip.rows).map((row, index) => h("span", {
959
+ className: "dsb-meter-row", key: "__xr" + index
960
+ }, [
961
+ h("span", { className: "dsb-meter-name", key: "__xn" }, row.label),
962
+ h("span", { className: "dsb-meter-value", key: "__xv" }, row.value)
963
+ ]));
964
+ return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
965
+ h("span", { className: "dsb-meter-head", key: "__xh" }, [
966
+ h("span", { className: "dsb-meter-headline", key: "__xt" }, tip.title),
967
+ tip.head === undefined || tip.head === null
968
+ ? null
969
+ : h("span", { className: "dsb-meter-figures", key: "__xhf" }, tip.head)
970
+ ]),
971
+ h("span", { className: "dsb-meter-divider", key: "__xdiv" }),
972
+ rows.length > 0 ? h("span", { className: "dsb-meter-rows dsb-meter-rows-fit", key: "__xrows" }, rows) : null
973
+ ]);
974
+ }
975
+
976
+ /** 计价分量 → 令牌面板三段(未命中 = 未缓存输入 + 缓存写入,与官方 tokenUsage 同口径)。 */
977
+ function billableParts(bucket) {
978
+ return {
979
+ cached: bucket.cacheRead || 0,
980
+ missInput: (bucket.input || 0) + (bucket.cacheWrite || 0),
981
+ output: bucket.output || 0
982
+ };
983
+ }
984
+
985
+ /** 官方 tokenUsage 投影 → 同样的三段;没数据时返回 null。 */
986
+ function sessionTokenParts(usage) {
987
+ if (usage === undefined || usage === null) return null;
988
+ return billableParts({
989
+ cacheRead: usage.cacheReadTokens,
990
+ input: usage.uncachedInputTokens,
991
+ cacheWrite: usage.cacheWriteTokens,
992
+ output: usage.outputTokens
993
+ });
994
+ }
995
+
996
+ /** 令牌面板的 tip;三段全 0 时不给面板(省得弹一个空泡泡)。 */
997
+ function tokensTip(parts, title, t) {
998
+ const panel = parts === null ? null : tokenPanelView(parts.cached, parts.missInput, parts.output, t);
999
+ return panel === null ? null : { kind: "tokens", title: title, panel: panel };
1000
+ }
1001
+
1002
+ /** 毫秒 → 面板里的时长文本;0 或缺数据给横杠。 */
1003
+ function tipDuration(ms) {
1004
+ const text = formatDuration(ms);
1005
+ return text === null ? "-" : text;
1006
+ }
1007
+
1008
+ /** 首字极值(毫秒)→ "9.3s";没有样本给横杠。 */
1009
+ function tipSeconds(mark) {
1010
+ if (mark === null || mark === undefined) return "-";
1011
+ const text = formatSeconds(mark.value);
1012
+ return text === null ? "-" : text + "s";
1013
+ }
1014
+
1015
+ /** 速度极值(tokens/秒)→ "42t/s";没有样本给横杠。 */
1016
+ function tipSpeed(mark, t) {
1017
+ if (mark === null || mark === undefined) return "-";
1018
+ return formatThroughput(mark.value) + t("unitSpeed");
1019
+ }
1020
+
1021
+ /** 余额接口给的金额字符串;没有这个字段时按 0.00 显示。 */
1022
+ function balanceAmount(value) {
1023
+ return typeof value === "string" && value.length > 0 ? value : "0.00";
1024
+ }
1025
+
1026
+ /** 单次调用的缓存命中率(%);这次调用一个输入 token 都没有时返回 null(算不出比例)。 */
1027
+ function hitRateOf(call) {
1028
+ const denominator = (call.input || 0) + (call.cacheRead || 0) + (call.cacheWrite || 0);
1029
+ if (denominator <= 0) return null;
1030
+ return ((call.cacheRead || 0) / denominator) * 100;
1031
+ }
1032
+
1033
+ /** 命中率文本:四舍五入到小数点后三位,第三位是 0 也照留(99.995%、50.000%)。 */
1034
+ function hitRateText(percent) {
1035
+ return percent.toFixed(3) + "%";
1036
+ }
1037
+
1038
+ /** 逐条调用里的最高/最低命中率;没有可用样本时返回 null(气泡里显示横杠)。 */
1039
+ function cacheHitExtremes(sessionUsage) {
1040
+ const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
1041
+ ? []
1042
+ : sessionUsage.calls;
1043
+ let high = null;
1044
+ let low = null;
1045
+ for (let i = 0; i < calls.length; i += 1) {
1046
+ const rate = hitRateOf(calls[i]);
1047
+ if (rate === null) continue;
1048
+ if (high === null || rate > high) high = rate;
1049
+ if (low === null || rate < low) low = rate;
1050
+ }
1051
+ if (high === null) return null;
1052
+ return { high: hitRateText(high), low: hitRateText(low) };
1053
+ }
1054
+
569
1055
  function formatSeconds(ms) {
570
1056
  const n = Number(ms) || 0;
571
1057
  if (n <= 0) return null;
572
1058
  return String(Math.round(n / 100) / 10);
573
1059
  }
574
- /** 时长按最小单位给:>=1h 用 h/m,>=1m 用 m/s,不足一分钟只给秒;分钟与秒之间不加空格。 */
1060
+ /** 时长按最小单位给:>=1h 用 h/m,>=1m 用 m/s,不足一分钟只给整数秒;分钟与秒之间不加空格。 */
575
1061
  function formatDuration(ms) {
576
1062
  const n = Number(ms) || 0;
577
1063
  if (n <= 0) return null;
578
1064
  const seconds = n / 1000;
579
1065
  const whole = Math.round(seconds);
580
- if (whole < 60) return (Math.round(seconds * 10) / 10) + "s";
1066
+ if (whole < 60) return whole + "s";
581
1067
  const minutes = Math.floor(whole / 60);
582
1068
  if (minutes < 60) return minutes + "m" + (whole % 60) + "s";
583
1069
  return Math.floor(minutes / 60) + "h" + (minutes % 60) + "m";
@@ -615,24 +1101,12 @@ window.__ModuleLoader__.load({
615
1101
  function segmentView(id, src, t) {
616
1102
  const stats = src.stats;
617
1103
  const usage = src.usage;
618
- const pressure = src.pressure;
619
1104
  const symbol = currencySymbol(src.currency);
620
1105
 
621
1106
  if (id === "status") {
622
- const running = src.running === true
623
- || (src.partial !== undefined && src.partial !== null)
624
- || (Array.isArray(src.runningCalls) && src.runningCalls.length > 0);
625
- const approvals = src.pendingApprovals;
626
- const waiting = approvals !== undefined && approvals !== null
627
- && (Array.isArray(approvals) ? approvals.length > 0 : true);
628
- const failed = !running && (src.lastAgentError !== undefined && src.lastAgentError !== null);
629
- return {
630
- id: id,
631
- /* 只靠状态点表达,文字交给后面的段 */
632
- state: running ? "running" : (failed ? "error" : (waiting ? "approval" : "idle")),
633
- /* 文字给峰谷判断:同一段既表示会话状态(点)又表示峰谷(文字) */
634
- text: src.now === undefined || src.now === null ? "" : (isPeakTime(src.now) ? t("f_peak") : t("f_valley"))
635
- };
1107
+ /* 拆分后这一段只出峰谷两个字:上下文占用圆环是独立开关,固定挂在栏首 */
1108
+ const period = src.now === undefined || src.now === null ? "" : (isPeakTime(src.now) ? t("f_peak") : t("f_valley"));
1109
+ return { id: id, text: period };
636
1110
  }
637
1111
 
638
1112
  if (id === "counts") {
@@ -640,22 +1114,23 @@ window.__ModuleLoader__.load({
640
1114
  return { id: id, text: t("f_counts", { turns: stats.turns || 0, steps: stats.steps || 0 }) };
641
1115
  }
642
1116
 
643
- if (id === "durations") {
644
- if (stats === undefined || stats === null) return null;
645
- const parts = [];
646
- const llm = formatDuration(stats.llmMs);
647
- const tool = formatDuration(stats.toolMs);
648
- if (llm !== null) parts.push(t("f_llm", { duration: llm }));
649
- if (tool !== null) parts.push(t("f_tool", { duration: tool }));
650
- if (parts.length === 0) return null;
651
- return { id: id, text: parts.join(" · ") };
652
- }
653
-
654
1117
  if (id === "ttft") {
655
1118
  if (stats === undefined || stats === null || !(stats.ttftSteps > 0) || !(stats.ttftMs > 0)) return null;
656
1119
  const duration = formatSeconds(stats.ttftMs / stats.ttftSteps);
657
1120
  if (duration === null) return null;
658
- return { id: id, text: t("f_ttft", { duration: duration + "s" }) };
1121
+ const timing = src.timing === undefined || src.timing === null ? null : src.timing;
1122
+ return {
1123
+ id: id,
1124
+ text: t("f_ttft", { duration: duration + "s" }),
1125
+ tip: {
1126
+ kind: "rows",
1127
+ title: t("segTtft"),
1128
+ rows: [
1129
+ { label: t("tipTtftFast"), value: tipSeconds(timing === null ? null : timing.fastestTtft) },
1130
+ { label: t("tipTtftSlow"), value: tipSeconds(timing === null ? null : timing.slowestTtft) }
1131
+ ]
1132
+ }
1133
+ };
659
1134
  }
660
1135
 
661
1136
  if (id === "cacheHit") {
@@ -663,41 +1138,38 @@ window.__ModuleLoader__.load({
663
1138
  const denominator = billedInputTokens(usage);
664
1139
  if (denominator <= 0) return null;
665
1140
  const percent = Math.min(99.99, ((usage.cacheReadTokens || 0) / denominator) * 100).toFixed(2);
666
- return { id: id, text: t("f_cacheHit", { percent: percent }) };
667
- }
668
-
669
- if (id === "tokens") {
670
- if (usage === undefined || usage === null) return null;
671
- const cached = usage.cacheReadTokens || 0;
672
- const missInput = (usage.uncachedInputTokens || 0) + (usage.cacheWriteTokens || 0);
673
- const output = usage.outputTokens || 0;
674
- const total = cached + missInput + output;
675
- if (total <= 0) return null;
676
- const rows = [
677
- { label: t("f_tokensHit"), value: formatTokens(cached) },
678
- { label: t("f_tokensMiss"), value: formatTokens(missInput) },
679
- { label: t("f_tokensOut"), value: formatTokens(output) },
680
- ];
1141
+ const extremes = cacheHitExtremes(src.sessionUsage);
681
1142
  return {
682
1143
  id: id,
683
- /* 只显示三者之和(命中缓存 + 未命中缓存 + 输出),不带段名 */
684
- text: t("f_tokens", { total: formatTokens(total) }),
685
- rows: rows,
1144
+ text: t("f_cacheHit", { percent: percent }),
1145
+ /* 点开看单次调用的最高/最低命中率(底栏那个是整会话的累计值) */
1146
+ tip: {
1147
+ kind: "rows",
1148
+ title: t("segCacheHit"),
1149
+ rows: [
1150
+ { label: t("tipHitHigh"), value: extremes === null ? "-" : extremes.high },
1151
+ { label: t("tipHitLow"), value: extremes === null ? "-" : extremes.low }
1152
+ ]
1153
+ }
686
1154
  };
687
1155
  }
688
1156
 
689
- if (id === "context") {
690
- if (pressure === undefined || pressure === null) return null;
691
- const used = pressure.projectedTokens !== undefined && pressure.projectedTokens !== null
692
- ? pressure.projectedTokens
693
- : pressure.pressureTokens;
694
- if (used === undefined || used === null || !(pressure.contextWindow > 0)) return null;
695
- return { id: id, text: t("f_context", { percent: Math.min(100, Math.round((used / pressure.contextWindow) * 100)) }) };
696
- }
697
-
698
1157
  if (id === "tps") {
699
1158
  if (stats === undefined || stats === null || !(stats.decodeMs > 0) || !(stats.decodeTokens > 0)) return null;
700
- return { id: id, text: t("f_speed", { throughput: formatThroughput(stats.decodeTokens / (stats.decodeMs / 1000)) }) };
1159
+ const throughput = formatThroughput(stats.decodeTokens / (stats.decodeMs / 1000));
1160
+ const timing = src.timing === undefined || src.timing === null ? null : src.timing;
1161
+ return {
1162
+ id: id,
1163
+ text: t("f_speed", { throughput: throughput }),
1164
+ tip: {
1165
+ kind: "rows",
1166
+ title: t("segTps"),
1167
+ rows: [
1168
+ { label: t("tipTpsFast"), value: tipSpeed(timing === null ? null : timing.fastestTps, t) },
1169
+ { label: t("tipTpsSlow"), value: tipSpeed(timing === null ? null : timing.slowestTps, t) }
1170
+ ]
1171
+ }
1172
+ };
701
1173
  }
702
1174
 
703
1175
  /* 总用时 = 各轮用时之和(与官方"本轮总用时"同口径) */
@@ -717,7 +1189,20 @@ window.__ModuleLoader__.load({
717
1189
  if (elapsed === null) return null;
718
1190
  const duration = formatDuration(elapsed);
719
1191
  if (duration === null) return null;
720
- return { id: id, text: t("f_sessionTime", { duration: duration }) };
1192
+ return {
1193
+ id: id,
1194
+ text: t("f_sessionTime", { duration: duration }),
1195
+ /* 模型用时与工具调用用时都来自官方 sessionStats:同一步里并行调用各算各的,
1196
+ 两者之和会大于上面那个墙钟总用时(口径不同,不互相比较) */
1197
+ tip: {
1198
+ kind: "rows",
1199
+ title: t("segSessionTime"),
1200
+ rows: [
1201
+ { label: t("tipModelTime"), value: tipDuration(stats === undefined || stats === null ? 0 : stats.llmMs) },
1202
+ { label: t("tipToolTime"), value: tipDuration(stats === undefined || stats === null ? 0 : stats.toolMs) }
1203
+ ]
1204
+ }
1205
+ };
721
1206
  }
722
1207
 
723
1208
  if (id === "cost") {
@@ -725,7 +1210,8 @@ window.__ModuleLoader__.load({
725
1210
  if (sessionUsage === undefined || sessionUsage === null) return null;
726
1211
  const calls = Array.isArray(sessionUsage.calls) ? sessionUsage.calls : null;
727
1212
  if (calls === null || calls.length === 0) return null;
728
- /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价 */
1213
+ /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
1214
+ 累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。 */
729
1215
  const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
730
1216
  let total = 0;
731
1217
  let priced = false;
@@ -734,10 +1220,15 @@ window.__ModuleLoader__.load({
734
1220
  const one = costOf(calls[i], callModel, calls[i].at);
735
1221
  if (one === null) continue;
736
1222
  priced = true;
737
- total += Number(one);
1223
+ total += one;
738
1224
  }
739
1225
  if (!priced) return null;
740
- return { id: id, text: t("f_cost", { symbol: symbol, cost: total.toFixed(2) }) };
1226
+ return {
1227
+ id: id,
1228
+ text: t("f_cost", { symbol: symbol, cost: costText(total) }),
1229
+ /* token 计数并进总计:点这一段看会话的 token 明细 */
1230
+ tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
1231
+ };
741
1232
  }
742
1233
 
743
1234
  /* 本轮费用:优先按 node.turn 折叠(与官方"本轮用量"同源),否则退回差分 */
@@ -752,21 +1243,56 @@ window.__ModuleLoader__.load({
752
1243
  if (turnCalls !== null && turnCalls.length > 0) {
753
1244
  let turnTotal = 0;
754
1245
  let turnPriced = false;
1246
+ const turnParts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
755
1247
  for (let i = 0; i < turnCalls.length; i += 1) {
756
1248
  const callModel = typeof turnCalls[i].model === "string" ? turnCalls[i].model : fallbackModel;
757
1249
  const one = costOf(turnCalls[i], callModel, turnCalls[i].at);
1250
+ turnParts.input += turnCalls[i].input || 0;
1251
+ turnParts.cacheRead += turnCalls[i].cacheRead || 0;
1252
+ turnParts.cacheWrite += turnCalls[i].cacheWrite || 0;
1253
+ turnParts.output += turnCalls[i].output || 0;
758
1254
  if (one === null) continue;
759
1255
  turnPriced = true;
760
1256
  turnTotal += Number(one);
761
1257
  }
762
- if (turnPriced) return { id: id, text: t("f_lastCost", { symbol: symbol, cost: turnTotal.toFixed(2) }) };
1258
+ if (turnPriced) {
1259
+ return {
1260
+ id: id,
1261
+ text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
1262
+ /* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
1263
+ tip: tokensTip(billableParts(turnParts), t("f_tokensTurn"), t)
1264
+ };
1265
+ }
763
1266
  }
764
- /* 兜底:官方 tokenUsage 差分,按当下时段计价 */
1267
+ /* 本轮是不是正在跑(与状态段同一套判断) */
1268
+ const active = src.running === true
1269
+ || (src.partial !== undefined && src.partial !== null)
1270
+ || (Array.isArray(src.runningCalls) && src.runningCalls.length > 0);
1271
+ /* 节点折叠出来的用量:只有轮号等于 host 说的当前轮,才算本轮正在进行中的账。
1272
+ 拿不到轮号(旧版后台之类)时退回老做法 —— 宁可显示得保守一点,也别一直顶着 0.00。
1273
+ 注意它只用来算钱:会话节点上的 usage 字段口径与官方投影不同(实测同一轮
1274
+ input 会少掉一截),拿它填 token 明细会让"本轮"和"总计"对不上。 */
1275
+ const foldedTurn = typeof src.turnUsageTurn === "number" ? src.turnUsageTurn : null;
1276
+ const currentTurn = typeof src.currentTurn === "number" ? src.currentTurn : null;
765
1277
  const bucket = src.turnUsage !== null && src.turnUsage !== undefined ? src.turnUsage : null;
1278
+ const sameTurn = foldedTurn !== null && (currentTurn === null || foldedTurn === currentTurn);
1279
+ if (active === true && bucket !== null && sameTurn === true) {
1280
+ const runningCost = costOf(bucket, fallbackModel, src.now);
1281
+ if (runningCost !== null) {
1282
+ return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip: null };
1283
+ }
1284
+ }
1285
+ /* 新一轮已经开跑、但还没有属于它的调用:本轮就是 0。
1286
+ 这里必须拦住 —— 否则会退回上一轮(节点折叠还错位过一轮,显示成上上轮)的金额,
1287
+ 发完消息底栏挂着一个旧数字,看着像没重置。 */
1288
+ if (active === true) {
1289
+ return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(0) }), tip: null };
1290
+ }
1291
+ /* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额 */
766
1292
  if (bucket === null) return null;
767
1293
  const cost = costOf(bucket, fallbackModel, src.now);
768
1294
  if (cost === null) return null;
769
- return { id: id, text: t("f_lastCost", { symbol: symbol, cost: cost }) };
1295
+ return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(cost) }), tip: null };
770
1296
  }
771
1297
 
772
1298
  if (id === "balance") {
@@ -774,8 +1300,20 @@ window.__ModuleLoader__.load({
774
1300
  if (balance === null || balance === undefined) return null;
775
1301
  if (balance.ok !== true) return null;
776
1302
  if (typeof balance.total !== "string") return null;
777
- /* 余额段不带悬停气泡 */
778
- return { id: id, text: t("f_balance", { symbol: symbol, amount: balance.total }) };
1303
+ /* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
1304
+ const balSymbol = currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
1305
+ /* 点开看构成:total = 充值余额 + 赠金余额;没有赠金时接口不给这个字段,按 0.00 显示 */
1306
+ const tip = {
1307
+ kind: "rows",
1308
+ title: t("segBalance"),
1309
+ rows: [
1310
+ { label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp) },
1311
+ { label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) }
1312
+ ]
1313
+ };
1314
+ /* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
1315
+ if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
1316
+ return { id: id, text: t("f_balance", { symbol: balSymbol, amount: balance.total }), tip: tip };
779
1317
  }
780
1318
 
781
1319
  return null;
@@ -798,11 +1336,11 @@ window.__ModuleLoader__.load({
798
1336
  if (id === "counts") return dash + t("unitCountTurns") + " " + dash + t("unitCountSteps");
799
1337
  if (id === "cacheHit") return label + " " + dash + " %";
800
1338
  if (id === "ttft") return label + " " + dash + " s";
801
- if (id === "tps") return label + " " + dash + t("unitSpeed");
802
- if (id === "sessionTime" || id === "durations") return label + " " + dash + "m " + dash + "s";
803
- if (id === "cost" || id === "lastCost") return label + " " + dash + t("unitCost");
1339
+ /* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
1340
+ if (id === "tps") return label + " " + dash;
1341
+ if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
1342
+ if (id === "cost" || id === "lastCost") return label + " " + dash + " " + currencySymbol(config.currency);
804
1343
  if (id === "balance") return t("f_balanceDash");
805
- if (id === "tokens") return dash + " tokens";
806
1344
  return dash;
807
1345
  }
808
1346
  /* ---------------------------------------------------------------- css */
@@ -810,24 +1348,70 @@ window.__ModuleLoader__.load({
810
1348
  /* 接管输入框下方的统计行:dock 里凡“不是本插件那一项”的都藏掉(也就是官方 StatsPills)。
811
1349
  * 两条不依赖官方构建 hash 的要点:
812
1350
  * 1. 用 .dsb-root 认自己,而不是官方组件的类名(hash 会随 DSH 更新变,2.0.10 踩过);
813
- * 2. 自己带标记与“后代里带标记”都要判 —— :has() 只看后代,漏掉前者会把自己的行也藏掉。 */
814
- '[data-slot="conversation.composer.dock"] > *:not(.dsb-root):not(:has(.dsb-root)){display:none !important}',
1351
+ * 2. 自己带标记与“后代里带标记”都要判 —— :has() 只看后代,漏掉前者会把自己的行也藏掉。
1352
+ * 整条规则挂在 html.dsb-on 上:设置页取消勾选「启用状态栏」后这个类会被摘掉,
1353
+ * 官方原本的统计行与上下文按钮随即恢复(不然关掉插件只剩一片空白)。 */
1354
+ 'html.dsb-on [data-slot="conversation.composer.dock"] > *:not(.dsb-root):not(:has(.dsb-root)){display:none !important}',
1355
+ /* 官方新版在输入卡片下方右侧另有一个上下文占用按钮:已被本插件的圆环接管,藏掉它。
1356
+ * 用 aria-label 认它(中文含"上下文"、英文含"context used",随语言变),不碰官方 hash 类名。 */
1357
+ 'html.dsb-on button[aria-haspopup="dialog"][aria-label*="上下文"],html.dsb-on button[aria-haspopup="dialog"][aria-label*="context used"]{display:none !important}',
1358
+ /* 官方的「用量 9.9M tok」按钮(TurnUsagePanel):同一份数据已由本轮/总计两个气泡接管,藏掉。
1359
+ * 它的类名是 CSS module 的 <hash>_root / <hash>_trigger,hash 随构建变,因此按后缀认结构:
1360
+ * root 外壳里包着带 aria-haspopup 的 trigger 按钮(trigger 本身没有 aria-label 可认)。 */
1361
+ 'html.dsb-on span[class$="_root"]>button[aria-haspopup="dialog"][class$="_trigger"]{display:none !important}',
815
1362
  ".dsb-root{display:flex;align-items:center;justify-content:center;gap:2px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;padding:4px calc(var(--dsh-composer-side-clearance) + 16px) 0;margin:0 auto;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
816
- ".dsb-wrap{flex-wrap:wrap}",
817
- ".dsb-dot{flex:none;width:7px;height:7px;border-radius:50%;margin-right:7px;background:#9ca3af}",
818
- ".dsb-dot-running{background:#22c55e;box-shadow:0 0 0 2px color-mix(in srgb,#22c55e 15%,transparent)}",
819
- ".dsb-dot-error{background:#ef4444;box-shadow:0 0 0 2px color-mix(in srgb,#ef4444 15%,transparent)}",
820
- ".dsb-dot-approval{background:#f59e0b;box-shadow:0 0 0 2px color-mix(in srgb,#f59e0b 15%,transparent)}",
1363
+ /* 上下文占用:圆环、分段小条与分项面板都按官方 ContextMeter 的几何与配色复刻 */
1364
+ ".dsb-ring{flex:none;width:14px;height:14px}",
1365
+ ".dsb-ring-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:none;padding:3px;margin-right:5px;background:0 0;border:none;border-radius:50%;cursor:pointer;color:var(--dsw-alias-label-tertiary)}",
1366
+ '.dsb-ring-btn:hover,.dsb-ring-btn[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary)}',
1367
+ ".dsb-seg-click{position:relative;cursor:pointer;border-radius:999px;padding:1px 6px;margin:0 -6px}",
1368
+ '.dsb-seg-click:hover,.dsb-seg-click[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary)}',
1369
+ ".dsb-ring-track{fill:none;stroke:var(--dsw-alias-border-l3,rgba(127,127,127,.25));stroke-width:2px}",
1370
+ ".dsb-ring-fill{fill:none;stroke:var(--dsw-alias-label-tertiary);stroke-width:2px;stroke-linecap:round}",
1371
+ ".dsb-ring-running .dsb-ring-fill{stroke:#22c55e}",
1372
+ ".dsb-ring-error .dsb-ring-fill{stroke:#ef4444}",
1373
+ ".dsb-ring-approval .dsb-ring-fill{stroke:#f59e0b}",
1374
+ ".dsb-bar{display:flex;gap:1px;height:4px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15));overflow:hidden;margin:6px 0 2px}",
1375
+ ".dsb-bar-seg{flex:none;min-width:2px;height:100%;border-radius:1px;background:var(--dsw-alias-label-tertiary)}",
1376
+ /* 宽度按内容自适应:width:max-content 让气泡收到"最长那一行"的宽度,
1377
+ * 行内标签与数值之间至少留 6ch(下面的 padding-left),所以不会出现大片留白;
1378
+ * 内容超过 max-width 时退回换行(white-space:normal)。 */
1379
+ ".dsb-tip.dsb-meter{width:max-content;max-width:min(560px,100vw - 24px);white-space:normal;font-size:12px;line-height:18px}",
1380
+ ".dsb-meter-head{display:flex;align-items:center;gap:6px}",
1381
+ ".dsb-meter-headline{color:var(--dsw-alias-label-primary)}",
1382
+ ".dsb-meter-percent{color:var(--dsw-alias-label-primary);font-weight:500}",
1383
+ /* 标签与数值之间至少留 6ch:配合上面的 width:max-content,气泡宽度就是
1384
+ * "最长那一行的标签 + 六个空格 + 数值",其余行在这个宽度里右对齐(间隙更大)。 */
1385
+ ".dsb-meter-figures{margin-left:auto;padding-left:6ch;color:var(--dsw-alias-label-primary);font-weight:500;font-variant-numeric:tabular-nums}",
1386
+ /* 复刻官方「会话统计」面板:标题下面那条细线(有进度条的面板不加,条本身就分隔了) */
1387
+ ".dsb-meter-divider{display:block;height:1px;margin:5px 0 0;background:var(--dsw-alias-separator-primary,rgba(127,127,127,.3))}",
1388
+ ".dsb-meter-rows{display:flex;flex-direction:column;margin-top:4px}",
1389
+ /* 两列气泡(标题下带分隔线的那几个)线到第一行再收 3px */
1390
+ ".dsb-meter-rows-fit{margin-top:1px}",
1391
+ ".dsb-meter-row{display:flex;align-items:center;gap:6px;padding:2px 0}",
1392
+ ".dsb-meter-swatch{flex:none;width:8px;height:8px;border-radius:2px}",
1393
+ ".dsb-meter-name{color:var(--dsw-alias-label-secondary)}",
1394
+ ".dsb-meter-value{margin-left:auto;padding-left:6ch;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
821
1395
  ".dsb-seg{white-space:nowrap}",
822
1396
  /* 气泡相对状态栏定位 */
823
1397
  ".dsb-root{position:relative}",
824
- ".dsb-tip{position:absolute;bottom:100%;left:0;margin-bottom:6px;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#1f1f1f);border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));box-shadow:0 6px 18px rgba(0,0,0,.18);white-space:nowrap;z-index:40;pointer-events:none}",
825
- ".dsb-tip-grid{display:grid;grid-template-columns:auto auto;gap:2px 16px;font-variant-numeric:tabular-nums}",
826
- ".dsb-tip-label{text-align:left;color:var(--dsw-alias-label-secondary)}",
827
- ".dsb-tip-value{text-align:right;color:var(--dsw-alias-label-primary)}",
1398
+ /* 方位由 JS 按视口夹住(fixed + 内联 left/bottom),这里不再写死 */
1399
+ /* 材质复刻官方的菜单浮层(stats 面板 / 各 ui-* 包里的 panel 都是这套):
1400
+ * 半透明底 --dsw-specific-menu(浅 #f8f9fa94 / 深 #43454a73)
1401
+ * + backdrop-filter blur(40px) saturate(150%) 的毛玻璃,
1402
+ * 圆角 --dsw-radius-lg(16px),描边用 .5px 细线(不上柔光阴影)。
1403
+ * 变量是主题层定义的,官方改材质时这里跟着变;取不到就退回原来的实心底。 */
1404
+ /* 透明度按"透出 61.8%"来:保留主题材质色的 RGB,只把 alpha 压到 .382
1405
+ * (rgb(from …) 是 CSS 相对颜色语法,Chromium 119+ 支持;不支持时上一行的官方原值仍然生效)。 */
1406
+ /* 柔光阴影去掉:只留 .5px 描边,浮层看起来更利落 */
1407
+ /* box-sizing:border-box 必须留着:内联宽度要含 padding,否则面板右边缘会比对齐点偏出 22px */
1408
+ ".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:none}",
828
1409
  ".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
829
1410
  /* 设置页 */
830
1411
  ".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
1412
+ /* 页头:与官方 Agent 预设设置页同款(18px/600,标题与说明间距 12px) */
1413
+ ".dsb-settings .dsb-head{display:flex;flex-direction:column;gap:12px}",
1414
+ ".dsb-settings .dsb-pagetitle{margin:0;font-size:18px;font-weight:600;color:var(--dsw-alias-label-primary)}",
831
1415
  ".dsb-settings .dsb-intro{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.6}",
832
1416
  ".dsb-settings .dsb-section{display:flex;flex-direction:column;gap:8px}",
833
1417
  ".dsb-settings .dsb-sectitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;letter-spacing:.02em}",
@@ -852,7 +1436,9 @@ window.__ModuleLoader__.load({
852
1436
  ".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
853
1437
  ".dsb-settings .dsb-current b{color:var(--dsw-alias-label-primary);font-weight:600}",
854
1438
  ".dsb-settings .dsb-models{display:flex;flex-direction:column}",
855
- ".dsb-settings .dsb-model{display:flex;flex-direction:column;gap:8px;padding:6px 0;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.18))}",
1439
+ ".dsb-settings .dsb-model{position:relative;display:flex;flex-direction:column;gap:8px;padding:6px 0;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.18))}",
1440
+ /* 编辑区:毛玻璃浮层,从卡片头部下方展开,盖在下面的内容上 */
1441
+ ".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))}",
856
1442
  ".dsb-settings .dsb-tier{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
857
1443
  ".dsb-settings .dsb-tier-name{flex:none;width:48px;font-size:12px;color:var(--dsw-alias-label-secondary)}",
858
1444
  ".dsb-settings .dsb-model:first-child{border-top:0;padding-top:0}",
@@ -860,10 +1446,23 @@ window.__ModuleLoader__.load({
860
1446
  ".dsb-settings .dsb-model-name{color:var(--dsw-alias-label-primary);font-weight:600;font-size:13px}",
861
1447
  ".dsb-settings .dsb-model-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:28px}",
862
1448
  ".dsb-settings .dsb-model-actions{display:flex;gap:6px;flex:none}",
863
- ".dsb-settings .dsb-inline-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}",
864
- ".dsb-settings .dsb-fields{display:flex;flex-wrap:wrap;gap:8px}",
1449
+ /* 峰谷计价那个勾选只占"勾选框 + 四个字"的宽度,不然整行都能点 */
1450
+ ".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}",
1451
+ /* 勾选在左、计价单位在右 */
1452
+ ".dsb-settings .dsb-tier-head{display:flex;align-items:center;justify-content:space-between;gap:8px}",
1453
+ ".dsb-settings .dsb-select{position:relative}",
1454
+ /* 就一个窄框,字号跟旁边的「峰谷计价」一致,不带下拉箭头 */
1455
+ ".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}",
1456
+ /* 下拉复刻底栏气泡那套材质:半透明菜单底 + 毛玻璃 + 大圆角 + .5px 描边柔光 */
1457
+ ".dsb-settings .dsb-select-menu{position:absolute;top:calc(100% + 4px);right:0;min-width:92px;padding:4px;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));z-index:60}",
1458
+ ".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}",
1459
+ ".dsb-settings .dsb-select-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}",
1460
+ ".dsb-settings .dsb-select-item-on{color:var(--dsw-alias-brand-primary,#4d6bfe)}",
1461
+ /* 三组字段整体贴右边缘:左边留给时段名,右边一列对齐 */
1462
+ ".dsb-settings .dsb-fields{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}",
865
1463
  ".dsb-settings .dsb-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
866
- ".dsb-settings .dsb-field input{width:74px;text-align:right}",
1464
+ /* 够放 xx.xx 就行,输入框不再拉一大条 */
1465
+ ".dsb-settings .dsb-field input{width:56px;text-align:right}",
867
1466
  ".dsb-settings input[type=number]{text-align:right}",
868
1467
  ".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
869
1468
  ".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
@@ -875,7 +1474,8 @@ window.__ModuleLoader__.load({
875
1474
  ".dsb-settings .dsb-action{box-sizing:border-box;height:28px;padding:0 12px;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:6px;cursor:pointer;white-space:nowrap}",
876
1475
  ".dsb-settings .dsb-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
877
1476
  ".dsb-settings .dsb-action-sm{height:24px;padding:0 8px;font-size:12px}",
878
- ".dsb-settings .dsb-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}"
1477
+ ".dsb-settings .dsb-foot{display:flex;align-items:center;gap:12px;flex-wrap:wrap}",
1478
+ ".dsb-settings .dsb-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}"
879
1479
  ].join("");
880
1480
 
881
1481
  /* 导航图标使用外部传入的 gauge(24x24 lucide,stroke=currentColor,随主题变色) */
@@ -935,6 +1535,9 @@ window.__ModuleLoader__.load({
935
1535
  return hook(selector);
936
1536
  }
937
1537
 
1538
+ /** 优先用 layout effect(绘制前就把气泡摆好),环境没有时退回普通 effect。 */
1539
+ const useTipLayout = typeof react.useLayoutEffect === "function" ? react.useLayoutEffect : react.useEffect;
1540
+
938
1541
  /* ------------------------------------------------------------- 底栏组件 */
939
1542
  function StatusBar(props) {
940
1543
  const t = props.t;
@@ -946,10 +1549,14 @@ window.__ModuleLoader__.load({
946
1549
 
947
1550
  const stats = useProjection("sessionStats");
948
1551
  const usage = useProjection("tokenUsage");
949
- const pressure = useProjection("contextPressure");
950
1552
  const sessionModel = useProjection("desktopStatusbarModel");
951
1553
  const sessionUsage = useProjection("desktopStatusbarUsage");
952
1554
  const timeRange = useProjection("desktopStatusbarActiveTime");
1555
+ /* 逐次调用的首字/速度极值(host 侧折出来的,官方 sessionStats 只有累计值) */
1556
+ const timing = useProjection("desktopStatusbarTiming");
1557
+ /* 上下文占用直接读 DSH 自带的两个投影,跟官方那一行同源同口径 */
1558
+ const pressure = useProjection("contextPressure");
1559
+ const breakdown = useProjection("contextBreakdown");
953
1560
  /* 设置页拿不到会话投影,把当前模型上报给 host,供设置页读取 */
954
1561
  const reportedModel = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.model === "string" && sessionModel.model.length > 0
955
1562
  ? sessionModel.model
@@ -975,6 +1582,15 @@ window.__ModuleLoader__.load({
975
1582
  const [now, setNow] = react.useState(() => Date.now());
976
1583
  const [balance, setBalance] = react.useState(null);
977
1584
  const [tipId, setTipId] = react.useState(null);
1585
+ const [tipPos, setTipPos] = react.useState(null);
1586
+ /* 气泡只由点击展开(圆环或各字段),再点一次或点别处收起 */
1587
+ const rootRef = react.useRef(null);
1588
+ /* 气泡定位:触发项位置 + 对齐方式(left / center / right)+ 面板节点(用来量宽度) */
1589
+ const anchorRef = react.useRef(null);
1590
+ const tipModeRef = react.useRef("center");
1591
+ const tipRef = react.useRef(null);
1592
+ /* 窗口缩放时 +1,只为让定位 effect 重新量一次位置 */
1593
+ const [tipTick, setTipTick] = react.useState(0);
978
1594
 
979
1595
  /* 本轮费用兜底:官方全量 tokenUsage 差分 */
980
1596
  const turnBaseRef = react.useRef(null);
@@ -1003,7 +1619,7 @@ window.__ModuleLoader__.load({
1003
1619
  * 不能把 running 当唯一开关 —— 它在 step/turn 边界会短暂转 false,
1004
1620
  * 那样时钟会在每轮中途停摆,要等下一个事件才跳一次。 */
1005
1621
  /* 需要“现在几点”的段:总用时(时长累加)与峰谷判断(时段文字) */
1006
- const needsNow = cfg.enabled === true && (isSegmentOn(cfg, "sessionTime") || isSegmentOn(cfg, "status"));
1622
+ const needsNow = isSegmentOn(cfg, "sessionTime") || isSegmentOn(cfg, "status");
1007
1623
  const timeRangeNow = timeRange === undefined || timeRange === null ? null : timeRange;
1008
1624
  const clockActive = timeRangeNow !== null
1009
1625
  && ((timeRangeNow.since !== null && timeRangeNow.since !== undefined)
@@ -1029,7 +1645,7 @@ window.__ModuleLoader__.load({
1029
1645
  void ensurePrices();
1030
1646
  }, []);
1031
1647
 
1032
- const wantsBalance = cfg.enabled === true && isSegmentOn(cfg, "balance");
1648
+ const wantsBalance = isSegmentOn(cfg, "balance");
1033
1649
  react.useEffect(() => {
1034
1650
  if (!wantsBalance) return undefined;
1035
1651
  let alive = true;
@@ -1044,18 +1660,81 @@ window.__ModuleLoader__.load({
1044
1660
  return () => { alive = false; window.clearInterval(timer); };
1045
1661
  }, [wantsBalance]);
1046
1662
 
1047
- if (cfg.enabled !== true) return null;
1663
+ /* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
1664
+ 不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
1665
+ 跟着 scroll 收起的话,气泡会在这些时刻莫名其妙消失。 */
1666
+ react.useEffect(() => {
1667
+ if (tipId === null) return undefined;
1668
+ const close = () => { setTipId(null); setTipPos(null); };
1669
+ const onDocClick = (event) => {
1670
+ if (rootRef.current !== null && rootRef.current.contains(event.target)) return;
1671
+ close();
1672
+ };
1673
+ /* 窗口尺寸变了只是位置要重算,不必把气泡收掉 */
1674
+ const reflow = () => setTipTick((n) => n + 1);
1675
+ document.addEventListener("click", onDocClick);
1676
+ window.addEventListener("resize", reflow);
1677
+ return () => {
1678
+ document.removeEventListener("click", onDocClick);
1679
+ window.removeEventListener("resize", reflow);
1680
+ };
1681
+ }, [tipId]);
1682
+
1683
+ /* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
1684
+ 最左、最右那两项贴边对齐,其余跟该字段居中对齐,最后按视口左右夹住。
1685
+ 用 layout effect 是为了在浏览器绘制前就落位,不会先闪一下再跳。
1686
+ anchorRef 存的是触发项节点本身,每次都重新量 —— 窗口缩放后位置才是对的。 */
1687
+ useTipLayout(() => {
1688
+ if (tipId === null) return;
1689
+ const target = anchorRef.current;
1690
+ const anchor = target !== null && target !== undefined && typeof target.getBoundingClientRect === "function"
1691
+ ? target.getBoundingClientRect()
1692
+ : null;
1693
+ if (anchor === null) {
1694
+ setTipPos(null);
1695
+ return;
1696
+ }
1697
+ const node = tipRef.current;
1698
+ const width = node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
1699
+ ? node.getBoundingClientRect().width
1700
+ : 0;
1701
+ const mode = tipModeRef.current;
1702
+ const centered = anchor.left + anchor.width / 2 - width / 2;
1703
+ const wanted = mode === "left" ? anchor.left : (mode === "right" ? anchor.right - width : centered);
1704
+ setTipPos({
1705
+ left: Math.max(8, Math.min(wanted, window.innerWidth - width - 8)),
1706
+ bottom: Math.max(8, window.innerHeight - anchor.top + 6)
1707
+ });
1708
+ }, [tipId, tipTick]);
1709
+
1710
+ /* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
1711
+ /* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
1712
+ react.useEffect(() => {
1713
+ if (typeof document === "undefined" || document === null) return undefined;
1714
+ const root = document.documentElement;
1715
+ if (root === null || root === undefined || root.classList === undefined) return undefined;
1716
+ root.classList.add("dsb-on");
1717
+ return () => { root.classList.remove("dsb-on"); };
1718
+ }, []);
1048
1719
 
1049
1720
  /* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底 */
1050
- const nodeTurnUsage = foldTurnUsage(chatNodes, running === true ? 1 : 0);
1051
- const turnUsage = nodeTurnUsage !== null ? nodeTurnUsage : turnLastRef.current;
1721
+ /* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底 */
1722
+ const foldedTurn = foldTurnUsage(chatNodes);
1723
+ const turnUsage = foldedTurn !== null ? foldedTurn.parts : turnLastRef.current;
1724
+ /* 这份用量属于哪一轮(节点折叠才有;差分兜底没有轮号) */
1725
+ const turnUsageTurn = foldedTurn !== null ? foldedTurn.turn : null;
1726
+ /* host 投影说的当前轮号:用来判断上面那份用量是不是本轮正在进行中的 */
1727
+ const currentTurn = sessionUsage !== undefined && sessionUsage !== null && typeof sessionUsage.turn === "number"
1728
+ ? sessionUsage.turn
1729
+ : null;
1052
1730
 
1053
1731
  const debugSrc = {
1054
- stats: stats, usage: usage, pressure: pressure,
1732
+ stats: stats, usage: usage,
1055
1733
  sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRangeNow,
1056
1734
  running: running, partial: partial, runningCalls: runningCalls,
1057
1735
  lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
1058
- now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency
1736
+ now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency,
1737
+ timing: timing, turnUsageTurn: turnUsageTurn, currentTurn: currentTurn
1059
1738
  };
1060
1739
  window.__dsbDebug = {
1061
1740
  now: now, running: running, clockActive: clockActive, timeRange: timeRangeNow,
@@ -1069,17 +1748,22 @@ window.__ModuleLoader__.load({
1069
1748
  currentAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.current,
1070
1749
  lastAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.last,
1071
1750
  turnUsage: turnUsage,
1751
+ turnUsageTurn: turnUsageTurn,
1752
+ currentTurn: currentTurn,
1753
+ timing: timing,
1072
1754
  costText: segmentText("cost", segmentView("cost", debugSrc, t), t),
1073
1755
  lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t),
1074
1756
  sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t)
1075
1757
  };
1076
1758
 
1077
1759
  const src = {
1078
- stats: stats, usage: usage, pressure: pressure,
1760
+ stats: stats, usage: usage,
1079
1761
  sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRange,
1080
1762
  running: running, partial: partial, runningCalls: runningCalls,
1081
1763
  lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
1082
- now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency
1764
+ now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency,
1765
+ pressure: pressure, breakdown: breakdown, timing: timing,
1766
+ turnUsageTurn: turnUsageTurn, currentTurn: currentTurn
1083
1767
  };
1084
1768
 
1085
1769
  const views = [];
@@ -1091,13 +1775,67 @@ window.__ModuleLoader__.load({
1091
1775
  : { id: id, text: segmentText(id, view, t) });
1092
1776
  });
1093
1777
 
1094
- const statusView = views.filter((v) => v.id === "status")[0];
1095
- const dotClass = "dsb-dot"
1096
- + (statusView !== undefined && statusView.state === "running" ? " dsb-dot-running" : "")
1097
- + (statusView !== undefined && statusView.state === "error" ? " dsb-dot-error" : "")
1098
- + (statusView !== undefined && statusView.state === "approval" ? " dsb-dot-approval" : "");
1099
-
1100
- const children = [h("span", { className: dotClass, key: "__dot" })];
1778
+ /* 运行状态:只用来给上下文占用圆环上色(空闲灰 / 运行绿 / 错误红 / 待审批黄) */
1779
+ const agentRunning = src.running === true
1780
+ || (src.partial !== undefined && src.partial !== null)
1781
+ || (Array.isArray(src.runningCalls) && src.runningCalls.length > 0);
1782
+ const approvals = src.pendingApprovals;
1783
+ const approvalWaiting = approvals !== undefined && approvals !== null
1784
+ && (Array.isArray(approvals) ? approvals.length > 0 : true);
1785
+ const agentFailed = !agentRunning && src.lastAgentError !== undefined && src.lastAgentError !== null;
1786
+ const agentState = agentRunning ? "running" : (agentFailed ? "error" : (approvalWaiting ? "approval" : "idle"));
1787
+ /* 会话状态是独立开关:固定在栏首、不参与排序;没有占用数据时整块不出现(不再退化成小圆点) */
1788
+ const contextMeter = cfg.contextMeter === true ? meterView(pressure, breakdown, t) : null;
1789
+ /* 可点项的顺序(含最前面的圆环):最左、最右那两个气泡贴边对齐,其余跟字段居中 */
1790
+ const tipKeys = [];
1791
+ if (contextMeter !== null) tipKeys.push("context");
1792
+ views.forEach((view) => {
1793
+ if (view.text === "") return;
1794
+ const one = view.tip === undefined ? null : view.tip;
1795
+ if (one !== null) tipKeys.push(view.id);
1796
+ });
1797
+ const modeOf = (key) => {
1798
+ const at = tipKeys.indexOf(key);
1799
+ if (at < 0) return "center";
1800
+ if (at === 0) return "left";
1801
+ return at === tipKeys.length - 1 ? "right" : "center";
1802
+ };
1803
+ /* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
1804
+ const toggleTip = (id, event, mode) => {
1805
+ if (tipId === id) {
1806
+ setTipId(null);
1807
+ setTipPos(null);
1808
+ return;
1809
+ }
1810
+ const target = event !== undefined && event !== null ? event.currentTarget : null;
1811
+ /* 存节点而不是当次的矩形:窗口缩放后要重新量 */
1812
+ anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
1813
+ tipModeRef.current = mode === undefined || mode === null ? "center" : mode;
1814
+ setTipId(id);
1815
+ setTipPos(null);
1816
+ };
1817
+ /* 面板挂在触发项自己身上(当子元素),这样气泡正好出现在该项正上方 */
1818
+ const tipPanelOf = (tip) => {
1819
+ if (tip === undefined || tip === null) return null;
1820
+ if (tip.kind === "tokens") return tokenPanel(tip.panel, tip.title, tipPos, tipRef);
1821
+ if (tip.kind === "meter") return meterPanel(tip.meter, t, tipPos, tipRef);
1822
+ return rowsPanel(tip, t, tipPos, tipRef);
1823
+ };
1824
+ /* 圆环自己就是触发项:点开上下文占用面板;峰谷那两个字不带气泡 */
1825
+ const contextTip = contextMeter === null ? null : { kind: "meter", meter: contextMeter };
1826
+ const children = [];
1827
+ if (contextTip !== null) {
1828
+ children.push(h("button", {
1829
+ type: "button",
1830
+ className: "dsb-ring-btn",
1831
+ key: "__ringbtn",
1832
+ "aria-expanded": tipId === "context" ? "true" : "false",
1833
+ onClick: (event) => toggleTip("context", event, modeOf("context"))
1834
+ }, [
1835
+ contextRing(contextMeter.percent, agentState),
1836
+ tipId === "context" ? tipPanelOf(contextTip) : null
1837
+ ]));
1838
+ }
1101
1839
  let lastShown = null;
1102
1840
  views.forEach((view) => {
1103
1841
  if (view.text === "") return;
@@ -1105,28 +1843,19 @@ window.__ModuleLoader__.load({
1105
1843
  children.push(h("span", { className: "dsb-sep", key: "__sep" + view.id }, "|"));
1106
1844
  }
1107
1845
  lastShown = view;
1846
+ const tip = view.tip === undefined ? null : view.tip;
1108
1847
  children.push(h("span", {
1109
- className: "dsb-seg", key: "__seg" + view.id,
1110
- onMouseEnter: view.rows !== undefined && view.rows !== null ? () => setTipId(view.id) : undefined,
1111
- onMouseLeave: view.rows !== undefined && view.rows !== null ? () => setTipId(null) : undefined
1112
- }, view.text));
1848
+ className: tip !== null ? "dsb-seg dsb-seg-click" : "dsb-seg",
1849
+ key: "__seg" + view.id,
1850
+ "aria-expanded": tip !== null ? (tipId === view.id ? "true" : "false") : undefined,
1851
+ onClick: tip !== null ? (event) => toggleTip(view.id, event, modeOf(view.id)) : undefined
1852
+ }, tip !== null
1853
+ ? [h("span", { key: "__txt" }, view.text), tipId === view.id ? tipPanelOf(tip) : null]
1854
+ : view.text));
1113
1855
  });
1114
-
1115
- /* 自绘悬停气泡:两列 grid,标签左对齐、数值右对齐 */
1116
- if (tipId !== null) {
1117
- const tipView = views.filter((v) => v.id === tipId)[0];
1118
- if (tipView !== undefined && Array.isArray(tipView.rows) && tipView.rows.length > 0) {
1119
- const cells = [];
1120
- tipView.rows.forEach((row, index) => {
1121
- cells.push(h("span", { className: "dsb-tip-label", key: "__tl" + index }, row.label));
1122
- cells.push(h("span", { className: "dsb-tip-value", key: "__tv" + index }, row.value));
1123
- });
1124
- children.push(h("span", { className: "dsb-tip", key: "__tip" },
1125
- h("span", { className: "dsb-tip-grid" }, cells)));
1126
- }
1127
- }
1128
1856
  return h("span", {
1129
- className: cfg.wrap === true ? "dsb-root dsb-wrap" : "dsb-root",
1857
+ ref: rootRef,
1858
+ className: "dsb-root",
1130
1859
  "data-dsb": "bar"
1131
1860
  }, children);
1132
1861
  }
@@ -1143,6 +1872,43 @@ window.__ModuleLoader__.load({
1143
1872
  const [hostModel, setHostModel] = react.useState(null);
1144
1873
  const [editing, setEditing] = react.useState(null);
1145
1874
  const [draft, setDraft] = react.useState(null);
1875
+ const [holidayBusy, setHolidayBusy] = react.useState(false);
1876
+ const [holidayNote, setHolidayNote] = react.useState(null);
1877
+ const [currencyOpen, setCurrencyOpen] = react.useState(false);
1878
+ const currencyRef = react.useRef(null);
1879
+ const editPopRef = react.useRef(null);
1880
+
1881
+ /* 编辑浮层比卡片本身高,会被设置页的滚动容器裁掉下半截 —— 打开后滚一下,
1882
+ 把它的底边对齐到可视区底部,整块就都看得见了。 */
1883
+ react.useEffect(() => {
1884
+ if (editing === null) return;
1885
+ const node = editPopRef.current;
1886
+ if (node === null || node === undefined || typeof node.scrollIntoView !== "function") return;
1887
+ node.scrollIntoView({ block: "end", behavior: "smooth" });
1888
+ }, [editing]);
1889
+
1890
+ /* 编辑浮层:点别处就收起,草稿一律丢弃 —— 改了一半的数字、新模型里填的空值都不留下 */
1891
+ react.useEffect(() => {
1892
+ if (editing === null) return undefined;
1893
+ const onDocClick = (event) => {
1894
+ const node = editPopRef.current;
1895
+ if (node !== null && node !== undefined && typeof node.contains === "function" && node.contains(event.target)) return;
1896
+ cancelEdit();
1897
+ };
1898
+ document.addEventListener("click", onDocClick);
1899
+ return () => document.removeEventListener("click", onDocClick);
1900
+ }, [editing]);
1901
+
1902
+ /* 币种下拉:点外部收起(和底栏气泡同一套规矩,不跟着滚动关) */
1903
+ react.useEffect(() => {
1904
+ if (currencyOpen !== true) return undefined;
1905
+ const onDocClick = (event) => {
1906
+ if (currencyRef.current !== null && currencyRef.current.contains(event.target)) return;
1907
+ setCurrencyOpen(false);
1908
+ };
1909
+ document.addEventListener("click", onDocClick);
1910
+ return () => document.removeEventListener("click", onDocClick);
1911
+ }, [currencyOpen]);
1146
1912
 
1147
1913
  /* 当前会话模型:底栏会上报给 host,这里读取(设置页自己拿不到会话投影) */
1148
1914
  react.useEffect(() => {
@@ -1213,19 +1979,20 @@ window.__ModuleLoader__.load({
1213
1979
  function setDraftValue(tierKey, field, value) {
1214
1980
  setDraft((prev) => {
1215
1981
  if (prev === null) return prev;
1982
+ /* 只改被编辑的那一格;另一档原样留着(切换计价方式不该丢数值) */
1216
1983
  const next = { tiered: prev.tiered, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
1217
1984
  next[tierKey][field] = value;
1218
- if (prev.tiered !== true) next.offPeak = Object.assign({}, next.peak); /* 全天同价:两档始终一致 */
1219
1985
  return next;
1220
1986
  });
1221
1987
  }
1222
1988
  function setDraftTiered(on) {
1223
1989
  setDraft((prev) => {
1224
1990
  if (prev === null) return prev;
1991
+ /* 用哪一档由 tiered 决定,两档数值都保留:取消勾选再勾回来,空闲价还在 */
1225
1992
  return {
1226
1993
  tiered: on,
1227
1994
  peak: Object.assign({}, prev.peak),
1228
- offPeak: on === true ? Object.assign({}, prev.offPeak) : Object.assign({}, prev.peak)
1995
+ offPeak: Object.assign({}, prev.offPeak)
1229
1996
  };
1230
1997
  });
1231
1998
  }
@@ -1237,7 +2004,8 @@ window.__ModuleLoader__.load({
1237
2004
  next[editing] = {
1238
2005
  tiered: draft.tiered === true,
1239
2006
  peak: asNumber(draft.peak),
1240
- offPeak: draft.tiered === true ? asNumber(draft.offPeak) : asNumber(draft.peak)
2007
+ /* 两档都照实存:计价用哪一档看 tiered,取消勾选不该把空闲价抹掉 */
2008
+ offPeak: asNumber(draft.offPeak)
1241
2009
  };
1242
2010
  setConfig({ models: next, priceConfigured: true });
1243
2011
  setEditing(null);
@@ -1246,47 +2014,32 @@ window.__ModuleLoader__.load({
1246
2014
 
1247
2015
  const sections = [];
1248
2016
 
1249
- sections.push(h("p", { className: "dsb-intro", key: "__intro" }, t("intro")));
2017
+ sections.push(h("div", { className: "dsb-head", key: "__head" }, [
2018
+ h("h2", { className: "dsb-pagetitle" }, t("sectionTitle")),
2019
+ h("p", { className: "dsb-intro" }, t("intro"))
2020
+ ]));
1250
2021
 
1251
- /* 基础 */
1252
- const basicRows = [];
1253
- basicRows.push(h("div", { className: "dsb-row", key: "__enabled" },
1254
- h("label", { className: "dsb-check" },
1255
- h("input", {
1256
- type: "checkbox",
1257
- checked: cfg.enabled === true,
1258
- onChange: (event) => setConfig({ enabled: event.target.checked })
1259
- }),
1260
- h("span", { className: "dsb-labels" },
1261
- h("span", { className: "dsb-name" }, t("enabled")),
1262
- h("span", { className: "dsb-desc" }, t("enabledHint"))
1263
- )
1264
- )
1265
- ));
1266
- basicRows.push(h("div", { className: "dsb-row", key: "__wrap" },
2022
+ /* 统计段:段序对整份列表生效,勾选只控制显示,取消勾选不移位。
2023
+ 上下文占用是固定项:单独一行、没有拖拽把手,永远排在栏首。 */
2024
+ const byId = {};
2025
+ SEGMENTS.forEach((segment) => { byId[segment.id] = segment; });
2026
+ const orderedSegments = cfg.segments
2027
+ .map((id) => byId[id])
2028
+ .filter((segment) => segment !== undefined && segment !== null);
2029
+
2030
+ const contextRow = h("div", { className: "dsb-row", key: "__context" },
1267
2031
  h("label", { className: "dsb-check" },
1268
2032
  h("input", {
1269
2033
  type: "checkbox",
1270
- checked: cfg.wrap === true,
1271
- onChange: (event) => setConfig({ wrap: event.target.checked })
2034
+ checked: cfg.contextMeter === true,
2035
+ onChange: (event) => setConfig({ contextMeter: event.target.checked })
1272
2036
  }),
1273
2037
  h("span", { className: "dsb-labels" },
1274
- h("span", { className: "dsb-name" }, t("wrap")),
1275
- h("span", { className: "dsb-desc" }, t("wrapHint"))
2038
+ h("span", { className: "dsb-name" }, t("segContext")),
2039
+ h("span", { className: "dsb-desc" }, t("segContextHint"))
1276
2040
  )
1277
2041
  )
1278
- ));
1279
- sections.push(h("div", { className: "dsb-section", key: "__basic" },
1280
- h("div", { className: "dsb-sectitle" }, t("secBasic")),
1281
- basicRows
1282
- ));
1283
-
1284
- /* 统计段:段序对整份列表生效,勾选只控制显示,取消勾选不移位 */
1285
- const byId = {};
1286
- SEGMENTS.forEach((segment) => { byId[segment.id] = segment; });
1287
- const orderedSegments = cfg.segments
1288
- .map((id) => byId[id])
1289
- .filter((segment) => segment !== undefined && segment !== null);
2042
+ );
1290
2043
 
1291
2044
  const segmentRows = orderedSegments.map((segment) => {
1292
2045
  const on = isSegmentOn(cfg, segment.id);
@@ -1355,12 +2108,13 @@ window.__ModuleLoader__.load({
1355
2108
  sections.push(h("div", { className: "dsb-section", key: "__segments" },
1356
2109
  h("div", { className: "dsb-sectitle" }, t("secSegments")),
1357
2110
  h("p", { className: "dsb-hint" }, t("secSegmentsHint")),
2111
+ contextRow,
1358
2112
  segmentRows
1359
2113
  ));
1360
2114
 
1361
2115
  /* 自定义模型价格 */
1362
2116
  const priceCards = [];
1363
- priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint")));
2117
+ priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint", { currency: cfg.currency })));
1364
2118
  /* "当前会话使用"提示:设置页是否能拿到投影由宿主决定,拿不到就显示占位 */
1365
2119
  const settingsModel = typeof props.useProjection === "function" ? props.useProjection("desktopStatusbarModel") : undefined;
1366
2120
  const currentModelName = settingsModel !== null && settingsModel !== undefined && typeof settingsModel.model === "string"
@@ -1408,7 +2162,16 @@ window.__ModuleLoader__.load({
1408
2162
  priceField(tierKey, t("priceOutput"), "output")
1409
2163
  )
1410
2164
  );
1411
- const action = (label, key, onClick) => h("button", { type: "button", key: key, className: "dsb-action dsb-action-sm", onClick: onClick }, label);
2165
+ /* 头部按钮自带行为:拦一下冒泡,别被"点别处收起编辑区"顺手收掉 */
2166
+ const action = (label, key, onClick) => h("button", {
2167
+ type: "button",
2168
+ key: key,
2169
+ className: "dsb-action dsb-action-sm",
2170
+ onClick: (event) => {
2171
+ if (event !== undefined && event !== null && typeof event.stopPropagation === "function") event.stopPropagation();
2172
+ onClick();
2173
+ }
2174
+ }, label);
1412
2175
  const head = h("div", { className: "dsb-model-head", key: "__head" },
1413
2176
  h("span", { className: "dsb-model-name" }, displayModelName(name)),
1414
2177
  h("div", { className: "dsb-model-actions" },
@@ -1416,20 +2179,49 @@ window.__ModuleLoader__.load({
1416
2179
  action(t("priceRemove"), "__remove", () => removeModel(name))
1417
2180
  )
1418
2181
  );
2182
+ /* 编辑区做成毛玻璃浮层(与底栏气泡、计价单位下拉同一套材质),从卡片头部下方展开 */
1419
2183
  const body = open === true
1420
- ? [
1421
- h("label", { className: "dsb-inline-check", key: "__tiered" },
1422
- h("input", {
1423
- type: "checkbox",
1424
- checked: draft.tiered === true,
1425
- onChange: (event) => setDraftTiered(event.target.checked)
1426
- }),
1427
- h("span", null, t("priceTiered"))
2184
+ ? h("div", { className: "dsb-edit-pop", key: "__edit", ref: editPopRef }, [
2185
+ /* 这一行左边是「峰谷计价」勾选,右边挂计价单位下拉(没有标题,就一个框) */
2186
+ h("div", { className: "dsb-tier-head", key: "__tiered" },
2187
+ h("label", { className: "dsb-inline-check" },
2188
+ h("input", {
2189
+ type: "checkbox",
2190
+ checked: draft.tiered === true,
2191
+ onChange: (event) => setDraftTiered(event.target.checked)
2192
+ }),
2193
+ h("span", null, t("priceTiered"))
2194
+ ),
2195
+ h("div", { className: "dsb-select", ref: currencyRef },
2196
+ h("button", {
2197
+ type: "button",
2198
+ className: "dsb-select-btn",
2199
+ "aria-haspopup": "listbox",
2200
+ "aria-expanded": currencyOpen === true ? "true" : "false",
2201
+ onClick: () => setCurrencyOpen(currencyOpen !== true)
2202
+ }, cfg.currency),
2203
+ currencyOpen === true
2204
+ ? h("div", { className: "dsb-select-menu", role: "listbox" },
2205
+ ["CNY", "USD"].map((code) => h("button", {
2206
+ type: "button",
2207
+ key: code,
2208
+ role: "option",
2209
+ "aria-selected": cfg.currency === code ? "true" : "false",
2210
+ className: cfg.currency === code ? "dsb-select-item dsb-select-item-on" : "dsb-select-item",
2211
+ onClick: () => {
2212
+ const switched = switchCurrency(code);
2213
+ setCurrencyOpen(false);
2214
+ /* 草稿跟着换成新币种的价格:不然框里还留着旧币种的数字 */
2215
+ if (switched !== null && editing !== null) startEdit(editing, switched.models[editing]);
2216
+ }
2217
+ }, code)))
2218
+ : null
2219
+ )
1428
2220
  ),
1429
2221
  draft.tiered === true ? tierRow("offPeak", t("priceTierOffPeak")) : tierRow("peak", null),
1430
2222
  draft.tiered === true ? tierRow("peak", t("priceTierPeak")) : null
1431
- ]
1432
- : [];
2223
+ ])
2224
+ : null;
1433
2225
  modelRows.push(h("div", { className: "dsb-model", key: name }, head, body));
1434
2226
  });
1435
2227
  if (modelRows.length > 0) {
@@ -1440,22 +2232,56 @@ window.__ModuleLoader__.load({
1440
2232
  h("div", { className: "dsb-card" }, priceCards)
1441
2233
  ));
1442
2234
 
1443
- /* 收尾 */
1444
- sections.push(h("div", { className: "dsb-foot", key: "__foot" },
2235
+ /* 收尾:恢复默认 + 常驻的「更新节假峰谷」;按钮右边只在点过之后出现状态 ——
2236
+ 搜索中 / 已写入概况,平时不占位。抓取失败的原因挪到按钮的悬停提示里 */
2237
+ const holidayStatus = holidaySummary(Date.now());
2238
+ const holidayFailure = holidayNote === null || holidayNote.ok === true
2239
+ ? null
2240
+ : (holidayNote.reason === "no-route"
2241
+ ? t("holidayUpdateNoRoute")
2242
+ : (holidayNote.reason === "not-found" || holidayNote.reason === "parse-empty"
2243
+ ? t("holidayUpdateMissing", { year: holidayNote.year })
2244
+ : t("holidayUpdateFail", { reason: holidayNote.reason })));
2245
+ const holidayActions = [
1445
2246
  h("button", {
1446
2247
  type: "button",
1447
2248
  className: "dsb-action",
2249
+ key: "__holiday",
2250
+ disabled: holidayBusy === true,
2251
+ title: holidayFailure === null ? undefined : holidayFailure,
2252
+ onClick: () => {
2253
+ const year = nextHolidayYear(Date.now());
2254
+ setHolidayBusy(true);
2255
+ setHolidayNote(null);
2256
+ updateHolidays(year).then((result) => {
2257
+ setHolidayBusy(false);
2258
+ setHolidayNote(result);
2259
+ });
2260
+ }
2261
+ }, t("holidayUpdate"))
2262
+ ];
2263
+ if (holidayBusy === true || holidayNote !== null) {
2264
+ holidayActions.push(h("span", { className: "dsb-hint", key: "__holidaystatus" },
2265
+ holidayBusy === true
2266
+ ? t("holidayUpdateBusy")
2267
+ : t("holidayUpdateOk", { year: holidayStatus.year, count: holidayStatus.count })));
2268
+ }
2269
+ sections.push(h("div", { className: "dsb-foot", key: "__foot" }, [
2270
+ h("button", {
2271
+ type: "button",
2272
+ className: "dsb-action",
2273
+ key: "__reset",
1448
2274
  onClick: () => setConfig({
1449
- enabled: true,
1450
- wrap: true,
2275
+ contextMeter: true,
1451
2276
  segments: DEFAULT_SEGMENTS.slice(),
1452
2277
  hidden: [],
1453
2278
  currency: "CNY",
1454
2279
  models: Object.assign({}, DEFAULT_PRICES),
1455
2280
  modelOrder: Object.keys(DEFAULT_PRICES)
1456
2281
  })
1457
- }, t("reset"))
1458
- ));
2282
+ }, t("reset")),
2283
+ h("div", { className: "dsb-actions", key: "__actions" }, holidayActions)
2284
+ ]));
1459
2285
 
1460
2286
  return h("div", { className: "dsb-settings" }, sections);
1461
2287
  }