dsh-desktop-statusbar 2.1.2 → 2.2.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
@@ -52,6 +52,8 @@ window.__ModuleLoader__.load({
52
52
  const VERSION_URL = "/dsh-desktop-statusbar/api/version";
53
53
  /** 一键更新:由本插件自己的 host 端执行(不依赖任何别的插件,见 self-update 说明)。 */
54
54
  const SELF_UPDATE_URL = "/dsh-desktop-statusbar/api/self-update";
55
+ /** 客户端侧更新失败的上报端点(host 写进日志;宿主自己不知道浏览器里发生了什么)。 */
56
+ const UPDATE_FAILURE_URL = "/dsh-desktop-statusbar/api/update-failure";
55
57
  /* 子智能体(Agent Teams 的队友)用量的聚合端点:队友是**独立会话**,它的消耗不在本会话
56
58
  的投影里,只能由 host 按 parentSession 找出来,「总计」「本轮」才真的把队友算进去。
57
59
  带 since(毫秒)= 只要那个时刻之后的用量,客户端拿它取「本轮」这一段。 */
@@ -60,15 +62,19 @@ window.__ModuleLoader__.load({
60
62
  所以这里取 1 秒 —— 用户要的「实时跳动」;也不必为它新增定时器。
61
63
  没有变化时后台只比对文件的 mtime/size(实测几毫秒),每秒重拉的开销可以忽略。 */
62
64
  const SUBAGENT_REFRESH_MS = 1000;
63
- /** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。 */
64
- const SUBAGENT_TIMEOUT_MS = 8000;
65
+ /** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。
66
+ 上百个子智能体时 host 要把所有子会话日志解压聚合,实测 146 个活跃子会话首轮聚合
67
+ 可以明显超过 8s;超时被当成失败后客户端会沿用"上一次成功的小值",界面就卡在偏小
68
+ 数字上、等某一次偶然成功再跳大。所以把超时放宽到 20s(配合 host 侧按文件缓存,
69
+ 热路径通常几百毫秒)。 */
70
+ const SUBAGENT_TIMEOUT_MS = 20000;
65
71
  /**
66
72
  * 配置结构版本。
67
73
  * v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
68
74
  */
69
75
  const CONFIG_VERSION = 6;
70
76
  /** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
71
- const VERSION = "2.1.2";
77
+ const VERSION = "2.2.0";
72
78
  /** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
73
79
  const PRICE_VERSION = 2;
74
80
  /** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
@@ -869,13 +875,232 @@ window.__ModuleLoader__.load({
869
875
  return { value: value, hold: { sessionId: sessionId, turnKey: turnKey, value: value === null ? floor : value } };
870
876
  }
871
877
 
872
- /** 本会话的令牌三段 + 队友的三段:同一个数字直接相加,不新增行。 */
878
+ /**
879
+ * 账本里「已结算调用指纹」集合的容量上限。
880
+ *
881
+ * 为什么需要上限:`calls` 最多 MAX_CALLS(4000) 条,但账本跨会话存活期间可能累计更多。
882
+ * 超出后按**插入顺序**淘汰最老的(Map 保证插入序)—— 这不会造成重复计费:
883
+ * 被淘汰的指纹对应的调用早已滑出 `calls` 窗口(host 那边按 4000 条滚动),不会再出现在
884
+ * 后续快照里;万一真的又出现(极端情况下 host 重新回放),代价是这一条被再算一次,
885
+ * 而不是像旧实现那样**每一拍**都重复加 overflow + 队友。取 2×MAX_CALLS 留足余量。
886
+ */
887
+ const SEEN_LIMIT = 8000;
888
+
889
+ /**
890
+ * 「总计」的**增量式记账**(用户拍板的口径):
891
+ * **每条调用只算一次钱,算完就固化,之后永不重算**;
892
+ * 没价的模型记**欠账 token**,后来补上价了只补算这部分。
893
+ *
894
+ * 为什么必须换掉旧实现(「锁定基准 + 差值修正」,已删除):它每拍都用**当前价**把全部历史
895
+ * 调用重算一遍,再用 `lockedCost` 把差额盖回去 —— 有三个必错的地方:
896
+ * ① 锁的是"整笔显示金额"(含 overflow 与队友),而差值修正只重算 calls
897
+ * ⇒ 那两块每一拍都被重复加一次(切会话时能把上个会话的队友花费永久算进来);
898
+ * ② 首帧算不出价时 `lockedCost` 被锁成 0 且永不重建 ⇒ 补价后总计永久显示 ¥0.00;
899
+ * ③ 用**数组下标**(`lockedCount`)切分新旧,而 calls 满 MAX_CALLS 后会从队首丢弃
900
+ * ⇒ 下标整体错位,扣错钱。
901
+ *
902
+ * 现在的做法:账本里记一份「已经结算过的调用指纹」集合,每拍只结算**没见过的**调用。
903
+ * 为什么用指纹而不是时间游标:`calls` **不保证按 at 升序**(实测 verify-pricing 里就是
904
+ * [22:00, 10:00, 10:00]),也不是只追加(MAX_CALLS 会从队首丢弃)—— 任何"只往后推游标"
905
+ * 的做法都会漏算乱序到达的调用。指纹对乱序、滚动窗口、同毫秒并行调用全部免疫。
906
+ *
907
+ * `hold`(调用方 ref 里那个对象,**就地更新**)的形状:
908
+ * · `sessionId` —— 这份账属于哪个会话;变了就把整份状态清空重建
909
+ * · `seen` —— 已结算调用的指纹集合(Map: 指纹 → true),见下面 ① 的说明
910
+ * · `cost` —— 已结算金额(累计,只增)
911
+ * · `tokens` —— 已结算的 token 三段(累计,只增)
912
+ * · `priced` —— 是否**成功定价过至少一条**(用来区分「真的是 0 元」与「一条都算不出价」)
913
+ * · `subCostSettled` —— 已计入的队友金额(算增量用;队友是快照,没有逐条时间戳)
914
+ * · `debts` —— 欠账,**Map**:`模型名 → { peak: 桶|null, offPeak: 桶|null }`
915
+ * (用 Map 而不是普通对象:模型名是上游给的任意字符串,`__proto__` 这类键
916
+ * 在普通对象上会变成设原型、静默丢失 —— 详见换会话清空处的说明)
917
+ *
918
+ * 返回 `{ cost, tokens, hold }`;`cost` 为 null 表示"一条都算不出价"(界面给横杠)。
919
+ *
920
+ * 输入就是**一个素材包**(calls / overflow / subCost / tokens / 价格库等),只有这一种形态。
921
+ */
922
+ function applyTotalFloor(hold, sessionId, input) {
923
+ const prev = hold !== null && hold !== undefined && typeof hold === "object" ? hold : null;
924
+ const sameSession = prev !== null && prev.sessionId === sessionId;
925
+ const snap = input !== null && input !== undefined && typeof input === "object" ? input : {};
926
+ /* 账本就地更新:调用方持有的是 useRef 里同一个对象,跨渲染靠它留住累计值 */
927
+ const box = prev !== null ? prev : {
928
+ sessionId: null, seen: new Map(), cost: 0, tokens: null,
929
+ priced: false, subCostSettled: 0, debts: new Map()
930
+ };
931
+ /* 换会话(含首次):整份状态清空。清空后 seen 为空,
932
+ 下面那段"只结算没见过的调用"自然退化成"结算现有全部调用",不必另写一套初始化。 */
933
+ if (sameSession !== true) {
934
+ box.sessionId = sessionId;
935
+ box.seen = new Map();
936
+ box.cost = 0;
937
+ box.tokens = null;
938
+ box.priced = false;
939
+ box.subCostSettled = 0;
940
+ /* 欠账必须用 **Map** 而不是普通对象:模型名是上游给的任意字符串,
941
+ 用 `obj[model] = slot` 时若 model 恰好是 `__proto__`,那是**设置原型**、
942
+ 不是加键 —— `Object.keys` 遍历不到它,补价时永远结算不回来,
943
+ 总计会永久少算这笔钱(实测确认)。`constructor` / `toString` 这类
944
+ 虽然"碰巧能跑"(读到原型函数、把函数当字典用),也不该依赖这种巧合。
945
+ Map 没有原型链,任意字符串键都安全 —— 与同文件 seen 的写法一致。 */
946
+ box.debts = new Map();
947
+ }
948
+ /* 兼容老账本对象(升级前 ref 里可能还留着 debts: {} 的形态):
949
+ 就地换成 Map,把已有条目搬过去 —— ref 跨热更新存活时不会丢欠账。 */
950
+ if (typeof Map !== "undefined" && (box.debts instanceof Map) !== true) {
951
+ const migrated = new Map();
952
+ const oldDebts = box.debts;
953
+ if (oldDebts !== null && oldDebts !== undefined && typeof oldDebts === "object") {
954
+ Object.keys(oldDebts).forEach((model) => {
955
+ const slot = oldDebts[model];
956
+ if (slot !== null && slot !== undefined && typeof slot === "object") migrated.set(model, slot);
957
+ });
958
+ }
959
+ box.debts = migrated;
960
+ }
961
+ /* 把一段用量按"它发生时的档位"记进欠账,等有价了再结算 */
962
+ const owe = (model, peak, bucket) => {
963
+ if (bucket === null || bucket === undefined) return;
964
+ const one = box.debts.get(model);
965
+ const slot = one === undefined ? { peak: null, offPeak: null } : one;
966
+ const key = peak === true ? "peak" : "offPeak";
967
+ const had = slot[key];
968
+ slot[key] = had === null || had === undefined
969
+ ? { input: bucket.input || 0, cacheRead: bucket.cacheRead || 0, cacheWrite: bucket.cacheWrite || 0, output: bucket.output || 0 }
970
+ : {
971
+ input: had.input + (bucket.input || 0),
972
+ cacheRead: had.cacheRead + (bucket.cacheRead || 0),
973
+ cacheWrite: had.cacheWrite + (bucket.cacheWrite || 0),
974
+ output: had.output + (bucket.output || 0)
975
+ };
976
+ box.debts.set(model, slot);
977
+ };
978
+ const shown = (amount, model) => toDisplayCurrency(amount, snap.models, model, snap.displayCurrency, snap.defaultCurrency);
979
+
980
+ /* ---------- ① 逐条结算「还没结算过」的调用 ---------- */
981
+ /* 判据是**调用指纹 + 出现次数**,不用数组下标、也不用时间游标:
982
+ · 不用下标:calls 是滚动窗口,队首会随 MAX_CALLS 丢弃,下标会整体错位(旧实现在这里扣错钱);
983
+ · 也不用时间游标:calls **不保证按 at 升序**(实测有 [22:00, 10:00, 10:00] 这种),
984
+ 任何"只往后推游标"的写法都会把乱序到达的早期调用永久漏掉;
985
+ · 用**计数**而不是"见过/没见过":同一毫秒可能有两条完全相同的并行调用
986
+ (at、model、token 数都一样),只记布尔会把第二条永久当成重复。
987
+ 每拍数一遍每个指纹在 calls 里出现几次,与账本里已结算次数比,差额才补算。
988
+ 指纹只增不减:调用滚出 calls 窗口后计数仍留着,同指纹再来时按已结算处理
989
+ (宁可极低概率地少算一条,也绝不重复计费)。
990
+ 契约保证 usageCall.at 必有(host 侧 schema),缺 at 的坏数据跳过。 */
991
+ const calls = Array.isArray(snap.calls) ? snap.calls : [];
992
+ /* 第一遍:数每个指纹在这一拍的 calls 里出现几次 */
993
+ const counts = new Map();
994
+ const firsts = new Map();
995
+ for (let i = 0; i < calls.length; i += 1) {
996
+ const call = calls[i];
997
+ if (call === null || call === undefined || typeof call !== "object") continue;
998
+ const at = typeof call.at === "number" && Number.isFinite(call.at) ? call.at : null;
999
+ if (at === null) continue;
1000
+ const model = typeof call.model === "string" ? call.model : snap.fallbackModel;
1001
+ const print = at + "|" + String(model) + "|" + (call.input || 0) + "|"
1002
+ + (call.cacheRead || 0) + "|" + (call.cacheWrite || 0) + "|" + (call.output || 0);
1003
+ counts.set(print, (counts.has(print) === true ? counts.get(print) : 0) + 1);
1004
+ if (firsts.has(print) !== true) firsts.set(print, call);
1005
+ }
1006
+ /* 第二遍:与账本里「已结算次数」比,只补算差额部分 */
1007
+ counts.forEach((nowCount, print) => {
1008
+ const done = box.seen.has(print) === true ? box.seen.get(print) : 0;
1009
+ if (nowCount <= done) return;
1010
+ const call = firsts.get(print);
1011
+ const model = typeof call.model === "string" ? call.model : snap.fallbackModel;
1012
+ const at = call.at;
1013
+ /* 每条单价都一样,差额直接按条数乘;没价的按条数累进欠账 */
1014
+ const one = costOf(call, model, at);
1015
+ if (one === null) {
1016
+ for (let n = done; n < nowCount; n += 1) owe(model, isPeakTime(at), call);
1017
+ } else {
1018
+ box.cost += shown(one, model) * (nowCount - done);
1019
+ box.priced = true;
1020
+ }
1021
+ box.seen.set(print, nowCount);
1022
+ /* 超出上限就淘汰最老的(Map 插入序 = 结算顺序):见 SEEN_LIMIT 的说明 */
1023
+ while (box.seen.size > SEEN_LIMIT) {
1024
+ const oldest = box.seen.keys().next();
1025
+ if (oldest.done === true) break;
1026
+ box.seen.delete(oldest.value);
1027
+ }
1028
+ });
1029
+ /* ---------- ② overflow:只加一次 ---------- */
1030
+ /* host 把被 MAX_CALLS 截掉的最老调用按「模型 + 峰谷档」累计好了。它没有逐条时间戳,
1031
+ 只能在**首次建立账本**那一拍计入一次 —— 之后每拍再加就是旧实现那个重复计费的错。 */
1032
+ if (sameSession !== true && snap.overflow !== null && snap.overflow !== undefined
1033
+ && typeof snap.overflow === "object") {
1034
+ Object.keys(snap.overflow).forEach((model) => {
1035
+ const byTier = snap.overflow[model];
1036
+ if (byTier === null || byTier === undefined) return;
1037
+ const peak = costOfTier(byTier.peak, model, true);
1038
+ const offPeak = costOfTier(byTier.offPeak, model, false);
1039
+ if (peak === null) owe(model, true, byTier.peak); else { box.cost += shown(peak, model); box.priced = true; }
1040
+ if (offPeak === null) owe(model, false, byTier.offPeak); else { box.cost += shown(offPeak, model); box.priced = true; }
1041
+ });
1042
+ }
1043
+
1044
+ /* ---------- ③ 补算欠账:现在能算价的按**当前价**结算,仍没价的留着 ---------- */
1045
+ box.debts.forEach((slot, model) => {
1046
+ if (slot === null || slot === undefined) { box.debts.delete(model); return; }
1047
+ let done = true;
1048
+ ["peak", "offPeak"].forEach((key) => {
1049
+ const bucket = slot[key];
1050
+ if (bucket === null || bucket === undefined) return;
1051
+ const one = costOfTier(bucket, model, key === "peak");
1052
+ if (one === null) { done = false; return; }
1053
+ box.cost += shown(one, model);
1054
+ box.priced = true;
1055
+ slot[key] = null;
1056
+ });
1057
+ if (done === true && slot.peak === null && slot.offPeak === null) box.debts.delete(model);
1058
+ });
1059
+
1060
+ /* ---------- ④ 队友(子智能体):只能算增量 ---------- */
1061
+ /* 它是**快照**不是逐条调用,没有时间戳,没法按时间切分 —— 这是数据形状决定的限制。
1062
+ 用「当前队友金额 − 已计入的队友金额」算增量:请求偶发失败时当前值会掉回 0,
1063
+ 取 max(0, …) 就自动不回退(防闪照旧,且不会把队友的钱重复加)。 */
1064
+ const subCost = typeof snap.subCost === "number" && Number.isFinite(snap.subCost) ? snap.subCost : 0;
1065
+ const subSettled = typeof box.subCostSettled === "number" && Number.isFinite(box.subCostSettled)
1066
+ ? box.subCostSettled
1067
+ : 0;
1068
+ if (subCost > subSettled) {
1069
+ box.cost += subCost - subSettled;
1070
+ box.subCostSettled = subCost;
1071
+ box.priced = true;
1072
+ }
1073
+
1074
+ /* ---------- ⑤ token 三段:本会话 + 队友,只增不减 ---------- */
1075
+ const tokens = snap.tokens;
1076
+ if (tokens !== null && tokens !== undefined && typeof tokens === "object") {
1077
+ const had = box.tokens;
1078
+ box.tokens = had === null || had === undefined
1079
+ ? { cached: tokens.cached || 0, missInput: tokens.missInput || 0, output: tokens.output || 0 }
1080
+ : {
1081
+ cached: Math.max(had.cached, tokens.cached || 0),
1082
+ missInput: Math.max(had.missInput, tokens.missInput || 0),
1083
+ output: Math.max(had.output, tokens.output || 0)
1084
+ };
1085
+ }
1086
+ return {
1087
+ /* 欠账部分**不计入显示**:它还没有价,算进去只能是错的;补价后会自动结算进来 */
1088
+ cost: box.priced === true ? box.cost : null,
1089
+ tokens: box.tokens,
1090
+ hold: box
1091
+ };
1092
+ }
1093
+
1094
+ /** 本会话的令牌三段 + 队友的三段:同一个数字直接相加,不新增行。
1095
+ * 两段都容忍 null / undefined(当 0):「总计用量」要把官方投影 + 压缩摘要 + 队友
1096
+ * 三段加起来,其中任一段在宿主老版本 / 没数据时都可能是 null。 */
873
1097
  function mergeTokenParts(parts, sub) {
874
1098
  const base = parts === null || parts === undefined ? { cached: 0, missInput: 0, output: 0 } : parts;
1099
+ const extra = sub === null || sub === undefined ? { cached: 0, missInput: 0, output: 0 } : sub;
875
1100
  return {
876
- cached: base.cached + sub.cached,
877
- missInput: base.missInput + sub.missInput,
878
- output: base.output + sub.output
1101
+ cached: (base.cached || 0) + (extra.cached || 0),
1102
+ missInput: (base.missInput || 0) + (extra.missInput || 0),
1103
+ output: (base.output || 0) + (extra.output || 0)
879
1104
  };
880
1105
  }
881
1106
 
@@ -1049,10 +1274,14 @@ window.__ModuleLoader__.load({
1049
1274
  tipCountsSkills: "技能注入",
1050
1275
  tipCountsCompactAt: "上下文压缩",
1051
1276
  tipCountsToolCalls: "工具调用",
1277
+ /* 手动调用 / 手动压缩那行星号的悬停说明:星号自己 aria-hidden,含义靠这句文字给 */
1278
+ tipManual: "手动触发",
1052
1279
  /* 压缩明细逐条列时的两段:序号用中文序数(第一次 / 第二次),落点用「第X轮Y步」 */
1053
1280
  tipCompactItem: "第{index}次压缩",
1054
1281
  tipCompactWhere: "第{turn}轮{step}步",
1055
1282
  tipCompactTurnOnly: "第{turn}轮",
1283
+ /* 手动 / 轮间压缩没有 turn,用「发生在第几轮之后」给落点(afterTurn) */
1284
+ tipCompactAfter: "第{turn}轮后",
1056
1285
  contextAria: "上下文已用 {percent}",
1057
1286
  contextSystem: "系统提示词",
1058
1287
  contextTools: "工具定义",
@@ -1067,6 +1296,15 @@ window.__ModuleLoader__.load({
1067
1296
  f_lastCost: "本轮 {symbol}{cost}",
1068
1297
  f_balance: "余额 {symbol}{amount}",
1069
1298
  f_balanceSubCent: "余额 <{symbol}0.01",
1299
+ /* WorkBuddy 积分口径的套餐气泡(只在宿主给了非空 rewards 时出现)。
1300
+ 标题用中性词「积分套餐」:列表里第一条是月度基础包,不都是「平台奖励」。 */
1301
+ tipCreditsTitle: "积分套餐",
1302
+ tipCreditsMonthly: "(每月刷新)",
1303
+ tipCreditsRefresh: " · {date} 刷新",
1304
+ tipCreditsExpire: " · {date} 到期",
1305
+ tipCreditsRefreshLatest: " · {date} 刷新(最近)",
1306
+ tipCreditsExpireLatest: " · {date} 到期(最近)",
1307
+ tipCreditsUsed: "已用 {used} / {total}",
1070
1308
  dragHint: "拖动排序",
1071
1309
  secSegments: "统计字段",
1072
1310
  secSegmentsHint: "勾选显示;按住拖动调整顺序。",
@@ -1083,7 +1321,7 @@ window.__ModuleLoader__.load({
1083
1321
  segTpsHint: "token 的每秒平均输出速度。",
1084
1322
  segSessionTimeHint: "当前会话的总计运行时间。",
1085
1323
  segCostHint: "当前会话估算消耗费用,包含主模型、子代理和辅助调用。",
1086
- segLastCostHint: "当前或最近一轮交流的估算消耗费用。",
1324
+ segLastCostHint: "当前或最近一轮交流的估算消耗费用,包含主模型、子代理和辅助调用。",
1087
1325
  segBalanceHint: "账户余额,每分钟刷新。",
1088
1326
  priceCurrent: "当前会话使用:",
1089
1327
  modelUnknown: "未识别",
@@ -1163,10 +1401,13 @@ window.__ModuleLoader__.load({
1163
1401
  tipCountsSkills: "Skills injected",
1164
1402
  tipCountsCompactAt: "Context compactions",
1165
1403
  tipCountsToolCalls: "Tool calls",
1404
+ /* 星号的悬停说明(见中文那份的同名键) */
1405
+ tipManual: "Manually invoked",
1166
1406
  /* 压缩明细逐条列时的两段:英文直接用阿拉伯数字,落点「Turn X·Step Y」 */
1167
1407
  tipCompactItem: "Compaction {index}",
1168
1408
  tipCompactWhere: "Turn {turn}·Step {step}",
1169
1409
  tipCompactTurnOnly: "Turn {turn}",
1410
+ tipCompactAfter: "After turn {turn}",
1170
1411
  contextAria: "{percent} of context used",
1171
1412
  contextSystem: "System prompt",
1172
1413
  contextTools: "Tool definitions",
@@ -1180,6 +1421,14 @@ window.__ModuleLoader__.load({
1180
1421
  f_lastCost: "This turn {symbol}{cost}",
1181
1422
  f_balance: "Balance {symbol}{amount}",
1182
1423
  f_balanceSubCent: "Balance <{symbol}0.01",
1424
+ /* 积分口径的套餐气泡(见中文那份的同名键) */
1425
+ tipCreditsTitle: "Points packages",
1426
+ tipCreditsMonthly: " (monthly)",
1427
+ tipCreditsRefresh: " · refreshes {date}",
1428
+ tipCreditsExpire: " · expires {date}",
1429
+ tipCreditsRefreshLatest: " · refreshes {date} (nearest)",
1430
+ tipCreditsExpireLatest: " · expires {date} (nearest)",
1431
+ tipCreditsUsed: "Used {used} / {total}",
1183
1432
  dragHint: "Drag to reorder",
1184
1433
  secSegments: "Stats fields",
1185
1434
  secSegmentsHint: "Select to show; drag to reorder.",
@@ -1196,7 +1445,7 @@ window.__ModuleLoader__.load({
1196
1445
  segTpsHint: "Average output rate, in tokens per second.",
1197
1446
  segSessionTimeHint: "Total run time of the current session.",
1198
1447
  segCostHint: "Estimated session cost, including the main model, subagents, and helper calls.",
1199
- segLastCostHint: "Estimated cost of the current or most recent turn.",
1448
+ segLastCostHint: "Estimated cost of the current or most recent turn, including the main model, subagents, and helper calls.",
1200
1449
  segBalanceHint: "Account balance, refreshed every minute.",
1201
1450
  priceCurrent: "Current session:",
1202
1451
  modelUnknown: "unknown",
@@ -1726,14 +1975,21 @@ window.__ModuleLoader__.load({
1726
1975
  };
1727
1976
  }
1728
1977
 
1729
- /** 官方那种分段小条(分项面板里那条)。 */
1978
+ /** 官方那种分段小条(分项面板里那条)。子代理「命中」那段挂 dsb-bar-sub(75% 浅灰)。 */
1730
1979
  function meterBar(meter) {
1731
1980
  return h("span", { className: "dsb-bar", style: barTrackStyle(meter.compact) },
1732
- meter.segments.map((segment, index) => h("span", {
1733
- className: "dsb-bar-seg",
1734
- key: "__bar" + index,
1735
- style: { width: segment.width + "%", background: segment.color }
1736
- })));
1981
+ meter.segments.map((segment, index) => {
1982
+ const style = { width: segment.width + "%" };
1983
+ /* 浅色子段(子代理命中)交给 CSS 的 75% 灰:这里不写颜色,否则行内样式会盖掉 class 的背景。
1984
+ 必须用 background-color 长写:`background` 简写会把 background-clip 重置成 border-box,
1985
+ 段内那条「1px 透明右边框」的间隔就不生效了(见 CSS .dsb-bar-seg:not(:last-child))。 */
1986
+ if (segment.light !== true) style.backgroundColor = segment.color;
1987
+ return h("span", {
1988
+ className: segment.light === true ? "dsb-bar-seg dsb-bar-sub" : "dsb-bar-seg",
1989
+ key: "__bar" + index,
1990
+ style: style
1991
+ });
1992
+ }));
1737
1993
  }
1738
1994
 
1739
1995
  /**
@@ -1839,19 +2095,53 @@ window.__ModuleLoader__.load({
1839
2095
  };
1840
2096
  }
1841
2097
 
1842
- function tokenPanelView(cached, missInput, output, t) {
2098
+ /**
2099
+ * 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
2100
+ * 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
2101
+ * `sub` 是子代理(队友)的三段;有子代理用量时,条上拆成 6 小段:
2102
+ * **子代理块在左、主会话块在右**:[子·命中][子·未命中][子·输出][主·命中][主·未命中][主·输出]。
2103
+ * 子·命中用 75% 浅灰(与活跃总览高峰段同款),子·未命中 / 子·输出用正常主色;
2104
+ * **数字与行仍是合并口径**(主 + 子),不新增行。
2105
+ */
2106
+ function tokenPanelView(cached, missInput, output, t, sub) {
1843
2107
  const total = cached + missInput + output;
1844
2108
  if (total <= 0) return null;
2109
+ const subParts = sub === null || sub === undefined
2110
+ ? { cached: 0, missInput: 0, output: 0 }
2111
+ : {
2112
+ cached: Math.max(0, Number(sub.cached) || 0),
2113
+ missInput: Math.max(0, Number(sub.missInput) || 0),
2114
+ output: Math.max(0, Number(sub.output) || 0)
2115
+ };
1845
2116
  const parts = [
1846
- { label: t("f_tokensHit"), color: "var(--dsw-static-neutral-bluish-400)", value: cached },
1847
- { label: t("f_tokensMiss"), color: "#a78bfa", value: missInput },
1848
- { label: t("f_tokensOut"), color: "var(--dsw-static-blue-450)", value: output }
2117
+ /* lightSub:只有「命中」那一档的子代理段用 75% 浅灰,其余子段用主色 */
2118
+ { label: t("f_tokensHit"), color: "var(--dsw-static-neutral-bluish-400)", value: cached, sub: subParts.cached, lightSub: true },
2119
+ { label: t("f_tokensMiss"), color: "#a78bfa", value: missInput, sub: subParts.missInput },
2120
+ { label: t("f_tokensOut"), color: "var(--dsw-static-blue-450)", value: output, sub: subParts.output }
1849
2121
  ];
2122
+ /* 段顺序(用户拍板):**子代理块在左、主会话块在右**:
2123
+ 子·命中 → 子·未命中 → 子·输出 → 主·命中 → 主·未命中 → 主·输出。
2124
+ 主段 = 合并值 - 子值,子段 = 子值;哪边是 0 就不画那一段。
2125
+ 宽度仍是占总量的比例,所以 6 小段合起来仍顶满 100%。
2126
+ 子代理命中段标 light(用活跃总览高峰段同款 75% 灰,见 CSS .dsb-bar-sub);
2127
+ 子代理未命中 / 输出不浅化,用正常主色。 */
2128
+ const segments = [];
2129
+ parts.forEach((one) => {
2130
+ const subValue = Math.min(one.value, one.sub);
2131
+ if (subValue > 0) {
2132
+ const segment = { color: one.color, width: (subValue / total) * 100, sub: true };
2133
+ if (one.lightSub === true) segment.light = true;
2134
+ segments.push(segment);
2135
+ }
2136
+ });
2137
+ parts.forEach((one) => {
2138
+ const subValue = Math.min(one.value, one.sub);
2139
+ const mainValue = Math.max(0, one.value - subValue);
2140
+ if (mainValue > 0) segments.push({ color: one.color, width: (mainValue / total) * 100 });
2141
+ });
1850
2142
  return {
1851
2143
  totalText: formatTokensExact(total),
1852
- segments: parts
1853
- .map((one) => ({ color: one.color, width: (one.value / total) * 100 }))
1854
- .filter((one) => one.width > 0),
2144
+ segments: segments,
1855
2145
  rows: parts.map((one) => ({
1856
2146
  label: one.label,
1857
2147
  color: one.color,
@@ -1877,6 +2167,102 @@ window.__ModuleLoader__.load({
1877
2167
  ]);
1878
2168
  }
1879
2169
 
2170
+ /**
2171
+ * 积分套餐面板(WorkBuddy):标题 + 总积分,下面每个套餐一个块:
2172
+ * 第一行 = 包名/备注 + 「已用 X / 总数」;第二行 = 一条进度。
2173
+ * 进度方向:**已用从左侧起**(剩余在右)—— 两段实现:前段灰(已用宽 = percent,
2174
+ * 颜色同「充值余额」图例 BALANCE_COLORS.topUp)、后段透明(剩余宽 = 100 - percent,露出轨道底色)。
2175
+ * `.dsb-bar` 始终渲染:percent=0 时只有空轨道、没有填充段(与已用套餐视觉一致)。
2176
+ * **聚合行(×N)可点**:点进去是子视图 —— 内容与主面板同一套套餐块(一行 + 一条进度),
2177
+ * 只是每个块代表该组合并前的一个原始包(自己的名字/日期备注、真实已用/总数),竖排单列可滚动;
2178
+ * 标题只显示聚合的显示名(不带 ×N),右侧不再重复总积分。点标题返回。
2179
+ * 颜色与轨道高度/圆角沿用令牌/配额条那套 `.dsb-bar`;列表容器带半屏高度上限可滚动
2180
+ * (滚动条隐藏,见 CSS),14 条套餐也不会把气泡撑出屏幕。没有新造颜色或新字号。
2181
+ */
2182
+ function creditsPanel(panel, title, pos, ref, sub) {
2183
+ const openedAt = sub === undefined || sub === null || typeof sub.id !== "number" ? null : sub.id;
2184
+ const opened = openedAt === null || panel.rewards[openedAt] === undefined ? null : panel.rewards[openedAt];
2185
+ /* 一个套餐块 = 一行(名字/备注 + 已用/总数)+ 它自己的一条进度。
2186
+ 主列表与下钻子视图共用这一份,别各写一遍。 */
2187
+ const barOf = (one, key) => {
2188
+ const used = Math.max(0, Math.min(100, one.percent));
2189
+ /* 已用在左(灰段)、剩余在右(透明段);100% 只画整条灰;0% 只有空轨道 */
2190
+ const segments = [];
2191
+ if (used >= 100) {
2192
+ segments.push(h("span", {
2193
+ className: "dsb-bar-seg", key: key + "a",
2194
+ style: { width: "100%", backgroundColor: BALANCE_COLORS.topUp }
2195
+ }));
2196
+ } else if (used > 0) {
2197
+ segments.push(h("span", {
2198
+ className: "dsb-bar-seg", key: key + "a",
2199
+ style: { width: used + "%", backgroundColor: BALANCE_COLORS.topUp }
2200
+ }));
2201
+ segments.push(h("span", {
2202
+ className: "dsb-bar-seg", key: key + "b",
2203
+ style: { width: (100 - used) + "%", backgroundColor: "transparent" }
2204
+ }));
2205
+ }
2206
+ return h("span", { className: "dsb-bar", key: key + "bar" }, segments);
2207
+ };
2208
+ const creditBlock = (one, index, clickable) => {
2209
+ const base = "__cr" + index;
2210
+ const rowNode = h("span", {
2211
+ /* 聚合行用独立修饰类:只给 cursor:pointer,不要 .dsb-meter-row-click 的悬停灰底胶囊
2212
+ (那是「会话数据」那类行才有的反馈,套餐行只要可点感) */
2213
+ className: clickable ? "dsb-meter-row dsb-meter-row-plain" : "dsb-meter-row",
2214
+ key: base + "r",
2215
+ onClick: clickable
2216
+ ? (event) => {
2217
+ stopTipClick(event);
2218
+ if (sub.open !== undefined) sub.open(index);
2219
+ }
2220
+ : undefined
2221
+ }, [
2222
+ h("span", { className: "dsb-meter-name", key: base + "n" }, one.label),
2223
+ h("span", { className: "dsb-meter-value", key: base + "v" }, one.valueText)
2224
+ ]);
2225
+ return h("span", { className: "dsb-credit", key: base }, [rowNode, barOf(one, base)]);
2226
+ };
2227
+ let body = null;
2228
+ if (opened === null) {
2229
+ body = h("span", { className: "dsb-meter-rows dsb-meter-rows-fit dsb-credits-list", key: "__clist" },
2230
+ panel.rewards.map((one, index) => creditBlock(one, index, one.count > 1)));
2231
+ } else {
2232
+ /* 子视图:每个原始包一块(与主面板同样的行 + 进度条),竖排单列、可滚动 */
2233
+ body = h("span", { className: "dsb-meter-rows dsb-meter-rows-fit dsb-credits-list", key: "__clist" },
2234
+ opened.items.map((one, index) => creditBlock(one, index, false)));
2235
+ }
2236
+ const headline = opened === null
2237
+ ? h("span", { className: "dsb-meter-headline", key: "__ctl" }, title)
2238
+ : h("span", {
2239
+ /* 点标题回上一层:与 rowsPanel 子视图同一套交互。
2240
+ 标题显示聚合行的名字与数量(如「运营裂变包×12」);右侧是**该组剩余积分**
2241
+ (total - used,动态)。 */
2242
+ className: "dsb-meter-headline dsb-meter-back",
2243
+ key: "__ctl",
2244
+ onClick: (event) => {
2245
+ stopTipClick(event);
2246
+ if (sub.back !== undefined) sub.back();
2247
+ }
2248
+ }, opened.count > 1 ? opened.name + "×" + opened.count : opened.name);
2249
+ return h("span", {
2250
+ className: "dsb-tip dsb-meter",
2251
+ key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick
2252
+ }, [
2253
+ h("span", { className: "dsb-meter-head", key: "__ch" }, [
2254
+ headline,
2255
+ /* 主面板右侧是全局总积分;下钻子视图换成该组剩余积分(不显示组总容量) */
2256
+ h("span", { className: "dsb-meter-figures", key: "__ctt" },
2257
+ opened === null ? panel.totalText : opened.remainingText)
2258
+ ]),
2259
+ /* 与其它 rows 气泡一致:标题下一条横杠(复用 .dsb-meter-divider,不新增 CSS)。
2260
+ 只有 credits 面板加;tokenPanel 那种自带分段条的不加。 */
2261
+ h("span", { className: "dsb-meter-divider", key: "__cdiv" }),
2262
+ body
2263
+ ]);
2264
+ }
2265
+
1880
2266
  /* ------------------------------------------------------ 活跃总览柱状图 */
1881
2267
  /** 柱状图用的三色:与令牌面板的三段同色(命中缓存=灰 / 未命中=紫 / 输出=蓝)。 */
1882
2268
  const WEEK_COLORS = {
@@ -1957,6 +2343,73 @@ window.__ModuleLoader__.load({
1957
2343
  ]);
1958
2344
  }
1959
2345
 
2346
+ /** 悬停文字与「工具调用」标题之间至少留 1ch(11px 字号下约 7px)。 */
2347
+ const HOVER_PLAIN_TITLE_GAP = 7;
2348
+
2349
+ /**
2350
+ * 工具彩条悬停文字的**水平夹取范围**(用户拍板):
2351
+ * 左 = 「工具调用」标题四个字的右边界 **再往右 1ch**(至少离标题 1ch,不能压到标题上);
2352
+ * 右 = **色条本身**(`.dsb-bar`)的右边界,不能超出色条;再与视口右缘取小。
2353
+ * 色条拿不到时退回气泡右边界;两者都没有就只用视口。
2354
+ * 纯函数,便于单测;返回 { min, max },max 是文字右边界能到的最远位置。
2355
+ */
2356
+ function plainBoundsOf(headBox, barBox, viewportWidth) {
2357
+ const viewport = typeof viewportWidth === "number" && viewportWidth > 0 ? viewportWidth : 0;
2358
+ const barRight = barBox !== null && barBox !== undefined && typeof barBox.right === "number"
2359
+ ? barBox.right
2360
+ : null;
2361
+ const titleRight = headBox !== null && headBox !== undefined && typeof headBox.right === "number"
2362
+ ? headBox.right
2363
+ : null;
2364
+ let min = 8;
2365
+ if (titleRight !== null) min = Math.max(8, titleRight + HOVER_PLAIN_TITLE_GAP);
2366
+ let max = barRight !== null ? barRight : (viewport > 0 ? viewport - 8 : null);
2367
+ if (max === null) return null;
2368
+ if (viewport > 0) max = Math.min(max, viewport - 8);
2369
+ if (max < min) return null;
2370
+ return { min: min, max: max };
2371
+ }
2372
+
2373
+ /**
2374
+ * 把文字提示的 left 夹进 plainBoundsOf 给的范围(夹不下就贴左边界);没有范围只夹视口。
2375
+ */
2376
+ function clampPlainLeft(centered, width, bounds, viewportWidth) {
2377
+ const viewport = typeof viewportWidth === "number" && viewportWidth > 0 ? viewportWidth : 0;
2378
+ const viewMin = 8;
2379
+ const viewMax = viewport > 0 ? viewport - width - 8 : null;
2380
+ let min = viewMin;
2381
+ let max = viewMax;
2382
+ if (bounds !== null && bounds !== undefined) {
2383
+ min = Math.max(min, bounds.min);
2384
+ if (max === null || bounds.max - width < max) max = bounds.max - width;
2385
+ }
2386
+ if (max === null) return Math.max(min, centered);
2387
+ return Math.max(min, Math.min(centered, max));
2388
+ }
2389
+
2390
+ /**
2391
+ * 纯文字提示在「可用区间比文字还窄」时的缩放比例(用户拍板:**特例才缩,一般情况不缩**)。
2392
+ * 可用区间 = plainBoundsOf 给的 min~max(再与视口夹);没有 bounds 就退回视口两侧各留 8px。
2393
+ * 返回 1 = 不缩放;< 1 = 需要等比缩小。
2394
+ * 下限 0.5:11px 缩到 5.5px 已经是能辨认的极限,再小不如不显示。
2395
+ */
2396
+ function plainFitScale(textWidth, bounds, viewportWidth) {
2397
+ if (typeof textWidth !== "number" || Number.isFinite(textWidth) !== true || textWidth <= 0) return 1;
2398
+ const viewport = typeof viewportWidth === "number" && viewportWidth > 0 ? viewportWidth : 0;
2399
+ let min = 8;
2400
+ let max = viewport > 0 ? viewport - 8 : null;
2401
+ if (bounds !== null && bounds !== undefined) {
2402
+ min = Math.max(min, bounds.min);
2403
+ if (max === null || bounds.max < max) max = bounds.max;
2404
+ }
2405
+ if (max === null) return 1;
2406
+ const available = max - min;
2407
+ /* 区间本身就是负的(标题右边界已经越过色条右边):没有可用的横向空间,不缩也无处可放 */
2408
+ if (available <= 0) return 1;
2409
+ if (textWidth <= available) return 1;
2410
+ return Math.max(0.5, available / textWidth);
2411
+ }
2412
+
1960
2413
  /**
1961
2414
  * 悬停浮层的内容:日期(或周区间)+ 三项总计 → 命中/未命中/输出三行 → API 请求次数 → 消费金额。
1962
2415
  * 六行全部走「名称左、数值右」的双边对齐;数量精确到个位并带千分位,不做缩写。
@@ -2205,9 +2658,7 @@ window.__ModuleLoader__.load({
2205
2658
  style: panelStyle(pos),
2206
2659
  /* 吃下鼠标事件:不穿透到下面,也不冒泡回去把气泡关掉 */
2207
2660
  onClick: stopTipClick,
2208
- onMouseDown: stopTipClick,
2209
- /* 诊断:定位回调把「模式 + 基准 + 结果」写在这里(不可见),同时会回传给 host 落盘 */
2210
- "data-dsb-align": pos === null || pos === undefined || pos.align === undefined ? undefined : pos.align
2661
+ onMouseDown: stopTipClick
2211
2662
  }, [
2212
2663
  h("span", { className: "dsb-meter-head", key: "__wh" }, [
2213
2664
  h("span", { className: "dsb-meter-headline", key: "__wt" }, tip.title)
@@ -2222,22 +2673,72 @@ window.__ModuleLoader__.load({
2222
2673
  }
2223
2674
 
2224
2675
  /**
2225
- * 长列表分列:**每一竖列最多三行**,超过就多开一竖列(列数不设上限,
2226
- * 放不下时由 CSS 折到下一排,整体高度另有屏幕半高的兜底上限)。
2676
+ * 长列表分列:**每一竖列最多三行**,超过就多开一竖列(列数不设上限)。
2677
+ * 列网格是 `flex-wrap:nowrap`(用户拍板:**绝不折行**),一排放不下就横向滚动;
2678
+ * 在那之前先**逐档加每列行数**(3 → 4 → 5 …,上限见 LIST_ROWS_PER_COLUMN_MAX)——
2679
+ * 行数一加,列数就变少,放得下的机会更大,而且气泡宽度会跟着收窄(用户称"理解 B")。
2227
2680
  */
2228
2681
  const LIST_ROWS_PER_COLUMN = 3;
2229
- function listColumns(rows) {
2230
- const size = LIST_ROWS_PER_COLUMN;
2682
+ /* 工具调用每列行数的上限:再往上加会让气泡高得离谱,12 行(约 264px)够收住绝大多数工具数。
2683
+ 12 行仍放不下才真正进入横向滚动(此时列网格 overflow-x 生效)。 */
2684
+ const LIST_ROWS_PER_COLUMN_MAX = 12;
2685
+ function listColumns(rows, rowsPerColumn) {
2686
+ const size = rowsPerColumn === undefined || rowsPerColumn === null ? LIST_ROWS_PER_COLUMN : rowsPerColumn;
2231
2687
  const out = [];
2232
2688
  for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size));
2233
2689
  return out;
2234
2690
  }
2235
2691
 
2692
+ /**
2693
+ * 工具调用子视图「每列行数」的下一档:放得下就保持;放不下且还没到上限就 +1。
2694
+ * 只升不降 —— 一放不下就升、升过头也不回退,避免行数在两档之间来回跳。
2695
+ * 到 12 行都还放不下就停在这里,由列网格的横向滚动兜底(不再折行)。
2696
+ * 纯函数,方便单测(4→5、上限 12 这两条边界都由它钉住)。
2697
+ * 第二参按"这一排放不下"传(历史上叫 wrapped,折行已不可能发生,语义见 toolColumnsOverflow)。
2698
+ */
2699
+ function nextToolRows(current, wrapped) {
2700
+ if (wrapped !== true) return current;
2701
+ return current < LIST_ROWS_PER_COLUMN_MAX ? current + 1 : current;
2702
+ }
2703
+
2704
+ /**
2705
+ * 工具调用子视图的列网格**是否横向放不下**:放不下返回 true,交给 nextToolRows 逐档加行
2706
+ * (3 → 4 → 5 …,行数加上去列数就变少,直到一排放得下)。
2707
+ *
2708
+ * 为什么不是「量各列 top 判断折行」:列网格已改成 `flex-wrap:nowrap`(用户拍板:
2709
+ * **绝不能折行**),折行在任何情况下都不会发生 —— 再按 top 判会恒为 false,
2710
+ * 逐档加行就彻底失效了。改成量**内容宽度 vs 容器宽度**(scrollWidth > clientWidth),
2711
+ * 这才是 nowrap 下"放不下"的唯一表现,也是加行的原始动机。
2712
+ *
2713
+ * 判据用 `1px` 容差:scrollWidth / clientWidth 都是整数,亚像素取整可能差 1px,
2714
+ * 差 1px 不该被当成溢出,否则会白加一档行数。真溢出时通常差几十上百像素。
2715
+ *
2716
+ * 只有工具调用那个子视图(带 dsb-meter-tight)才换行数,别的子视图不参与。
2717
+ * 拿不到 DOM 几何(测试替身 / 面板还没挂上)时一律当"放得下",避免在非浏览器环境里误判。
2718
+ */
2719
+ function toolColumnsOverflow(panel) {
2720
+ if (panel === null || panel === undefined) return false;
2721
+ if (typeof panel.querySelector !== "function" || typeof panel.querySelectorAll !== "function") return false;
2722
+ if (panel.querySelector(".dsb-meter-tight") === null) return false;
2723
+ const grids = panel.querySelectorAll(".dsb-meter-cols");
2724
+ if (grids === null || grids === undefined) return false;
2725
+ for (let g = 0; g < grids.length; g += 1) {
2726
+ const node = grids[g];
2727
+ if (node === null || node === undefined) continue;
2728
+ const scrollWidth = typeof node.scrollWidth === "number" ? node.scrollWidth : null;
2729
+ const clientWidth = typeof node.clientWidth === "number" ? node.clientWidth : null;
2730
+ if (scrollWidth === null || clientWidth === null) continue;
2731
+ if (clientWidth <= 0) continue;
2732
+ if (scrollWidth - clientWidth > 1) return true;
2733
+ }
2734
+ return false;
2735
+ }
2736
+
2236
2737
  /**
2237
2738
  * 两列面板:标题 + 主数值,下面若干「名称 / 数值」行(用时、极值、命中率、余额、会话数据这类段用)。
2238
2739
  * 行上带 sub 的(会话数据那三行)点一下就把整个气泡换成子视图:标题变成子视图名、内容换成列表,
2239
2740
  * 子视图沿用主气泡的字号字色(不是二次气泡),点标题回到上一层。
2240
- * 子视图的列表每列最多三行、超了往右加列;只有名字的行(技能注入)不占数值那一格。
2741
+ * 子视图的列表每列最多三行、超了往右加列(工具调用那档按量测逐档加到 4、5 … 直到不折行)。
2241
2742
  */
2242
2743
  function rowsPanel(tip, pos, ref, sub) {
2243
2744
  const rows = tip.rows === undefined ? [] : tip.rows;
@@ -2246,7 +2747,8 @@ window.__ModuleLoader__.load({
2246
2747
  ? null
2247
2748
  : rows[openedAt].sub;
2248
2749
  /* 一行里的三块:色块(可选,与上面那条色条一一对应)+ 名称 + 数值。
2249
- 没有数值的行(技能注入的子视图)只出名字,不占右边那一格,气泡宽度就是最长那个名字。
2750
+ 名称也可能是节点数组(手动调用 / 手动压缩的名字后要跟上标星号,压缩的星号还得夹在
2751
+ 序号后面 —— 拼成字符串就插不进去了),这里原样交给 React 当子节点。
2250
2752
  可点行与普通行共用这一份,别各写一遍。 */
2251
2753
  const parts = (row, key) => [
2252
2754
  row.color === undefined || row.color === null
@@ -2275,7 +2777,14 @@ window.__ModuleLoader__.load({
2275
2777
  }, parts(row, "__xr" + index));
2276
2778
  }));
2277
2779
  } else {
2278
- const columns = listColumns(opened.rows);
2780
+ /* 每列几行:工具调用那档由 StatusBar 量完 DOM 后逐档加(3 → 4 → 5 …,最多 12),
2781
+ 其余子视图照旧 3。所有行统一走**一个**列网格 —— 工具名就是原生英文名,
2782
+ 不再有「中文/英文整行块」两套结构。 */
2783
+ const perColumn = opened.tight === true && sub !== undefined && sub !== null
2784
+ && typeof sub.rowsPerColumn === "number"
2785
+ ? sub.rowsPerColumn
2786
+ : LIST_ROWS_PER_COLUMN;
2787
+ const columns = listColumns(opened.rows, perColumn);
2279
2788
  wide = columns.length > 1;
2280
2789
  /* 一列时不套外层(与其它面板的行结构完全一致),分列才多一层;
2281
2790
  子视图带 tight 的(工具调用)另挂一个类,把名字与数值的间隙收紧 */
@@ -2289,7 +2798,11 @@ window.__ModuleLoader__.load({
2289
2798
  : h("span", { className: cls, key: "__xrows" },
2290
2799
  (columns[0] === undefined ? [] : columns[0]).map((row, index) => cell(row, "__r" + index)));
2291
2800
  }
2292
- const segments = Array.isArray(tip.segments) ? tip.segments : [];
2801
+ /* 标题下那条:根视图用 tip.segments(余额/用时那种),子视图用 opened.segments(工具调用彩条);
2802
+ 都没有就退回普通分隔线。彩条里的 title 只做悬浮提示,不加交互。 */
2803
+ const barSegments = opened === null
2804
+ ? (Array.isArray(tip.segments) ? tip.segments : [])
2805
+ : (Array.isArray(opened.segments) ? opened.segments : []);
2293
2806
  return h("span", {
2294
2807
  className: wide === true ? "dsb-tip dsb-meter dsb-meter-wide" : "dsb-tip dsb-meter",
2295
2808
  key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick
@@ -2310,12 +2823,42 @@ window.__ModuleLoader__.load({
2310
2823
  ? null
2311
2824
  : h("span", { className: "dsb-meter-figures", key: "__xhf" }, tip.head)
2312
2825
  ]),
2313
- /* 有分段数据就画色条(与令牌面板同一条),否则还是那条普通分隔线 */
2314
- segments.length > 0
2315
- ? h("span", { className: "dsb-bar", key: "__xbar" }, segments.map((one, index) => h("span", {
2316
- className: "dsb-bar-seg", key: "__xseg" + index,
2317
- style: { width: one.width + "%", background: one.color }
2318
- })))
2826
+ /* 有分段数据就画色条(与令牌面板同一条),否则还是那条普通分隔线。
2827
+ 子视图的彩条(工具调用)每段挂插件自己的悬停处理:**只显示一行纯文字**
2828
+ (工具名 + 占比),没有气泡、没有阴影(用户拍板)。 */
2829
+ barSegments.length > 0
2830
+ ? h("span", {
2831
+ /* 悬停放大的动效**只在工具调用这个下钻子视图**启用:opened 非空且是 tight 列表才挂
2832
+ dsb-bar-hoverable。根视图的分段条(余额/用时)与其它子视图一律是普通 .dsb-bar,
2833
+ 不参与任何悬停放大/过渡。 */
2834
+ className: opened !== null && opened.tight === true
2835
+ ? "dsb-bar dsb-bar-hoverable"
2836
+ : "dsb-bar",
2837
+ key: "__xbar"
2838
+ }, barSegments.map((one, index) => {
2839
+ const segProps = {
2840
+ className: "dsb-bar-seg",
2841
+ key: "__xseg" + index,
2842
+ /* background-color 长写(不是 background 简写):简写会把 background-clip 重置成
2843
+ border-box,段内 1px 透明右边框的分隔就失效了 */
2844
+ style: { width: one.width + "%", backgroundColor: one.color }
2845
+ };
2846
+ if (opened !== null && sub !== undefined && sub !== null && typeof sub.hover === "function"
2847
+ && one.name !== undefined) {
2848
+ /* 用户拍板:不要气泡,只显示一行文字(工具名 + 占比),贴在鼠标上方最近处 */
2849
+ segProps.onMouseEnter = (event) => sub.hover(event, {
2850
+ plain: one.name + " " + one.percentText,
2851
+ follow: true
2852
+ });
2853
+ /* 在同一个色段里移动时只更新文字位置,不重建内容(避免闪断) */
2854
+ segProps.onMouseMove = (event) => {
2855
+ if (typeof sub.hoverMove === "function") sub.hoverMove(event);
2856
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
2857
+ };
2858
+ segProps.onMouseLeave = () => sub.hover(null);
2859
+ }
2860
+ return h("span", segProps);
2861
+ }))
2319
2862
  : h("span", { className: "dsb-meter-divider", key: "__xdiv" }),
2320
2863
  body
2321
2864
  ]);
@@ -2341,9 +2884,35 @@ window.__ModuleLoader__.load({
2341
2884
  });
2342
2885
  }
2343
2886
 
2344
- /** 令牌面板的 tip。没有数据也给面板(三行横杠)—— 气泡必须任何情况下都点得开。 */
2345
- function tokensTip(parts, title, t) {
2346
- const panel = parts === null ? null : tokenPanelView(parts.cached, parts.missInput, parts.output, t);
2887
+ /**
2888
+ * 宿主投影里的「压缩摘要 token 累计」→ 与本面板同样的三段。
2889
+ *
2890
+ * 为什么要单独加这一份:**「总计费用」含摘要,「总计用量」原本不含** ——
2891
+ * 费用是我们自己逐条算的(`addCallTotals` 对 `compaction/summary` 也累加),
2892
+ * 而用量来自官方 `tokenUsage` 投影,官方只折 `assistant` 事件、不含压缩摘要。
2893
+ * 结果同一个气泡里两个数字口径不一致。用户拍板「官方投影继续用,只把摘要这部分
2894
+ * 额外加上」,这个函数就是那个「额外加上」的部分。
2895
+ *
2896
+ * 口径必须与 `billableParts` 一致:`input` + `cacheWrite` 都算未命中(missInput),
2897
+ * `cacheRead` 算命中,`output` 算输出 —— 否则三行明细与总计对不上。
2898
+ * 宿主老版本没有 `summaryParts`(或值为空)时返回 null,调用方跳过即可。
2899
+ */
2900
+ function summaryTokenParts(summaryParts) {
2901
+ if (summaryParts === undefined || summaryParts === null || typeof summaryParts !== "object") return null;
2902
+ return billableParts(summaryParts);
2903
+ }
2904
+
2905
+ /**
2906
+ * 令牌面板的 tip。没有数据也给面板(三行横杠)—— 气泡必须任何情况下都点得开。
2907
+ * `sub` 是子代理三段:条上会拆出浅色子段;`parts` 为 null(本会话还没上报用量)
2908
+ * 但有子代理数据时,用子代理的三段当合并值,不能退化成横杠(否则队友的量看不见)。
2909
+ */
2910
+ function tokensTip(parts, title, t, sub) {
2911
+ const subParts = sub === null || sub === undefined ? null : sub;
2912
+ const merged = parts === null || parts === undefined
2913
+ ? (subParts === null ? null : { cached: subParts.cached, missInput: subParts.missInput, output: subParts.output })
2914
+ : parts;
2915
+ const panel = merged === null ? null : tokenPanelView(merged.cached, merged.missInput, merged.output, t, subParts);
2347
2916
  return { kind: "tokens", title: title, panel: panel === null ? dashTokenPanel(t) : panel };
2348
2917
  }
2349
2918
 
@@ -2440,109 +3009,152 @@ window.__ModuleLoader__.load({
2440
3009
  return head + (rest < 10 ? "零" + DIGITS[rest] : under100(rest));
2441
3010
  }
2442
3011
 
3012
+ /**
3013
+ * 手动调用 / 手动压缩那一项的星号:做成**上标**(像 X² 的 ²),不是普通的星号字符。
3014
+ * 读屏要能听到「手动触发」:span 默认是 generic 角色、aria-label 会被忽略,
3015
+ * 所以给它 img 角色当纯图形标注,视觉里仍然只有那个 *。
3016
+ * title 留给鼠标悬停;中英界面都要显示,所以由调用方传当前语言的 t 进来。
3017
+ */
3018
+ function manualStar(t) {
3019
+ /* 只有读屏名称,**不挂 title**:星号只有 11px,鼠标几乎落不到它上面,
3020
+ 留一个原生悬停气泡只会是"偶尔冒出来一下"的意外行为(用户明确不要)。
3021
+ aria-label 保留 —— 它只服务屏幕阅读器,不产生任何可见元素。 */
3022
+ return h("span", {
3023
+ className: "dsb-sup",
3024
+ key: "__star",
3025
+ role: "img",
3026
+ "aria-label": t("tipManual")
3027
+ }, "*");
3028
+ }
3029
+
2443
3030
  /** 子视图没有内容时给一行横杠:气泡任何情况下都要打得开,也不能是一片空白。 */
2444
3031
  function emptyList(title) {
2445
3032
  return { title: title, rows: [{ label: title, value: "-" }] };
2446
3033
  }
2447
3034
 
2448
- /** 技能注入子视图:只列名字,不跟数值(次数在上面那一行已经给了)。
2449
- 全局提示词(AGENTS.md 这类)排在最前,它也是「注入」的一种;
2450
- 其余技能按首次注入的先后,重复注入聚合成一行;气泡宽度就是最长那个名字。 */
3035
+ /**
3036
+ * 技能注入子视图:只列名字,**不显示统计次数**(用户明确要求:次数只用来排序)。
3037
+ * 同名注入在内部聚合计数,排序 = **名字短的在前** → 注入次数多的在前 → 字典序;
3038
+ * 次数只参与排序,不进界面(与工具调用「次」那套无关)。
3039
+ * 手动调用的技能(`manual: true`)名字后跟上标星号;只要其中有一次是手动就算。
3040
+ * 全局提示词(AGENTS.md 这类)**固定排第一**:它也是注入的一种,但不是技能调用,
3041
+ * 不参与上面那套技能排序;其余技能照常排。
3042
+ */
2451
3043
  function skillList(skills, globalInstruction, t) {
2452
3044
  const title = t("tipCountsSkills");
2453
3045
  const names = [];
2454
- if (typeof globalInstruction === "string" && globalInstruction.length > 0) names.push(globalInstruction);
2455
- if (skills !== null) {
2456
- skills.forEach((name) => {
2457
- const key = String(name);
2458
- if (names.indexOf(key) < 0) names.push(key);
3046
+ const counts = new Map();
3047
+ const manual = new Set();
3048
+ const pinned = typeof globalInstruction === "string" && globalInstruction.length > 0 ? globalInstruction : null;
3049
+ const add = (name, isManual) => {
3050
+ const key = String(name);
3051
+ if (key.length === 0) return;
3052
+ if (counts.has(key) !== true) {
3053
+ counts.set(key, 0);
3054
+ names.push(key);
3055
+ }
3056
+ counts.set(key, counts.get(key) + 1);
3057
+ if (isManual === true) manual.add(key);
3058
+ };
3059
+ if (pinned !== null) add(pinned, false);
3060
+ if (Array.isArray(skills)) {
3061
+ skills.forEach((entry) => {
3062
+ /* 契约是 `{ name, manual }`;旧宿主(还没升级的那一半)只给字符串,兼容一下 */
3063
+ if (typeof entry === "string") {
3064
+ add(entry, false);
3065
+ return;
3066
+ }
3067
+ if (entry === null || entry === undefined || typeof entry.name !== "string") return;
3068
+ add(entry.name, entry.manual === true);
2459
3069
  });
2460
3070
  }
2461
3071
  if (names.length === 0) return emptyList(title);
2462
- return { title: title, rows: names.map((name) => ({ label: name })) };
3072
+ const rest = names.filter((name) => name !== pinned);
3073
+ rest.sort((a, b) => a.length - b.length
3074
+ || counts.get(b) - counts.get(a)
3075
+ || (a < b ? -1 : (a > b ? 1 : 0)));
3076
+ const ordered = pinned !== null && counts.has(pinned) ? [pinned].concat(rest) : rest;
3077
+ /* 行上只有名字(手动调用的带行尾星号),不带 value —— 次数只用于上面的排序 */
3078
+ return {
3079
+ title: title,
3080
+ rows: ordered.map((name) => ({
3081
+ label: manual.has(name) === true ? [name, manualStar(t)] : name
3082
+ }))
3083
+ };
2463
3084
  }
2464
3085
 
3086
+ /* 工具调用分段条的 32 色(按 01→32 顺序使用、浅→浓,每 8 个一行)。
3087
+ 颜色跟着「显示顺序」走:第 1 个工具用第 1 色,超过 32 个循环回第 1 色。 */
3088
+ const TOOL_BAR_COLORS = [
3089
+ "#FFBFD1", "#FFC5AB", "#F4D06A", "#B0E693", "#72ECD6", "#93E0FF", "#C3D2FF", "#EBC1FF",
3090
+ "#FFA29E", "#F5AE60", "#C0C762", "#77D6A1", "#57D4DE", "#87C5FF", "#C0B3FC", "#EDA3D8",
3091
+ "#EB9067", "#CAA63E", "#88BC6B", "#42C2AD", "#54B9DD", "#91A7EC", "#C496D9", "#E68BA8",
3092
+ "#CA8634", "#989E36", "#4DAD79", "#1BAAB4", "#609CD4", "#998BD1", "#C27BAF", "#D67774"
3093
+ ];
3094
+
2465
3095
  /**
2466
- * 工具名的中文显示名。工具名本身是会话日志里的键(英文标识),这里换成读者看得懂的词;
2467
- * **没列到的工具照原样显示** —— 插件工具、MCP 工具随时可能出现,不能因为查不到就留白。
2468
- * 只在中文界面用;英文界面直接用原名。
3096
+ * 「工具调用」子视图:直接显示**原生工具名**(不翻译,用户拍板:翻不准就用原名)。
3097
+ * **先按名字长度升序(短的在前),同长再按调用次数降序,最后字典序**;
3098
+ * 同名(同名原始名)合并计数 —— 不同工具天然不同名,不会被合并。
3099
+ * 名字短、列多,标 tight 让名字与次数之间的间隙收到 1ch。
3100
+ * 所有行统一走一个列网格:每列默认 3 行,折行时逐档 4→5…(上限 12,见 LIST_ROWS_PER_COLUMN_MAX;
3101
+ * LIST_ROWS_PER_COLUMN 是默认值 3,别把两者搞混)。
3102
+ * 同时给出 `segments`:标题下那条彩条,按上面的显示顺序取 32 色(第 33 个循环回第 1 色),
3103
+ * 宽度 = 该工具次数 ÷ 总次数;每段带 `name` / `percentText`(两位小数占比),给状态栏的悬停提示用。
2469
3104
  */
2470
- const TOOL_LABELS_ZH = {
2471
- pwsh: "命令行",
2472
- bash: "命令行",
2473
- read: "读取文件",
2474
- write: "写入文件",
2475
- edit: "修改文件",
2476
- glob: "查找文件",
2477
- grep: "搜索内容",
2478
- todo_write: "待办清单",
2479
- skill: "技能",
2480
- subagent: "子智能体",
2481
- subagent_fork: "子智能体(继承)",
2482
- ask_user_question: "询问用户",
2483
- create_goal: "新建目标",
2484
- update_goal: "更新目标",
2485
- get_goal: "查看目标",
2486
- present: "展示文件",
2487
- web_search: "联网搜索",
2488
- web_fetch: "抓取网页",
2489
- job_list: "后台任务列表",
2490
- job_output: "后台任务输出",
2491
- job_kill: "取消后台任务",
2492
- spawn_teammate: "新增队友",
2493
- send_message: "发送消息",
2494
- wait_agent: "等待队友",
2495
- interrupt_agent: "中断队友",
2496
- list_agents: "队友列表",
2497
- team_task_create: "新建任务",
2498
- team_task_list: "任务列表",
2499
- team_task_get: "查看任务",
2500
- team_task_update: "更新任务",
2501
- exit_plan_mode: "退出计划模式",
2502
- load_workspace_dependencies: "加载工作区依赖",
2503
- find_dsh_plugin: "查找插件",
2504
- workflow: "工作流"
2505
- };
2506
-
2507
- /** 工具名 → 显示名:中文界面查表(查不到给原名),英文界面直接用原名。 */
2508
- function toolLabel(name, t) {
2509
- if (detectLocale(t) !== "zh") return name;
2510
- const label = TOOL_LABELS_ZH[name];
2511
- return label === undefined ? name : label;
2512
- }
2513
-
2514
- /** 「工具调用」子视图:**先按显示名长短(短的在前),同名长短再按调用次数降序**。
2515
- 名字短、列多,标 tight 让名字与次数之间的间隙收到 1ch。 */
2516
3105
  function toolList(counts, t) {
2517
3106
  const title = t("tipCountsToolCalls");
2518
3107
  if (counts === null || typeof counts !== "object") return Object.assign(emptyList(title), { tight: true });
2519
- const rows = Object.keys(counts)
2520
- .map((name) => ({ label: toolLabel(name, t), count: Number(counts[name]) || 0 }))
3108
+ const entries = Object.keys(counts)
3109
+ .map((name) => ({ label: name, count: Number(counts[name]) || 0 }))
2521
3110
  .filter((one) => one.count > 0)
2522
3111
  .sort((a, b) => a.label.length - b.label.length
2523
3112
  || b.count - a.count
2524
- || (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)))
2525
- .map((one) => ({ label: one.label, value: formatTokensExact(one.count) + t("unitCountCalls") }));
2526
- if (rows.length === 0) return Object.assign(emptyList(title), { tight: true });
2527
- return { title: title, rows: rows, tight: true };
3113
+ || (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)));
3114
+ if (entries.length === 0) return Object.assign(emptyList(title), { tight: true });
3115
+ const total = entries.reduce((sum, one) => sum + one.count, 0);
3116
+ const rows = entries.map((one) => ({ label: one.label, value: formatTokensExact(one.count) + t("unitCountCalls") }));
3117
+ const segments = entries.map((one, index) => {
3118
+ const percent = (one.count / total) * 100;
3119
+ return {
3120
+ color: TOOL_BAR_COLORS[index % TOOL_BAR_COLORS.length],
3121
+ width: percent,
3122
+ /* 悬停浮层用:工具名 + 占比(两位小数,不带次数;浮层由 StatusBar 的 onHover 统一渲染) */
3123
+ name: one.label,
3124
+ percentText: percent.toFixed(2) + "%"
3125
+ };
3126
+ });
3127
+ return { title: title, rows: rows, tight: true, segments: segments };
2528
3128
  }
2529
3129
 
2530
- /** 「上下文压缩」子视图:逐条列出每次压缩落在第几轮第几步(顺序就是发生的顺序)。 */
3130
+ /**
3131
+ * 「上下文压缩」子视图:逐条列出每次压缩落在第几轮第几步(顺序就是发生的顺序)。
3132
+ * host 已经滤掉失败的压缩,这里只按契约渲染。落点三档:
3133
+ * turn 是数字 → 「第X轮Y步」(step 为空时「第X轮」);
3134
+ * turn 为 null(手动 /compact 的轮间压缩)→ 用 afterTurn 显示「第X轮后」,
3135
+ * 否则手动压缩只剩一个横杠,用户看不出它发生在哪儿(2026-10 实测反馈);
3136
+ * afterTurn 也没有 → 「-」。
3137
+ * 手动压缩(`manual: true`)在**行尾**跟上标星号(「第二次压缩*」)—— 星号不插进序号里,
3138
+ * 免得把「第二次」拆开。
3139
+ */
2531
3140
  function compactList(compacts, t) {
2532
3141
  const title = t("tipCountsCompactAt");
2533
- if (compacts === null || compacts.length === 0) return emptyList(title);
3142
+ if (compacts === null || compacts === undefined || compacts.length === 0) return emptyList(title);
2534
3143
  const ordinal = detectLocale(t) === "zh";
2535
3144
  const rows = compacts.map((one, at) => {
2536
3145
  const index = at + 1;
2537
3146
  const word = ordinal === true ? chineseNumber(index) : null;
3147
+ const text = word === null ? String(index) : word;
2538
3148
  const mark = one === null || one === undefined ? {} : one;
2539
3149
  const turn = typeof mark.turn === "number" ? mark.turn : null;
2540
3150
  const step = typeof mark.step === "number" ? mark.step : null;
3151
+ const afterTurn = typeof mark.afterTurn === "number" ? mark.afterTurn : null;
3152
+ const label = t("tipCompactItem", { index: text });
2541
3153
  return {
2542
- label: t("tipCompactItem", { index: word === null ? String(index) : word }),
2543
- value: turn === null
2544
- ? "-"
2545
- : (step === null ? t("tipCompactTurnOnly", { turn: turn }) : t("tipCompactWhere", { turn: turn, step: step }))
3154
+ label: mark.manual === true ? [label, manualStar(t)] : label,
3155
+ value: turn !== null
3156
+ ? (step === null ? t("tipCompactTurnOnly", { turn: turn }) : t("tipCompactWhere", { turn: turn, step: step }))
3157
+ : (afterTurn === null ? "-" : t("tipCompactAfter", { turn: afterTurn }))
2546
3158
  };
2547
3159
  });
2548
3160
  return { title: title, rows: rows };
@@ -2553,6 +3165,150 @@ window.__ModuleLoader__.load({
2553
3165
  return typeof value === "string" && value.length > 0 ? value : "0.00";
2554
3166
  }
2555
3167
 
3168
+ /**
3169
+ * 积分数字(used / total)→ 显示文本:**保留两位小数、去掉无意义的尾零**。
3170
+ * 上游给的是原始 number,实测带浮点尾数(12.389999979999999、552.42999936),
3171
+ * 直接 String() 会显示一长串;这里四舍五入到分位:552.43 / 12.39 / 500。
3172
+ */
3173
+ function formatCredits(value) {
3174
+ const n = Number(value);
3175
+ if (Number.isFinite(n) !== true) return "0";
3176
+ return String(Math.round(n * 100) / 100);
3177
+ }
3178
+
3179
+ /**
3180
+ * 积分套餐的一行文字:包名 + 备注(月度刷新 / 每月刷新兜底 / 到期),不排序。
3181
+ * 聚合行(count > 1)名称带「×X」,日期换用「(最近)」版文案 —— 合并后只代表合计与最近到期日。
3182
+ */
3183
+ function creditsLabel(one, t) {
3184
+ const merged = typeof one.count === "number" && one.count > 1;
3185
+ let label = merged ? one.name + "×" + one.count : one.name;
3186
+ if (one.monthly === true) {
3187
+ /* 月度包:有刷新日期就给日期,没有才退回「每月刷新」 */
3188
+ if (typeof one.refreshAt === "string" && one.refreshAt.length >= 10) {
3189
+ label += t(merged ? "tipCreditsRefreshLatest" : "tipCreditsRefresh", { date: one.refreshAt.slice(5, 10) });
3190
+ } else {
3191
+ label += t("tipCreditsMonthly");
3192
+ }
3193
+ } else if (typeof one.expireAt === "string" && one.expireAt.length >= 10) {
3194
+ label += t(merged ? "tipCreditsExpireLatest" : "tipCreditsExpire", { date: one.expireAt.slice(5, 10) });
3195
+ }
3196
+ return label;
3197
+ }
3198
+
3199
+ /* 积分套餐名里的公共前缀:按顺序剥、只剥开头、可连续剥(例如
3200
+ CodeBuddy个人版国内运营裂变包 → 个人版国内运营裂变包 → 国内运营裂变包 → 运营裂变包)。
3201
+ 纯本地实现,不查任何外部数据:上游以后新增什么套餐名都照样能剥。 */
3202
+ const CREDITS_NAME_PREFIXES = ["CodeBuddy", "个人版", "国内"];
3203
+
3204
+ /**
3205
+ * 显示用的套餐名:**保持上游原名,只剥渠道前缀**(不翻译)。
3206
+ *
3207
+ * 为什么不翻译:国内站(region=cn)的包名上游就是中文、国际站(region=global)上游就是英文,
3208
+ * 本来就该原样显示。以前维护过一张双向映射表,但上游随时可能出新包
3209
+ * (如国际站的 `Launch Bonus`),表里没覆盖到就会漏翻、比不翻更糟 —— 所以整张表已删除。
3210
+ *
3211
+ * 剥前缀规则(纯本地、可连续剥、大小写不敏感、**只剥开头**):
3212
+ * ① 按 CREDITS_NAME_PREFIXES 顺序反复剥,直到一轮下来一个都没剥掉;
3213
+ * ② 全剥完是空串时保留原名 —— 不能给用户一行只有「· 11-01 到期」没有名字的东西;
3214
+ * ③ 前缀出现在中间(如 `我的CodeBuddy包`)不剥,因为只匹配开头。
3215
+ *
3216
+ * 入参 `t` 已不再使用(保留形参是为了不动调用方),但**不要**据此认为它有用。
3217
+ */
3218
+ function creditDisplayName(name) {
3219
+ let out = name;
3220
+ let stripped = true;
3221
+ while (stripped === true) {
3222
+ stripped = false;
3223
+ for (let i = 0; i < CREDITS_NAME_PREFIXES.length; i += 1) {
3224
+ const prefix = CREDITS_NAME_PREFIXES[i];
3225
+ if (out.toLowerCase().indexOf(prefix.toLowerCase()) === 0) {
3226
+ out = out.slice(prefix.length);
3227
+ stripped = true;
3228
+ break;
3229
+ }
3230
+ }
3231
+ }
3232
+ return out.length === 0 ? name : out;
3233
+ }
3234
+
3235
+ /** 两个 ISO 日期里取更早的那个(null 不参与);合并相邻同名包时「只显示最近到期日」。 */
3236
+ function earliestDate(a, b) {
3237
+ if (a === null || a === undefined) return b === undefined ? null : b;
3238
+ if (b === null || b === undefined) return a;
3239
+ return a <= b ? a : b;
3240
+ }
3241
+
3242
+ /**
3243
+ * 积分套餐面板数据:`{ title, totalText, rewards[] }`。
3244
+ * 每个套餐带上显示用字段(label / valueText / percent),渲染函数只负责画。
3245
+ * **相邻且显示名相同**的套餐合并成一行:used/total 相加、percent 按合计重算、count 记原条数;
3246
+ * 日期只取最近的一个(monthly 取最早 refreshAt,否则取最早 expireAt),不显示区间;
3247
+ * 顺序按首次出现位置。字段缺失(name 不是非空字符串)的套餐直接跳过。
3248
+ * percent = clamp(used / total × 100, 0, 100),total<=0 时给 0(画空轨道)。
3249
+ */
3250
+ function creditsView(rewards, totalText, t) {
3251
+ const list = Array.isArray(rewards) ? rewards : [];
3252
+ const merged = [];
3253
+ list.forEach((one) => {
3254
+ if (one === null || one === undefined || typeof one !== "object") return;
3255
+ if (typeof one.name !== "string" || one.name.length === 0) return;
3256
+ const name = creditDisplayName(one.name);
3257
+ const used = Number(one.used) || 0;
3258
+ const total = Number(one.total) || 0;
3259
+ const monthly = one.monthly === true;
3260
+ const expireAt = typeof one.expireAt === "string" && one.expireAt.length >= 10 ? one.expireAt : null;
3261
+ const refreshAt = typeof one.refreshAt === "string" && one.refreshAt.length >= 10 ? one.refreshAt : null;
3262
+ /* 每个原始包自己保留一份明细(子视图里逐条列出,各自真实值 + 自己的日期备注) */
3263
+ const itemLabel = creditsLabel({ name: name, monthly: monthly, expireAt: expireAt, refreshAt: refreshAt, count: 1 }, t);
3264
+ const item = {
3265
+ name: one.name,
3266
+ used: used,
3267
+ total: total,
3268
+ expireAt: expireAt,
3269
+ refreshAt: refreshAt,
3270
+ monthly: monthly,
3271
+ label: itemLabel,
3272
+ valueText: t("tipCreditsUsed", { used: formatCredits(used), total: formatCredits(total) }),
3273
+ percent: total > 0 ? Math.max(0, Math.min(100, (used / total) * 100)) : 0
3274
+ };
3275
+ const last = merged.length > 0 ? merged[merged.length - 1] : null;
3276
+ if (last !== null && last.name === name) {
3277
+ last.used += used;
3278
+ last.total += total;
3279
+ last.count += 1;
3280
+ last.expireAt = earliestDate(last.expireAt, expireAt);
3281
+ last.refreshAt = earliestDate(last.refreshAt, refreshAt);
3282
+ last.items.push(item);
3283
+ return;
3284
+ }
3285
+ merged.push({
3286
+ name: name, used: used, total: total, count: 1,
3287
+ expireAt: expireAt, refreshAt: refreshAt, monthly: monthly, items: [item]
3288
+ });
3289
+ });
3290
+ const rows = merged.map((one) => {
3291
+ /* 该组剩余积分 = 总容量 - 已用(不小于 0);下钻标题右侧显示它,随用量动态变化 */
3292
+ const remaining = Math.max(0, one.total - one.used);
3293
+ return {
3294
+ name: one.name,
3295
+ used: one.used,
3296
+ total: one.total,
3297
+ remaining: remaining,
3298
+ remainingText: formatCredits(remaining),
3299
+ count: one.count,
3300
+ items: one.items,
3301
+ expireAt: one.expireAt,
3302
+ refreshAt: one.refreshAt,
3303
+ monthly: one.monthly,
3304
+ label: creditsLabel(one, t),
3305
+ valueText: t("tipCreditsUsed", { used: formatCredits(one.used), total: formatCredits(one.total) }),
3306
+ percent: one.total > 0 ? Math.max(0, Math.min(100, (one.used / one.total) * 100)) : 0
3307
+ };
3308
+ });
3309
+ return { title: t("tipCreditsTitle"), totalText: totalText, rewards: rows };
3310
+ }
3311
+
2556
3312
  /** 单次调用的缓存命中率(%);这次调用一个输入 token 都没有时返回 null(算不出比例)。 */
2557
3313
  function hitRateOf(call) {
2558
3314
  const denominator = (call.input || 0) + (call.cacheRead || 0) + (call.cacheWrite || 0);
@@ -2836,20 +3592,7 @@ window.__ModuleLoader__.load({
2836
3592
  const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
2837
3593
  ? []
2838
3594
  : sessionUsage.calls;
2839
- /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
2840
- 累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。
2841
- **每条还要按它自己条目的币种折到显示币种**:会话里 A 是人民币、B 是美元时,
2842
- 底栏给的是一个数字(折到主模型那种币),不是两种货币相加。 */
2843
3595
  const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
2844
- let total = 0;
2845
- let priced = false;
2846
- for (let i = 0; i < calls.length; i += 1) {
2847
- const callModel = typeof calls[i].model === "string" ? calls[i].model : fallbackModel;
2848
- const one = costOf(calls[i], callModel, calls[i].at);
2849
- if (one === null) continue;
2850
- priced = true;
2851
- total += toDisplayCurrency(one, src.models, callModel, symbolCurrency, src.defaultCurrency);
2852
- }
2853
3596
  /* 被 host 的 MAX_CALLS 截掉的那部分(host 按「模型 + 峰谷档」累计好给回来)。
2854
3597
  不补这一段的话,长会话的费用只算了最近 4000 条 —— 实测 5000 次少 20%、
2855
3598
  2 万次少 80%,而「总计用量」用的是官方全量数据,两者会在同一个界面上对不上。
@@ -2859,31 +3602,41 @@ window.__ModuleLoader__.load({
2859
3602
  || typeof sessionUsage.overflow !== "object"
2860
3603
  ? {}
2861
3604
  : sessionUsage.overflow;
2862
- const overflowNames = Object.keys(overflow);
2863
- for (let i = 0; i < overflowNames.length; i += 1) {
2864
- const overflowModel = overflowNames[i];
2865
- const byTier = overflow[overflowModel];
2866
- if (byTier === undefined || byTier === null) continue;
2867
- const peakCost = costOfTier(byTier.peak, overflowModel, true);
2868
- const offPeakCost = costOfTier(byTier.offPeak, overflowModel, false);
2869
- if (peakCost === null && offPeakCost === null) continue;
2870
- priced = true;
2871
- total += toDisplayCurrency((peakCost === null ? 0 : peakCost) + (offPeakCost === null ? 0 : offPeakCost),
2872
- src.models, overflowModel, symbolCurrency, src.defaultCurrency);
2873
- }
2874
- /* 队友(子智能体)是**独立会话**,它的用量不在上面这份本会话调用表里,
2875
- host 按 parentSession 算好单独给回来;这里直接把数字并进「总计」,
2876
- 不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。
2877
- 两段数据(整会话 / 本轮窗口)各自拿到才用,没拿到的那段当 0。 */
3605
+ /* 队友(子智能体)是**独立会话**,它的用量不在本会话调用表里,
3606
+ host 按 parentSession 算好单独给回来;这里把金额并进「总计」,
3607
+ 不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。 */
2878
3608
  const sub = subagentOf(src.subagent, "session");
2879
3609
  const subCost = subagentCostOf(src.subagent, "session", symbolCurrency);
2880
- const grand = total + subCost;
3610
+ /* 「总计用量」= 官方投影 + 压缩摘要 + 队友。
3611
+ 为什么必须加摘要那一份:官方 `tokenUsage` 只折 assistant 事件、**不含压缩摘要**,
3612
+ 而「总计费用」是我们自己逐条算的、**含摘要**;不加的话同一气泡里两个数字
3613
+ 口径不一致(费用把摘要算进去了、用量没算)。宿主老版本没有 summaryParts,
3614
+ 这里会是 null,`mergeTokenParts` 对 null 段当 0 处理 —— 行为退回改动前。 */
3615
+ const mergedTokens = mergeTokenParts(
3616
+ mergeTokenParts(sessionTokenParts(src.usage), summaryTokenParts(src.sessionUsage === undefined || src.sessionUsage === null
3617
+ ? null
3618
+ : src.sessionUsage.summaryParts)),
3619
+ sub);
3620
+ /* 增量式记账:把**原始素材**交给 applyTotalFloor,由它自己决定"哪些还没结算过"。
3621
+ 逐条计价、峰谷判定(按每条自己的 at)、overflow 兜底、队友增量、欠账补算
3622
+ 全在那一个函数里 —— 这里不再做任何重算,也不再有锁定基准与差值修正。 */
3623
+ const held = applyTotalFloor(src.totalHold, src.sessionId, {
3624
+ calls: calls,
3625
+ fallbackModel: fallbackModel,
3626
+ overflow: overflow,
3627
+ subCost: subCost,
3628
+ tokens: mergedTokens,
3629
+ models: src.models,
3630
+ displayCurrency: symbolCurrency,
3631
+ defaultCurrency: src.defaultCurrency
3632
+ });
2881
3633
  return {
2882
3634
  id: id,
2883
- /* 算不出价(本会话与队友都没价)时给横杠:与"这笔真的是 0"区分开 */
2884
- text: t("f_cost", { symbol: symbol, cost: priced === true || subCost > 0 ? costText(grand) : "-" }),
2885
- /* token 计数并进总计:本会话的 + 队友的,同一个数字相加,不新增行 */
2886
- tip: tokensTip(mergeTokenParts(sessionTokenParts(src.usage), sub), t("f_tokensTotal"), t)
3635
+ /* 一条都算不出价(本会话与队友都没价)时给横杠:与"这笔真的是 0"区分开 */
3636
+ text: t("f_cost", { symbol: symbol, cost: held.cost === null ? "-" : costText(held.cost) }),
3637
+ /* token 计数并进总计:本会话的 + 队友的,同一个数字相加,不新增行;
3638
+ 条形图里队友那部分单独成块画在左边(sub) */
3639
+ tip: tokensTip(held.tokens, t("f_tokensTotal"), t, sub)
2887
3640
  };
2888
3641
  }
2889
3642
 
@@ -2922,7 +3675,7 @@ window.__ModuleLoader__.load({
2922
3675
  const subTurn = subagentOf(src.subagent, "turn");
2923
3676
  /* 队友这一段折到显示币种后的金额(逐桶折算,见 subagentDisplayCost) */
2924
3677
  const subTurnCost = subagentCostOf(src.subagent, "turn", symbolCurrency);
2925
- const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t);
3678
+ const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t, subTurn);
2926
3679
  /* 「本轮」同轮内只增不减(用户实测要求):把这次的金额过一遍粘性下限。
2927
3680
  窗口起点在一轮内可能移动(父会话日志多一条 user/message、折叠出的最早时间戳变化、
2928
3681
  host 收敛起点),窗口一变数字就会小一截;这里把已经显示过的最大值兜住,
@@ -3049,12 +3802,19 @@ window.__ModuleLoader__.load({
3049
3802
  const balSymbol = balance.isPercent === true || isCredits === true
3050
3803
  ? ""
3051
3804
  : currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
3052
- /* 积分口径:只有一个总积分,没有「充值 / 赠金」构成可展开 ⇒ 不给 tip。
3053
- 段没有 tip 就不会被渲染成可点击项(渲染处按 tip !== null 决定 className
3054
- 与 onClick),所以气泡点不开、悬停也不弹 —— 这正是要的:
3055
- 弹一个只有标题、没有任何内容的空气泡只会让人以为还能点。 */
3805
+ /* 积分口径:段只显示总积分;**宿主给了非空 rewards 时**才生成「积分套餐」气泡
3806
+ (kind:'credits',每个套餐一行 + 一条已用进度条,面板外壳与令牌面板同一套样式)。
3807
+ rewards 为空 / 缺字段 / 旧插件 → 照旧不给 tip:没有可展开的内容,
3808
+ 弹一个只有标题的空气泡只会让人以为还能点。 */
3056
3809
  if (isCredits === true) {
3057
- return { id: id, text: t("f_balance", { symbol: balSymbol, amount: balance.total }) };
3810
+ const creditsText = t("f_balance", { symbol: balSymbol, amount: balance.total });
3811
+ const panel = creditsView(balance.rewards, balance.total, t);
3812
+ if (panel.rewards.length === 0) return { id: id, text: creditsText };
3813
+ return {
3814
+ id: id,
3815
+ text: creditsText,
3816
+ tip: { kind: "credits", title: t("tipCreditsTitle"), panel: panel }
3817
+ };
3058
3818
  }
3059
3819
  /* 分隔线换成色条,两种情况各一套:
3060
3820
  金额余额(DeepSeek 等)= 充值 : 赠金,按金额占比分两段(紫 / 蓝);
@@ -3169,8 +3929,35 @@ window.__ModuleLoader__.load({
3169
3929
  "@media (prefers-reduced-motion: reduce){.dsb-ring-running .dsb-ring-fill{animation:none}}",
3170
3930
  ".dsb-ring-error .dsb-ring-fill{stroke:#ef4444}",
3171
3931
  ".dsb-ring-approval .dsb-ring-fill{stroke:#f59e0b}",
3932
+ /* 条形图恢复原设计:**段与段之间 1px 间隔(gap:1px)+ 分段 1px 圆角**。
3933
+ 曾试过「段内 1px 透明右边框 + background-clip:padding-box」来固定间隔,但边框算在段宽内,
3934
+ 0.76% / 0.29% 这种亚像素小段会被整段吃掉、直接看不见(实测紫/蓝段消失)—— 已回退。
3935
+ 间隔 1–2px 的观感差异是小数百分比宽度 + 像素取整造成的,不再用会吞掉小段的办法去「修」。
3936
+ 整条外框圆角与裁切仍由 .dsb-bar 的 border-radius + overflow:hidden 负责。 */
3172
3937
  ".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}",
3173
- ".dsb-bar-seg{flex:none;min-width:2px;height:100%;border-radius:1px;background:var(--dsw-alias-label-tertiary)}",
3938
+ /* 段宽是「各段占比 %」:允许收缩(flex:0 1 auto),否则小数百分比取整后总宽略超 100% 会被
3939
+ `.dsb-bar` 的 overflow:hidden 从**右侧**裁掉小段。**不设最小宽度**(用户拍板去掉 2px 保底):
3940
+ 极小段可能不足 1px、由浏览器自行渲染或直接看不见,这是预期 —— 精确比例以面板里那三行数字为准。
3941
+ 圆角 999px:条高只有 4px,浏览器会把半径夹到半高,所以每段两端都是完整半圆(胶囊头),
3942
+ 不会出现任何竖直直边 —— 未放大时也是圆的。 */
3943
+ ".dsb-bar-seg{flex:0 1 auto;min-width:0;height:100%;border-radius:999px;background:var(--dsw-alias-label-tertiary)}",
3944
+ /* 令牌条里「子代理·命中」那一段:与活跃总览柱状图的高峰段同一处理(75% 灰)。
3945
+ 只有命中这一档的浅色子段用这个类;子代理未命中 / 输出仍用正常主色(行内 background-color 长写)。 */
3946
+ ".dsb-bar-sub{background:color-mix(in srgb,var(--dsw-static-neutral-bluish-400) 75%,transparent)}",
3947
+ /* 工具调用彩条的悬停动效:鼠标移到某一段时该段轻微纵向放大,像从条上凸起一块。
3948
+ 平时 .dsb-bar 仍是 overflow:hidden(保住两端的圆角裁切);工具彩条这个类放开裁切,
3949
+ 否则 4px 高的段放大会被条上下切平,看不出"凸起"。scaleY(1.45) 后约 6px(用户要求放大倍率收窄)。
3950
+ 圆角沿用 999px:放大后仍是完整胶囊头(左右两端是半圆),不会变成直角方块。
3951
+ ::before 是**透明的放大判定区**:上下各外扩 7px(用户要求上下判定范围放宽),
3952
+ 鼠标在条上方/下方几个像素内也算悬停到这一段;左右不越界,不会抢相邻段的判定。
3953
+ transition 用缓出曲线、时长稍长,段与段之间的放大/复原更顺滑。 */
3954
+ ".dsb-bar-hoverable{overflow:visible}",
3955
+ ".dsb-bar-hoverable .dsb-bar-seg{position:relative;transition:transform .2s cubic-bezier(.16,1,.3,1)}",
3956
+ ".dsb-bar-hoverable .dsb-bar-seg::before{content:\"\";position:absolute;left:0;right:0;top:-7px;bottom:-7px}",
3957
+ ".dsb-bar-hoverable .dsb-bar-seg:hover{transform:scaleY(1.45);z-index:2}",
3958
+ /* 工具彩条的悬停提示:**不要气泡、也不要阴影**,只显示一行文字(比 10px 的悬停浮层大、
3959
+ 但小于 12px 的标题)。位置由 JS 给 left/top,贴在鼠标上方很近处。 */
3960
+ ".dsb-hover-plain{position:fixed;z-index:41;pointer-events:none;white-space:nowrap;font-size:11px;line-height:15px;font-weight:500;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
3174
3961
  /* 宽度按内容自适应:width:max-content 让气泡收到"最长那一行"的宽度,
3175
3962
  * 行内标签与数值之间至少留 5ch(.dsb-meter-value 的 padding-left),所以不会出现大片留白;
3176
3963
  * 内容超过 max-width 时退回换行(white-space:normal)。 */
@@ -3197,6 +3984,21 @@ window.__ModuleLoader__.load({
3197
3984
  /* 两列面板里的行:标签与数值之间至少留 5ch(工具调用那个子视图另有一条更紧的覆盖,
3198
3985
  见 .dsb-meter-tight),其余行在这个宽度里右对齐。 */
3199
3986
  ".dsb-meter-value{margin-left:auto;padding-left:5ch;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
3987
+ /* 手动调用 / 手动压缩的星号:做成上标(像 X² 的 ²),不是普通的星号字符。
3988
+ .7em 收小、vertical-align:super 抬到基线以上;line-height:0 防止把行盒撑高
3989
+ (行高 18px 是按字体度量算好的,多 1px 同排的行就会错位)。 */
3990
+ ".dsb-sup{font-size:.7em;line-height:0;vertical-align:super;margin-left:1px}",
3991
+ /* 积分套餐列表:同一屏最多显示 5 个套餐块,超出就往下滑。高度按单块 37px 推算:
3992
+ .dsb-credit 上下 padding 2+1=3px + .dsb-meter-row(line-height 18 + padding 1+3=4 → 22px)
3993
+ + .dsb-bar(height 4 + margin 6+2=8 → 12px)= 37px;5 块 = 185px。
3994
+ **改块内间距/行高时要同步这个数**。下钻子视图复用同一个类,因此同样最多 5 块可滚。
3995
+ **滚动条隐藏但保留滚动**:气泡很窄,常驻滚动条会挤掉内容宽度;
3996
+ Firefox/旧 Edge 用 scrollbar-width:none / -ms-overflow-style:none,
3997
+ WebKit/Chromium 用下面那条 ::-webkit-scrollbar。 */
3998
+ ".dsb-credits-list{max-height:calc(5 * 37px);overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none}",
3999
+ ".dsb-credits-list::-webkit-scrollbar{display:none}",
4000
+ /* 积分套餐的单块:上面一行「包名/备注 + 已用/总量」,下面一条进度 */
4001
+ ".dsb-credit{display:flex;flex-direction:column;padding:2px 0 1px}",
3200
4002
  /* 工具调用子视图:名字短、列多,5ch 太占宽度,收到 1ch */
3201
4003
  ".dsb-meter-tight .dsb-meter-value{padding-left:1ch}",
3202
4004
  /* 可点行(会话数据那几行):光标 + 悬停底色,跟设置页那些可点项同一套反馈。
@@ -3204,13 +4006,28 @@ window.__ModuleLoader__.load({
3204
4006
  上下内边距跟 .dsb-meter-row 保持一致(1/3),可点与不可点的行文字才不会错位。 */
3205
4007
  ".dsb-meter-row-click{cursor:pointer;margin:0 -4px;padding:1px 4px 3px;border-radius:999px}",
3206
4008
  ".dsb-meter-row-click:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
4009
+ /* 积分套餐的聚合行:只要 cursor:pointer,**不要**上面那个悬停灰底(用户拍板)。
4010
+ 走独立修饰类,不碰「会话数据」那些可点行的悬停样式。 */
4011
+ ".dsb-meter-row-plain{cursor:pointer}",
3207
4012
  /* 子视图标题就是返回入口:不加返回箭头,悬停变亮 */
3208
4013
  ".dsb-meter-back{cursor:pointer}",
3209
4014
  ".dsb-meter-back:hover{color:var(--dsw-alias-label-secondary)}",
3210
4015
  /* 子视图列表分列:每列最多三行(由 listColumns 决定),每列自己两列对齐。
3211
- 列多到一排放不下就折到下一排;兜底再压一道高度上限,不越过屏幕可用高度的一半。 */
3212
- ".dsb-meter-cols{flex-direction:row;flex-wrap:wrap;align-content:flex-start;align-items:flex-start;gap:6px 16px;max-height:calc(50vh - 46px);overflow-y:auto;overscroll-behavior:contain}",
3213
- ".dsb-meter-col{display:flex;flex-direction:column}",
4016
+ **绝不折行**(用户拍板):`flex-wrap:nowrap` 让所有列排成一排,一排放不下时由
4017
+ 横向滚动兜底(`overflow-x:auto`),而不是折到下一排 —— 折行会让"第二排只有一两列、
4018
+ 右边一大片空白",用户明确禁止。优先级链:先加每列行数(列数随之变少)直到放得下,
4019
+ 只有 12 行仍放不下才真的滚动(判定见 toolColumnsOverflow / nextToolRows)。
4020
+ 宽度方向:气泡是 `width:max-content`,列数少了宽度自然跟着收窄("理解 B":
4021
+ 宽度 = 加完行数之后刚好不折的那个宽度,不许停在加行之前的旧宽度)。
4022
+ **必须自带 display:flex**:这个类是从块级堆叠变成横向排布的那一步 ——
4023
+ 少了它,各列会被当成块级元素上下叠成一段(实测踩过)。
4024
+ 滚动条隐藏但保留滚动:沿用 .dsb-credits-list 那套写法(见下方 ::-webkit-scrollbar)。 */
4025
+ ".dsb-meter-cols{display:flex;flex-direction:row;flex-wrap:nowrap;align-content:flex-start;align-items:flex-start;gap:6px 16px;max-height:calc(50vh - 46px);overflow-x:auto;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none}",
4026
+ ".dsb-meter-cols::-webkit-scrollbar{display:none}",
4027
+ /* 每列**不收缩**(flex:none):容器放不下时 flex 默认会先把列压扁(flex-shrink:1),
4028
+ 那等于用"挤扁内容"替代折行 —— 同样是用户禁止的坏形态,而且会让"是否溢出"的
4029
+ 判定失真。列保持自身内容宽度,放不下就交给上面的横向滚动。 */
4030
+ ".dsb-meter-col{display:flex;flex-direction:column;flex:none}",
3214
4031
  ".dsb-tip.dsb-meter-wide{max-width:min(880px,100vw - 24px)}",
3215
4032
  /* 活跃总览:标题 → 分隔线 → 数值行 → 左边柱状图 + 右边当月活跃度 */
3216
4033
  ".dsb-week{display:flex;flex-direction:column;gap:4px}",
@@ -3268,9 +4085,9 @@ window.__ModuleLoader__.load({
3268
4085
  ".dsb-tip.dsb-hovercard .dsb-meter-rows{margin-top:2px}",
3269
4086
  ".dsb-tip.dsb-hovercard .dsb-meter-row{padding:0;gap:4px}",
3270
4087
  ".dsb-tip.dsb-hovercard .dsb-meter-figures{font-weight:400;padding-left:5ch}",
3271
- /* 柱的悬停:::before 只负责把热区左右各撑 2px(透明、看不见),
3272
- ::after 才是变灰反馈,left/right 都是 0 —— 严格不超出柱身那 5px。
3273
- 同样由 JS 状态驱动,用 :hover 的话划过柱间缝隙会一闪一闪。 */
4088
+ /* 柱的悬停:::before 只负责把热区左右各撑 2px(透明、看不见);
4089
+ 变灰反馈由下面的 `.dsb-day-active .dsb-day-track` 画(JS 状态加类,
4090
+ 严格不超出柱身那 5px)。用 :hover 的话划过柱间缝隙会一闪一闪。 */
3274
4091
  ".dsb-day{position:relative;display:flex;flex-direction:column;align-items:center;gap:3px;width:5px}",
3275
4092
  /* 热区:左右各撑 2px,透明看不见(让鼠标不会掉进柱间 4px 的缝) */
3276
4093
  ".dsb-day::before{content:\"\";position:absolute;left:-2px;right:-2px;top:0;bottom:0;border-radius:3px}",
@@ -3398,7 +4215,6 @@ window.__ModuleLoader__.load({
3398
4215
  又排在后面,会把高度覆盖回去(实测踩过:用户看到"又变高了")。
3399
4216
  自定义模型的名称框走通用规则,保持 28px。 */
3400
4217
  ".dsb-settings .dsb-field input[type=text],.dsb-settings .dsb-field input[type=number]{width:52px;height:24px;text-align:right}",
3401
- ".dsb-settings input[type=number]{text-align:right}",
3402
4218
  ".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
3403
4219
  ".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
3404
4220
  ".dsb-settings input::placeholder{color:var(--dsw-alias-label-tertiary);opacity:.5}",
@@ -3456,8 +4272,10 @@ window.__ModuleLoader__.load({
3456
4272
  * 版本自检结果 —— 导航胶囊和设置页那颗「更新」按钮共用这一份,两边同进同出。
3457
4273
  * 默认什么都没有:只有 registry 上确实更新才显示,查不到就安静地不显示。
3458
4274
  * note 是更新动作的结果:null / "done" / "failed"。
4275
+ * failedTarget 记「失败的那次试的是哪个版本」:失败态点一下退回可重试时,
4276
+ * 文案要回到那个版本号(而不是 last 里可能已被刷新的别的版本)。
3459
4277
  */
3460
- let updateState = { hasUpdate: false, latest: null, busy: false, note: null, reason: null };
4278
+ let updateState = { hasUpdate: false, latest: null, busy: false, note: null, reason: null, failedTarget: null };
3461
4279
 
3462
4280
  /**
3463
4281
  * 开发模式(连点版本号 5 次进入):让作者在没有新版本的时候也能看到更新按钮的
@@ -3541,6 +4359,27 @@ window.__ModuleLoader__.load({
3541
4359
  return typeof raw === "string" && raw.length > 0 ? raw : "";
3542
4360
  }
3543
4361
 
4362
+ /**
4363
+ * 客户端侧失败上报:把「浏览器里到底怎么失败的」写成一条日志交给 host。
4364
+ *
4365
+ * 宿主侧失败它自己会记(`update: 结果 {...}`),但**客户端**这边的失败
4366
+ * (fetch 被代理拦、响应解不出来、网络断)宿主完全看不见 —— 用户点完只看到
4367
+ * 「更新失败」,日志里没有任何痕迹,想拿日志问 AI 也无从下手。
4368
+ *
4369
+ * 只发诊断字段、不改任何状态;任何异常都静默(日志补不上不能连累界面)。
4370
+ */
4371
+ function reportUpdateFailure(info) {
4372
+ try {
4373
+ window.fetch(UPDATE_FAILURE_URL, {
4374
+ method: "POST",
4375
+ headers: { "content-type": "application/json" },
4376
+ body: JSON.stringify(info)
4377
+ }).catch(() => { /* 静默 */ });
4378
+ } catch (error) {
4379
+ /* 静默 */
4380
+ }
4381
+ }
4382
+
3544
4383
  /**
3545
4384
  * 一键更新:调**本插件自己的** host 路由,由它自己跑 pnpm 把新版本装上。
3546
4385
  *
@@ -3564,27 +4403,52 @@ window.__ModuleLoader__.load({
3564
4403
  if (devMode === true) { runDevPreview(); return; }
3565
4404
  if (updateState.latest === null || updateState.busy === true) return;
3566
4405
  const target = updateState.latest;
3567
- setUpdateState({ busy: true, note: null, reason: null });
4406
+ /* 失败时记下"这个版本已经试过了":按钮点一下回到可重试文案时要用它。 */
4407
+ setUpdateState({ busy: true, note: null, reason: null, failedTarget: null });
4408
+ const startedAt = Date.now();
4409
+ /* 失败收敛到一处:状态 + 日志。三条失败路径(响应解不出 / 应答 ok:false / 网络异常)
4410
+ 都走这里,保证每一条都在日志里留下同样的字段。 */
4411
+ const fail = (reason, extra) => {
4412
+ const info = Object.assign({
4413
+ reason: reason,
4414
+ target: target,
4415
+ current: VERSION,
4416
+ at: Date.now(),
4417
+ elapsedMs: Date.now() - startedAt
4418
+ }, extra === undefined ? {} : extra);
4419
+ reportUpdateFailure(info);
4420
+ setUpdateState({ busy: false, note: "failed", reason: reason, failedTarget: target });
4421
+ };
3568
4422
  window.fetch(SELF_UPDATE_URL, {
3569
4423
  method: "POST",
3570
4424
  headers: { "content-type": "application/json" },
3571
4425
  body: JSON.stringify({ version: target })
3572
- }).then((response) => (response.ok === true ? response.json() : null))
4426
+ }).then((response) => {
4427
+ /* fetch 对 4xx/5xx 是 **resolve** 不是 reject:必须自己看状态码,
4428
+ 否则 403(跨站防护)/ 409(正在更新)/ 500 都会被当成"响应解不出来"。 */
4429
+ if (response.ok !== true) {
4430
+ fail("http-error", { stage: "request", httpStatus: response.status });
4431
+ return null;
4432
+ }
4433
+ return response.json();
4434
+ })
3573
4435
  .then((payload) => {
3574
- /* 响应都解不出来(服务端崩了、被代理拦了):按失败处理并说清楚 */
3575
- if (payload === null || payload === undefined) {
3576
- setUpdateState({ busy: false, note: "failed", reason: "no-response" });
4436
+ /* null 有两种来源:上面的 HTTP 错误(已处理过)或 JSON 解析失败 */
4437
+ if (payload === null || payload === undefined) return;
4438
+ const ok = payload.ok === true;
4439
+ if (ok === true) {
4440
+ setUpdateState({ busy: false, note: "done", reason: null, failedTarget: null });
3577
4441
  return;
3578
4442
  }
3579
- const ok = payload.ok === true;
3580
- setUpdateState({
3581
- busy: false,
3582
- note: ok ? "done" : "failed",
3583
- reason: ok ? null : reasonOf(payload)
3584
- });
4443
+ /* 宿主明确回了失败原因(release-age / verify-failed / pnpm-failed…):
4444
+ 宿主自己已经记过日志,这里补的是"客户端看到的那一份",两边能对上。 */
4445
+ fail(reasonOf(payload), { stage: "host", detail: JSON.stringify(payload).slice(0, 300) });
3585
4446
  })
3586
4447
  .catch((error) => {
3587
- setUpdateState({ busy: false, note: "failed", reason: "network" });
4448
+ const detail = error !== null && error !== undefined && typeof error.message === "string"
4449
+ ? error.message
4450
+ : String(error);
4451
+ fail("network", { stage: "request", detail: detail });
3588
4452
  });
3589
4453
  }
3590
4454
 
@@ -3619,6 +4483,39 @@ window.__ModuleLoader__.load({
3619
4483
  /** 开发模式里「更新中」那一步的定时器;退出开发模式或卸载时要清掉。 */
3620
4484
  let devTimer = null;
3621
4485
 
4486
+ /**
4487
+ * 「更新失败」那一态下点按钮:**只把状态退回可重试,不直接重试**。
4488
+ *
4489
+ * 用户原话:「"更新失败"还能点啊,点完就回到,更新X.X.X版本这个按钮,
4490
+ * 然后用户就会再点一次更新来重试更新。」
4491
+ * ——所以这里是"点一下退回去、再点一下才真装",不是"点一下就重装":
4492
+ * 前者用户看得见自己点过了、也能先确认网络/版本再动手;后者会让人以为
4493
+ * 按钮没反应(毕竟失败→重装→再失败 的往返太快)。
4494
+ *
4495
+ * 退回后的文案是「更新到 X.X.X 版本」,那个版本号取失败时记下的 failedTarget
4496
+ * (比 updateState.latest 更可靠:latest 若被后台刷新改掉,文案会跳到别的版本)。
4497
+ */
4498
+ function dismissUpdateFailure() {
4499
+ const target = updateState.failedTarget === null || updateState.failedTarget === undefined
4500
+ ? updateState.latest
4501
+ : updateState.failedTarget;
4502
+ setUpdateState({ busy: false, note: null, reason: null, latest: target, failedTarget: null });
4503
+ }
4504
+
4505
+ /**
4506
+ * 更新按钮的点击入口:按当前状态分派。
4507
+ * · 失败态 → 只退回可重试(不重试,见 dismissUpdateFailure)
4508
+ * · 其余 → 真的发起一次更新
4509
+ * 更新中(busy)按钮本来就是 disabled,走不到这里。
4510
+ */
4511
+ function onUpdateClick() {
4512
+ if (updateState.note === "failed") {
4513
+ dismissUpdateFailure();
4514
+ return;
4515
+ }
4516
+ startPluginUpdate();
4517
+ }
4518
+
3622
4519
  /** 开发模式当前是否开着(给测试与自检读)。 */
3623
4520
  function isDevMode() {
3624
4521
  return devMode;
@@ -3838,6 +4735,14 @@ window.__ModuleLoader__.load({
3838
4735
  /* 「本轮」显示值的粘性下限盒子(同轮内只增不减,换轮作废):一个可变盒子,
3839
4736
  由 lastCost 段在渲染时更新,跨渲染靠这个 ref 留住(见 applyTurnFloor)。 */
3840
4737
  const lastCostHoldRef = react.useRef({ sessionId: null, turnKey: null, value: 0 });
4738
+ /* 「总计」的**增量式账本**(见 applyTotalFloor 的长注释):
4739
+ { sessionId, seen, cost, tokens, priced, subCostSettled, debts }。
4740
+ 一个可变盒子:段里渲染时按需把它推到最新,跨渲染靠这个 ref 留住累计值。
4741
+ 换会话时整份状态由 applyTotalFloor 自己清空,这里不必额外处理。 */
4742
+ const totalHoldRef = react.useRef({
4743
+ sessionId: null, seen: new Map(), cost: 0, tokens: null,
4744
+ priced: false, subCostSettled: 0, debts: new Map()
4745
+ });
3841
4746
  /* 点开「总计」气泡时 +1:强制重拉一次队友用量(别的触发条件都带 SUBAGENT_REFRESH_MS 节流) */
3842
4747
  const [subagentTick, setSubagentTick] = react.useState(0);
3843
4748
  const [tipId, setTipId] = react.useState(null);
@@ -3845,14 +4750,36 @@ window.__ModuleLoader__.load({
3845
4750
  /* 气泡里的下钻层:null = 根视图,数字 = 点开了第几行(会话数据那三行的子视图)。
3846
4751
  换字段、收起气泡都要清掉,否则下次打开会停在上次的子视图上。 */
3847
4752
  const [tipSub, setTipSub] = react.useState(null);
4753
+ /* 积分套餐气泡的下钻层:null = 根视图,数字 = 点开了第几个聚合行(×N 那行)。
4754
+ 换字段 / 收起气泡 / 点标题返回都清掉,下次打开回到套餐列表。 */
4755
+ const [creditSub, setCreditSub] = react.useState(null);
4756
+ /* 工具调用子视图「每列几行」:默认 3 行一列;量到一排放不下(列被折到下一排)就
4757
+ 逐档 +1(最多 12 行,见下面气泡定位那条 layout effect)。换字段 / 换子视图都重置回 3。 */
4758
+ const [toolRows, setToolRows] = react.useState(LIST_ROWS_PER_COLUMN);
3848
4759
  /* 柱 / 方块 / 每周底条的悬停浮层:内容 + 触发元素的位置(自己算 fixed 坐标) */
3849
4760
  const [hoverCard, setHoverCard] = react.useState(null);
3850
4761
  const [hoverPos, setHoverPos] = react.useState(null);
4762
+ /* 工具彩条段悬停的「无气泡纯文字」内容(工具名 + 占比);有值时只渲染一行文字 */
4763
+ const [hoverPlain, setHoverPlain] = react.useState(null);
4764
+ /* 纯文字提示的固定垂直位置(与气泡标题行文字居中对齐):定位 effect 算好后存这里,
4765
+ 段内左右移动时直接复用,保证「只左右移动、不上下移动」且不抖。 */
4766
+ const hoverPlainTopRef = react.useRef(null);
4767
+ /* 纯文字提示的**未缩放原始宽度**(px):段内移动时按它算缩放比例。
4768
+ 不能拿 getBoundingClientRect().width —— 那个节点此时已经带 `transform: scale(x)`,
4769
+ 量到的是**缩放后**的视觉宽,再乘一次比例就会越缩越小、来回振荡(长 MCP 工具名
4770
+ + 窄窗时可见)。定位 effect 在还没写 transform 的那一帧量一次存这里。 */
4771
+ const hoverPlainWidthRef = react.useRef(0);
4772
+ /* 纯文字提示的水平夹取范围:左 = 「工具调用」标题右边界 + 1ch,右 = 色条右边界
4773
+ (再与视口右缘取小)。定位 effect 里算一次存这里,段内移动时复用。 */
4774
+ const hoverPlainBoundsRef = react.useRef(null);
3851
4775
  /* 当前悬停的是哪一周(用来给那条底板加深);悬停方块/柱子时为 null */
3852
4776
  const [hoverWeek, setHoverWeek] = react.useState(null);
3853
4777
  /* 当前悬停的是哪一根柱(那一列的判定区变灰);不在柱上时为 null */
3854
4778
  const [hoverBar, setHoverBar] = react.useState(null);
3855
4779
  const hoverRef = react.useRef(null);
4780
+ /* 工具调用彩条悬停时「跟着鼠标走」:鼠标坐标(clientX / clientY)。
4781
+ 仅鼠标跟随型浮层(payload.follow === true)设置;活跃总览的柱/方块不设,仍贴整个气泡上方。 */
4782
+ const [hoverMouse, setHoverMouse] = react.useState(null);
3856
4783
  const hoverTimerRef = react.useRef(null);
3857
4784
  /* 收起延后一小会儿:鼠标在相邻的空位格之间横向移动会先 leave 再 enter,
3858
4785
  当场清空就会一闪一闪;紧接着的新悬停会把定时器取消掉,等于没关过。 */
@@ -3864,9 +4791,13 @@ window.__ModuleLoader__.load({
3864
4791
  setHoverPos(null);
3865
4792
  setHoverWeek(null);
3866
4793
  setHoverBar(null);
4794
+ setHoverPlain(null);
4795
+ setHoverMouse(null);
3867
4796
  }, 110);
3868
4797
  }, []);
3869
- /* payload 为对象:rows 有值时弹浮层,week 有值时那条底板加深,bar 有值时那根柱的判定区变灰。
4798
+ /* payload 为对象:rows 有值时弹浮层,plain 有值时只显示一行文字(无气泡),
4799
+ week 有值时那条底板加深,bar 有值时那根柱的判定区变灰。
4800
+ follow === true 时浮层跟着鼠标走(贴鼠标上方、水平居中)。
3870
4801
  传空表示离开判定区(延后收起,避免在相邻区域间横向移动时闪烁)。 */
3871
4802
  const onHover = react.useCallback((event, payload) => {
3872
4803
  if (payload === null || payload === undefined) {
@@ -3878,10 +4809,61 @@ window.__ModuleLoader__.load({
3878
4809
  hoverTimerRef.current = null;
3879
4810
  }
3880
4811
  setHoverPos(null);
4812
+ const pointer = event !== null && event !== undefined
4813
+ && typeof event.clientX === "number" && typeof event.clientY === "number"
4814
+ ? { x: event.clientX, y: event.clientY }
4815
+ : null;
4816
+ setHoverMouse(payload.follow === true ? pointer : null);
4817
+ setHoverPlain(typeof payload.plain === "string" ? payload.plain : null);
3881
4818
  setHoverCard(payload.rows === undefined ? null : payload.rows);
3882
4819
  setHoverWeek(typeof payload.week === "number" ? payload.week : null);
3883
4820
  setHoverBar(typeof payload.bar === "number" ? payload.bar : null);
3884
4821
  }, [closeHoverSoon]);
4822
+ /* 同一色段里移动:**直接写提示的 left**,不走 setState —— 跟鼠标是同一帧,
4823
+ 不会有 React 重渲染 + layout effect 的一到两帧延迟(用户实测「不跟手」)。
4824
+ 纯文字提示**只左右移动、上下固定**(垂直位置与气泡标题文字居中对齐,
4825
+ 固定值由定位 effect 算好后存在 hoverPlainTopRef 里);气泡版仍按鼠标上下跟。
4826
+ 只在内容切换(进入另一个色段)时才走 onHover 重设位置;这里改完不触发渲染。 */
4827
+ const onHoverMove = react.useCallback((event) => {
4828
+ if (event === null || event === undefined) return;
4829
+ if (typeof event.clientX !== "number" || typeof event.clientY !== "number") return;
4830
+ const node = hoverRef.current;
4831
+ if (node === null || node === undefined || typeof node.getBoundingClientRect !== "function") return;
4832
+ /* 测试替身没有 style(不是真实 DOM):直接跳过,几何本身由其它用例覆盖 */
4833
+ if (node.style === undefined || node.style === null) return;
4834
+ const size = node.getBoundingClientRect();
4835
+ if (hoverPlain !== null) {
4836
+ /* 纯文字提示:水平夹在「标题右边界 ~ 色条右边界」之间。
4837
+ 区间放不下这段文字时(长 MCP 工具名 + 窄窗)**等比缩小到放得下**,
4838
+ 一般情况 scale 就是 1、不做任何缩放。
4839
+ ⚠ 宽度必须用**未缩放**的原始宽,两个来源都行、但都不能含 transform:
4840
+ ① hoverPlainWidthRef(定位 effect 用 offsetWidth 存好的);
4841
+ ② 现场量 node.offsetWidth(同样不含 transform)。
4842
+ 绝不能用 getBoundingClientRect().width —— 此时节点可能已经带着
4843
+ transform: scale(),量到的是缩放后的视觉宽,再乘一次比例就会越缩越小。 */
4844
+ const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
4845
+ ? node.offsetWidth
4846
+ : size.width;
4847
+ const natural = hoverPlainWidthRef.current > 0 ? hoverPlainWidthRef.current : layoutWidth;
4848
+ const scale = plainFitScale(natural, hoverPlainBoundsRef.current, window.innerWidth);
4849
+ const shown = scale === 1 ? natural : Math.round(natural * scale);
4850
+ const left = clampPlainLeft(event.clientX - shown / 2, shown, hoverPlainBoundsRef.current, window.innerWidth);
4851
+ node.style.left = left + "px";
4852
+ /* 缩放以左端为基准:left 已经按缩小后的宽度算好,transform 原点放左上角才对得上 */
4853
+ node.style.transform = scale === 1 ? "" : "scale(" + scale + ")";
4854
+ node.style.transformOrigin = scale === 1 ? "" : "left center";
4855
+ /* 上下固定:沿用 title 行居中算出的 top */
4856
+ if (hoverPlainTopRef.current !== null) node.style.top = hoverPlainTopRef.current + "px";
4857
+ } else {
4858
+ const left = Math.max(8, Math.min(event.clientX - size.width / 2, window.innerWidth - size.width - 8));
4859
+ node.style.left = left + "px";
4860
+ node.style.top = Math.max(8, event.clientY - size.height - 12) + "px";
4861
+ node.style.transform = "";
4862
+ node.style.transformOrigin = "";
4863
+ }
4864
+ /* 直径写宽度会把「贴段居中」的旧宽档带过来,鼠标跟随时让提示按内容自适应 */
4865
+ node.style.width = "";
4866
+ }, [hoverPlain]);
3885
4867
  react.useEffect(() => () => {
3886
4868
  if (hoverTimerRef.current !== null) clearTimeout(hoverTimerRef.current);
3887
4869
  }, []);
@@ -4117,7 +5099,7 @@ window.__ModuleLoader__.load({
4117
5099
  跟着 scroll 收起的话,气泡会在这些时刻莫名其妙消失。 */
4118
5100
  react.useEffect(() => {
4119
5101
  if (tipId === null) return undefined;
4120
- const close = () => { setTipId(null); setTipPos(null); setTipSub(null); };
5102
+ const close = () => { setTipId(null); setTipPos(null); setTipSub(null); setCreditSub(null); };
4121
5103
  const onDocClick = (event) => {
4122
5104
  if (rootRef.current !== null && rootRef.current.contains(event.target)) return;
4123
5105
  close();
@@ -4132,10 +5114,6 @@ window.__ModuleLoader__.load({
4132
5114
  };
4133
5115
  }, [tipId]);
4134
5116
 
4135
- /* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
4136
- 最左、最右那两项贴边对齐,其余跟该字段居中对齐,最后按视口左右夹住。
4137
- 用 layout effect 是为了在浏览器绘制前就落位,不会先闪一下再跳。
4138
- anchorRef 存的是触发项节点本身,每次都重新量 —— 窗口缩放后位置才是对的。 */
4139
5117
  /* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
4140
5118
  **不设依赖数组**:下钻换子视图、数字位数变化都会改宽度,坐标必须跟着重算 ——
4141
5119
  只在 tipId 变时算的话,换子视图后会沿用上一个宽度,气泡就偏到一边去。
@@ -4156,6 +5134,17 @@ window.__ModuleLoader__.load({
4156
5134
  const width = node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
4157
5135
  ? node.getBoundingClientRect().width
4158
5136
  : 0;
5137
+ /* 工具调用子视图:默认每列 3 行,一排放不下就逐档加行
5138
+ (3 → 4 → 5 …,上限 LIST_ROWS_PER_COLUMN_MAX)。只有这一套行数 ——
5139
+ 不再有「中文/英文整行块」两套结构(见 rowsPanel 的注释)。
5140
+ 列网格是 nowrap(绝不折行),所以"放不下"用横向溢出判(见 toolColumnsOverflow);
5141
+ 每加一行列数就少一档,直到一排放得下。行数一加气泡就变窄 —— 这就是用户要的
5142
+ "宽度 = 加完行数之后刚好不折的那个宽度"(理解 B),不会停在加行前的旧宽度上。
5143
+ 只升不降(回退会让行数在两档之间来回跳);换字段 / 开子视图 / 返回时都重置回 3。
5144
+ 到 12 行仍放不下就停在 12,横向滚动兜底(不再折行)。
5145
+ 量的是 DOM 几何,测试替身里拿不到就一律当放得下(量测函数自己兜住)。 */
5146
+ const nextRows = nextToolRows(toolRows, toolColumnsOverflow(node));
5147
+ if (nextRows !== toolRows) setToolRows(nextRows);
4159
5148
  const centered = anchor.left + anchor.width / 2 - width / 2;
4160
5149
  /* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
4161
5150
  左 —— 可见左边缘压在上下文占用那个**圆圈灰底**的左边。灰底是圆环按钮自己画的
@@ -4182,20 +5171,23 @@ window.__ModuleLoader__.load({
4182
5171
  ? lastSeg.getBoundingClientRect().right
4183
5172
  : anchor.right;
4184
5173
  const barRightIn = barRight - TIP_OUTLINE_PX;
4185
- /* 气泡比底栏还宽时(工具调用那种多列面板),先把宽度压到底栏跨度以内,
4186
- 多出来的列由 flex-wrap 折到下一排 —— 否则右边界怎么挪都会顶出去。 */
5174
+ /* 气泡比底栏还宽时(工具调用那种多列面板),先把宽度压到底栏跨度以内 ——
5175
+ 否则右边界怎么挪都会顶出去。
5176
+ **注意:多出来的列不是折到下一排**(`flex-wrap` 已是 nowrap,用户明确要求永不折行),
5177
+ 而是由内层 `.dsb-meter-cols` 横向滚动(`overflow-x:auto`)接住;
5178
+ 行数在 12 行以内时优先加行把列数压下来,列少了气泡自然变窄(这就是"自适应宽度")。 */
4187
5179
  const span = barRightIn - barLeft;
4188
5180
  const maxWidth = span > 0 ? Math.min(span, window.innerWidth - 24) : null;
4189
5181
  const maxLeft = Math.min(barRightIn - width, window.innerWidth - width - 8);
4190
5182
  const left = Math.max(barLeft, Math.min(centered, maxLeft));
4191
5183
  const bottom = Math.max(8, window.innerHeight - anchor.top + 6);
4192
- /* 诊断:这几个数会写到气泡的 data-dsb-align 上(也作为悬停提示) */
5184
+ /* 诊断串:只用于下面回传给 host 落盘(不写进 DOM,浏览器也不显示) */
4193
5185
  const align = "center left=" + left.toFixed(1) + " ring=" + barLeft.toFixed(1)
4194
5186
  + " bar=" + barRight.toFixed(1) + " anchor=" + Math.round(anchor.left) + " w=" + Math.round(width);
4195
5187
  setTipPos((prev) => (prev !== null && prev.left === left && prev.bottom === bottom
4196
- && prev.maxWidth === maxWidth && prev.align === align
5188
+ && prev.maxWidth === maxWidth
4197
5189
  ? prev
4198
- : { left: left, bottom: bottom, maxWidth: maxWidth, align: align }));
5190
+ : { left: left, bottom: bottom, maxWidth: maxWidth }));
4199
5191
  /* 诊断回传:本机打不开控制台也能核对,host 会落到 <DSH_HOME>/dsh-status-bar/tip-diag.log。
4200
5192
  只在坐标真的变了时才回传,不然每次渲染都发一遍会把日志刷满。 */
4201
5193
  if (tipDiagRef.current !== align) {
@@ -4215,21 +5207,71 @@ window.__ModuleLoader__.load({
4215
5207
  }
4216
5208
  });
4217
5209
 
4218
- /* 悬停浮层定位:固定贴在活跃总览气泡的正上方,且左右边界与它对齐(同宽),不遮挡它 */
5210
+ /* 悬停定位:两种情形。
5211
+ ① 工具调用彩条的纯文字提示(hoverPlain)—— **垂直方向固定**:与气泡标题文字居中对齐,
5212
+ 不随鼠标上下移动;只有 left 跟着鼠标水平居中(用户拍板:只左右移动、不上下移动)。
5213
+ 水平位置用 hoverMouse 记下的鼠标坐标(onHover 时写入)。
5214
+ ② 活跃总览的柱 / 方块 / 底板 —— 固定贴在气泡正上方、左右同宽。
5215
+ hoverMouse 由 onHover 记录;离开后由 closeHoverSoon 清空。 */
4219
5216
  useTipLayout(() => {
4220
- if (hoverCard === null) return;
5217
+ if (hoverCard === null && hoverPlain === null) return;
4221
5218
  const node = hoverRef.current;
4222
5219
  const panel = tipRef.current;
4223
5220
  if (node === null || node === undefined || panel === null || panel === undefined) return;
4224
5221
  if (typeof node.getBoundingClientRect !== "function" || typeof panel.getBoundingClientRect !== "function") return;
4225
5222
  const size = node.getBoundingClientRect();
5223
+ if (hoverPlain !== null) {
5224
+ const one = hoverMouse;
5225
+ if (one === null || one === undefined) return;
5226
+ /* 标题行(.dsb-meter-head)的垂直中心:文字提示与它居中对齐 */
5227
+ const head = typeof panel.querySelector === "function" ? panel.querySelector(".dsb-meter-head") : null;
5228
+ const headBox = head !== null && head !== undefined && typeof head.getBoundingClientRect === "function"
5229
+ ? head.getBoundingClientRect()
5230
+ : null;
5231
+ const fixedTop = headBox === null || headBox.height <= 0
5232
+ ? Math.max(8, node.getBoundingClientRect().top)
5233
+ : headBox.top + headBox.height / 2 - size.height / 2;
5234
+ hoverPlainTopRef.current = fixedTop;
5235
+ /* 水平范围:左 = 标题右边界 + 1ch;右 = **色条本身**的右边界(不是气泡右边界) */
5236
+ const title = typeof panel.querySelector === "function" ? panel.querySelector(".dsb-meter-headline") : null;
5237
+ const titleBox = title !== null && title !== undefined && typeof title.getBoundingClientRect === "function"
5238
+ ? title.getBoundingClientRect()
5239
+ : null;
5240
+ const bar = typeof panel.querySelector === "function" ? panel.querySelector(".dsb-bar") : null;
5241
+ const barBox = bar !== null && bar !== undefined && typeof bar.getBoundingClientRect === "function"
5242
+ ? bar.getBoundingClientRect()
5243
+ : panel.getBoundingClientRect();
5244
+ const bounds = plainBoundsOf(titleBox, barBox, window.innerWidth);
5245
+ hoverPlainBoundsRef.current = bounds;
5246
+ /* 记下**未缩放**的原始宽度,段内移动时按它算比例(见 onHoverMove)。
5247
+ ⚠ 必须用 offsetWidth(布局宽度),**不能**用 getBoundingClientRect().width:
5248
+ 后者含 transform。这条 effect 的依赖里有 hoverMouse(鼠标一动就变),
5249
+ 所以它会重跑;重跑时节点上还留着上一帧写的 transform: scale(x),
5250
+ 用 getBoundingClientRect 量到的是缩放后的宽 → ref 被写小 → 下次更小
5251
+ → 悬停文字一路缩到 0.5 下限(实测自然宽 600 被写成 300)。
5252
+ offsetWidth 不受 transform 影响,重跑多少次都是自然宽。
5253
+ offsetWidth 是整数且老浏览器/替身里可能没有,取不到就退回 size.width。 */
5254
+ const layoutWidth = typeof node.offsetWidth === "number" && node.offsetWidth > 0
5255
+ ? node.offsetWidth
5256
+ : size.width;
5257
+ hoverPlainWidthRef.current = layoutWidth;
5258
+ /* 区间放不下这段文字时缩小到放得下(一般情况 scale=1,不缩) */
5259
+ const scale = plainFitScale(layoutWidth, bounds, window.innerWidth);
5260
+ const shown = scale === 1 ? layoutWidth : Math.round(layoutWidth * scale);
5261
+ setHoverPos({
5262
+ left: clampPlainLeft(one.x - shown / 2, shown, bounds, window.innerWidth),
5263
+ top: Math.max(8, fixedTop),
5264
+ scale: scale
5265
+ });
5266
+ return;
5267
+ }
4226
5268
  const box = panel.getBoundingClientRect();
4227
5269
  setHoverPos({
4228
5270
  left: Math.max(8, box.left),
4229
5271
  top: Math.max(8, box.top - size.height - 6),
4230
5272
  width: box.width
4231
5273
  });
4232
- }, [hoverCard]);
5274
+ }, [hoverCard, hoverPlain, hoverMouse]);
4233
5275
 
4234
5276
  /* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上 */
4235
5277
  react.useEffect(() => {
@@ -4239,7 +5281,6 @@ window.__ModuleLoader__.load({
4239
5281
  }
4240
5282
  }, [tipId]);
4241
5283
 
4242
- /* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
4243
5284
  /* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
4244
5285
  react.useEffect(() => {
4245
5286
  if (typeof document === "undefined" || document === null) return undefined;
@@ -4322,7 +5363,9 @@ window.__ModuleLoader__.load({
4322
5363
  progress: progress, subagent: subagent,
4323
5364
  /* 「本轮」同轮粘性下限的载体:sessionId/turnKey 说明"这份下限属于谁",
4324
5365
  lastCostHold 是个可变盒子(ref 里那个对象),段里渲染时按需更新它 —— 见 applyTurnFloor。 */
4325
- sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current
5366
+ sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current,
5367
+ /* 「总计」增量式账本的载体(见 applyTotalFloor 的注释) */
5368
+ totalHold: totalHoldRef.current
4326
5369
  };
4327
5370
 
4328
5371
  const views = [];
@@ -4353,6 +5396,9 @@ window.__ModuleLoader__.load({
4353
5396
  const toggleTip = (id, event) => {
4354
5397
  /* 换字段(含收起)一律回到根视图,别把上一个字段的下钻层带过来 */
4355
5398
  setTipSub(null);
5399
+ setCreditSub(null);
5400
+ /* 工具调用子视图的行数也回到默认 3:换字段 / 重开都重新量 */
5401
+ setToolRows(LIST_ROWS_PER_COLUMN);
4356
5402
  if (tipId === id) {
4357
5403
  setTipId(null);
4358
5404
  setTipPos(null);
@@ -4375,9 +5421,33 @@ window.__ModuleLoader__.load({
4375
5421
  const tipPanelOf = (tip) => {
4376
5422
  if (tip === undefined || tip === null) return null;
4377
5423
  if (tip.kind === "tokens") return tokenPanel(tip.panel, tip.title, tipPos, tipRef);
5424
+ if (tip.kind === "credits") {
5425
+ return creditsPanel(tip.panel, tip.title, tipPos, tipRef, {
5426
+ id: creditSub,
5427
+ /* 点聚合行进入明细子视图;点标题返回套餐列表 */
5428
+ open: (index) => setCreditSub(index),
5429
+ back: () => setCreditSub(null)
5430
+ });
5431
+ }
4378
5432
  if (tip.kind === "meter") return meterPanel(tip.meter, t, tipPos, tipRef);
4379
5433
  if (tip.kind === "bars") return weekBarsPanel(tip, t, tipPos, tipRef, onHover, { week: hoverWeek, bar: hoverBar });
4380
- return rowsPanel(tip, tipPos, tipRef, { id: tipSub, open: setTipSub, back: () => setTipSub(null) });
5434
+ return rowsPanel(tip, tipPos, tipRef, {
5435
+ id: tipSub,
5436
+ /* 打开 / 返回都把行数重置回 3:另一个子视图不该沿用上一条量出来的行数 */
5437
+ open: (index) => {
5438
+ setToolRows(LIST_ROWS_PER_COLUMN);
5439
+ setTipSub(index);
5440
+ },
5441
+ back: () => {
5442
+ setToolRows(LIST_ROWS_PER_COLUMN);
5443
+ setTipSub(null);
5444
+ },
5445
+ rowsPerColumn: toolRows,
5446
+ /* 工具调用彩条每段的悬停浮层(工具名 + 占比)走同一套 onHover,与柱状图互不干扰;
5447
+ onHoverMove 只更新鼠标坐标,让浮层在同一个色段里跟着鼠标走而不闪断。 */
5448
+ hover: onHover,
5449
+ hoverMove: onHoverMove
5450
+ });
4381
5451
  };
4382
5452
  /* 圆环自己就是触发项:点开上下文占用面板;峰谷那两个字不带气泡 */
4383
5453
  const contextTip = contextMeter === null ? null : { kind: "meter", meter: contextMeter };
@@ -4411,6 +5481,26 @@ window.__ModuleLoader__.load({
4411
5481
  ? [h("span", { key: "__txt" }, view.text), tipId === view.id ? tipPanelOf(tip) : null]
4412
5482
  : view.text));
4413
5483
  });
5484
+ /* 工具彩条悬停:**不要气泡**,只渲染一行文字(工具名 + 占比),贴在鼠标上方。
5485
+ 字号 11px(比悬停浮层的 10px 大、比标题 12px 小),无背景/圆角/描边。
5486
+ 极窄区间放不下时按 plainFitScale 等比缩小(transform 不动布局,left/top 照常)。 */
5487
+ if (hoverPlain !== null) {
5488
+ const hoverStyle = hoverPos === null
5489
+ ? undefined
5490
+ : (hoverPos.scale === undefined || hoverPos.scale === 1
5491
+ ? { left: hoverPos.left + "px", top: hoverPos.top + "px" }
5492
+ : {
5493
+ left: hoverPos.left + "px", top: hoverPos.top + "px",
5494
+ transform: "scale(" + hoverPos.scale + ")",
5495
+ transformOrigin: "left center"
5496
+ });
5497
+ children.push(h("span", {
5498
+ className: "dsb-hover-plain",
5499
+ key: "__hoverplain",
5500
+ ref: hoverRef,
5501
+ style: hoverStyle
5502
+ }, hoverPlain));
5503
+ }
4414
5504
  /* 悬停浮层:与主气泡同一套质感,fixed 定位在触发元素上方。
4415
5505
  必须先挂上节点(此时 pos 还是 null、不给 left/bottom),
4416
5506
  layout effect 才量得到宽度并把坐标算出来 —— 否则会永远卡在"没有坐标"而不显示。 */
@@ -5288,6 +6378,10 @@ window.__ModuleLoader__.load({
5288
6378
  24 小时发布年龄限制)。三种状态都写在按钮自己身上:空闲报版本号、
5289
6379
  更新中带动效点、失败就说「更新失败」。
5290
6380
 
6381
+ **失败态仍然可点**:点一下只是退回「更新到 X.X.X 版本」,让用户看清
6382
+ 自己点过了、可以再点一次真重试(见 dismissUpdateFailure 的说明)。
6383
+ 唯一 disabled 的是「更新中」——那时候不能重复发起。
6384
+
5291
6385
  **装完必须重启 DSH,刷新页面没有用** —— 这不是保守说法,是 DSH 的设计:
5292
6386
  · `@deepseek-ai/dsh-hmr` 的已知限制原文:「通过插件管理器替换已安装包版本
5293
6387
  仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。」
@@ -5302,7 +6396,7 @@ window.__ModuleLoader__.load({
5302
6396
  className: "dsb-action",
5303
6397
  key: "__update",
5304
6398
  disabled: update.busy === true,
5305
- onClick: startPluginUpdate
6399
+ onClick: onUpdateClick
5306
6400
  }, update.busy === true
5307
6401
  ? [t("updateRunning"), h("span", { className: "dsb-dots", key: "__dots" }, [
5308
6402
  h("span", { key: "__d1" }, "."),
@@ -5349,6 +6443,9 @@ window.__ModuleLoader__.load({
5349
6443
  runDevPreview: runDevPreview,
5350
6444
  isDevMode: isDevMode,
5351
6445
  onVersionClick: onVersionClick,
6446
+ /* 更新按钮的两个点击入口:失败态只退回可重试、其余真发起更新 */
6447
+ onUpdateClick: onUpdateClick,
6448
+ dismissUpdateFailure: dismissUpdateFailure,
5352
6449
  DEV_VERSION: DEV_VERSION,
5353
6450
  DEV_FAIL_REASON: DEV_FAIL_REASON,
5354
6451
  DEV_CLICKS: DEV_CLICKS,