dsh-desktop-statusbar 2.1.1 → 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/README.en.md +132 -108
- package/README.md +130 -108
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/balance-api.js +60 -47
- package/lib/client.js +1604 -216
- package/lib/index.js +917 -303
- package/lib/release-age.js +509 -0
- package/lib/self-update.js +368 -0
- package/package.json +8 -2
- package/tools/plugin-dir.cjs +112 -0
- package/tools/shot.cjs +365 -0
- package/tools/sync-from-plugin.cjs +49 -0
- package/tools/sync-to-plugin.cjs +71 -0
- package/tools/test.cjs +99 -0
package/lib/client.js
CHANGED
|
@@ -50,6 +50,10 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
const PRICES_URL = "/dsh-desktop-statusbar/api/prices";
|
|
51
51
|
const HOLIDAYS_URL = "/dsh-desktop-statusbar/api/holidays";
|
|
52
52
|
const VERSION_URL = "/dsh-desktop-statusbar/api/version";
|
|
53
|
+
/** 一键更新:由本插件自己的 host 端执行(不依赖任何别的插件,见 self-update 说明)。 */
|
|
54
|
+
const SELF_UPDATE_URL = "/dsh-desktop-statusbar/api/self-update";
|
|
55
|
+
/** 客户端侧更新失败的上报端点(host 写进日志;宿主自己不知道浏览器里发生了什么)。 */
|
|
56
|
+
const UPDATE_FAILURE_URL = "/dsh-desktop-statusbar/api/update-failure";
|
|
53
57
|
/* 子智能体(Agent Teams 的队友)用量的聚合端点:队友是**独立会话**,它的消耗不在本会话
|
|
54
58
|
的投影里,只能由 host 按 parentSession 找出来,「总计」「本轮」才真的把队友算进去。
|
|
55
59
|
带 since(毫秒)= 只要那个时刻之后的用量,客户端拿它取「本轮」这一段。 */
|
|
@@ -58,15 +62,19 @@ window.__ModuleLoader__.load({
|
|
|
58
62
|
所以这里取 1 秒 —— 用户要的「实时跳动」;也不必为它新增定时器。
|
|
59
63
|
没有变化时后台只比对文件的 mtime/size(实测几毫秒),每秒重拉的开销可以忽略。 */
|
|
60
64
|
const SUBAGENT_REFRESH_MS = 1000;
|
|
61
|
-
/** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。
|
|
62
|
-
|
|
65
|
+
/** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。
|
|
66
|
+
上百个子智能体时 host 要把所有子会话日志解压聚合,实测 146 个活跃子会话首轮聚合
|
|
67
|
+
可以明显超过 8s;超时被当成失败后客户端会沿用"上一次成功的小值",界面就卡在偏小
|
|
68
|
+
数字上、等某一次偶然成功再跳大。所以把超时放宽到 20s(配合 host 侧按文件缓存,
|
|
69
|
+
热路径通常几百毫秒)。 */
|
|
70
|
+
const SUBAGENT_TIMEOUT_MS = 20000;
|
|
63
71
|
/**
|
|
64
72
|
* 配置结构版本。
|
|
65
73
|
* v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
|
|
66
74
|
*/
|
|
67
75
|
const CONFIG_VERSION = 6;
|
|
68
76
|
/** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
|
|
69
|
-
const VERSION = "2.
|
|
77
|
+
const VERSION = "2.2.0";
|
|
70
78
|
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
71
79
|
const PRICE_VERSION = 2;
|
|
72
80
|
/** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
|
|
@@ -650,6 +658,21 @@ window.__ModuleLoader__.load({
|
|
|
650
658
|
+ (bucket.output || 0) * tier.output) / 1e6;
|
|
651
659
|
}
|
|
652
660
|
|
|
661
|
+
/**
|
|
662
|
+
* 按**指定档位**算一段用量的费用(不按时刻判断)。返回未舍入数值;没单价返回 null。
|
|
663
|
+
* 专供 host 回传的 overflow 用 —— 那部分已经按档归好类(host 侧判的峰谷),
|
|
664
|
+
* 这里不能再拿一个时刻去猜档位。没勾「峰谷计价」的条目两档同价,取 peak 即可。
|
|
665
|
+
*/
|
|
666
|
+
function costOfTier(bucket, model, peak) {
|
|
667
|
+
const price = priceOf(model);
|
|
668
|
+
if (price === null || bucket === undefined || bucket === null) return null;
|
|
669
|
+
const tier = price.tiered === false ? price.peak : (peak === true ? price.peak : price.offPeak);
|
|
670
|
+
return ((bucket.input || 0) * tier.input
|
|
671
|
+
+ (bucket.cacheRead || 0) * tier.cacheRead
|
|
672
|
+
+ (bucket.cacheWrite || 0) * tier.cacheWrite
|
|
673
|
+
+ (bucket.output || 0) * tier.output) / 1e6;
|
|
674
|
+
}
|
|
675
|
+
|
|
653
676
|
/**
|
|
654
677
|
* 费用显示:一律两位小数,不满一分钱也进到 0.01(0.0075 → 0.01),不出现更长的小数位。
|
|
655
678
|
* 累加用 costOf 的原始值,只在这里做一次舍入。
|
|
@@ -852,13 +875,232 @@ window.__ModuleLoader__.load({
|
|
|
852
875
|
return { value: value, hold: { sessionId: sessionId, turnKey: turnKey, value: value === null ? floor : value } };
|
|
853
876
|
}
|
|
854
877
|
|
|
855
|
-
/**
|
|
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。 */
|
|
856
1097
|
function mergeTokenParts(parts, sub) {
|
|
857
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;
|
|
858
1100
|
return {
|
|
859
|
-
cached: base.cached +
|
|
860
|
-
missInput: base.missInput +
|
|
861
|
-
output: base.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)
|
|
862
1104
|
};
|
|
863
1105
|
}
|
|
864
1106
|
|
|
@@ -984,6 +1226,11 @@ window.__ModuleLoader__.load({
|
|
|
984
1226
|
updateTo: "更新 {version} 版本",
|
|
985
1227
|
updateRunning: "正在更新中",
|
|
986
1228
|
updateFailed: "更新失败",
|
|
1229
|
+
updateDone: "更新成功,重启 DSH 后生效",
|
|
1230
|
+
/* 开发模式:连点版本号 5 次进入。不做成可见控件 —— 它面向作者调试,
|
|
1231
|
+
普通用户装了插件也不该看到一个「预览」勾选框。进入后标题右侧挂一枚胶囊。 */
|
|
1232
|
+
devModeBadge: "开发模式",
|
|
1233
|
+
devModeHint: "开发模式:点更新按钮只演示状态,不会真的安装",
|
|
987
1234
|
sectionTitle: "状态栏设置",
|
|
988
1235
|
intro: "接管对话区底部的统计行。勾选要显示的字段、拖动调整顺序或配置模型费用单价。",
|
|
989
1236
|
segContext: "上下文占用",
|
|
@@ -1027,10 +1274,14 @@ window.__ModuleLoader__.load({
|
|
|
1027
1274
|
tipCountsSkills: "技能注入",
|
|
1028
1275
|
tipCountsCompactAt: "上下文压缩",
|
|
1029
1276
|
tipCountsToolCalls: "工具调用",
|
|
1277
|
+
/* 手动调用 / 手动压缩那行星号的悬停说明:星号自己 aria-hidden,含义靠这句文字给 */
|
|
1278
|
+
tipManual: "手动触发",
|
|
1030
1279
|
/* 压缩明细逐条列时的两段:序号用中文序数(第一次 / 第二次),落点用「第X轮Y步」 */
|
|
1031
1280
|
tipCompactItem: "第{index}次压缩",
|
|
1032
1281
|
tipCompactWhere: "第{turn}轮{step}步",
|
|
1033
1282
|
tipCompactTurnOnly: "第{turn}轮",
|
|
1283
|
+
/* 手动 / 轮间压缩没有 turn,用「发生在第几轮之后」给落点(afterTurn) */
|
|
1284
|
+
tipCompactAfter: "第{turn}轮后",
|
|
1034
1285
|
contextAria: "上下文已用 {percent}",
|
|
1035
1286
|
contextSystem: "系统提示词",
|
|
1036
1287
|
contextTools: "工具定义",
|
|
@@ -1045,6 +1296,15 @@ window.__ModuleLoader__.load({
|
|
|
1045
1296
|
f_lastCost: "本轮 {symbol}{cost}",
|
|
1046
1297
|
f_balance: "余额 {symbol}{amount}",
|
|
1047
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}",
|
|
1048
1308
|
dragHint: "拖动排序",
|
|
1049
1309
|
secSegments: "统计字段",
|
|
1050
1310
|
secSegmentsHint: "勾选显示;按住拖动调整顺序。",
|
|
@@ -1061,7 +1321,7 @@ window.__ModuleLoader__.load({
|
|
|
1061
1321
|
segTpsHint: "token 的每秒平均输出速度。",
|
|
1062
1322
|
segSessionTimeHint: "当前会话的总计运行时间。",
|
|
1063
1323
|
segCostHint: "当前会话估算消耗费用,包含主模型、子代理和辅助调用。",
|
|
1064
|
-
segLastCostHint: "
|
|
1324
|
+
segLastCostHint: "当前或最近一轮交流的估算消耗费用,包含主模型、子代理和辅助调用。",
|
|
1065
1325
|
segBalanceHint: "账户余额,每分钟刷新。",
|
|
1066
1326
|
priceCurrent: "当前会话使用:",
|
|
1067
1327
|
modelUnknown: "未识别",
|
|
@@ -1095,6 +1355,9 @@ window.__ModuleLoader__.load({
|
|
|
1095
1355
|
updateTo: "Update to {version}",
|
|
1096
1356
|
updateRunning: "Updating",
|
|
1097
1357
|
updateFailed: "Update failed",
|
|
1358
|
+
updateDone: "Update succeeded — restart DSH to apply",
|
|
1359
|
+
devModeBadge: "Dev mode",
|
|
1360
|
+
devModeHint: "Dev mode: the update button only simulates states — nothing is installed",
|
|
1098
1361
|
sectionTitle: "Status Bar Settings",
|
|
1099
1362
|
intro: "Replaces the stats line under the message input. Select the fields to show, drag to reorder, or set model prices.",
|
|
1100
1363
|
segContext: "Context usage",
|
|
@@ -1138,10 +1401,13 @@ window.__ModuleLoader__.load({
|
|
|
1138
1401
|
tipCountsSkills: "Skills injected",
|
|
1139
1402
|
tipCountsCompactAt: "Context compactions",
|
|
1140
1403
|
tipCountsToolCalls: "Tool calls",
|
|
1404
|
+
/* 星号的悬停说明(见中文那份的同名键) */
|
|
1405
|
+
tipManual: "Manually invoked",
|
|
1141
1406
|
/* 压缩明细逐条列时的两段:英文直接用阿拉伯数字,落点「Turn X·Step Y」 */
|
|
1142
1407
|
tipCompactItem: "Compaction {index}",
|
|
1143
1408
|
tipCompactWhere: "Turn {turn}·Step {step}",
|
|
1144
1409
|
tipCompactTurnOnly: "Turn {turn}",
|
|
1410
|
+
tipCompactAfter: "After turn {turn}",
|
|
1145
1411
|
contextAria: "{percent} of context used",
|
|
1146
1412
|
contextSystem: "System prompt",
|
|
1147
1413
|
contextTools: "Tool definitions",
|
|
@@ -1155,6 +1421,14 @@ window.__ModuleLoader__.load({
|
|
|
1155
1421
|
f_lastCost: "This turn {symbol}{cost}",
|
|
1156
1422
|
f_balance: "Balance {symbol}{amount}",
|
|
1157
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}",
|
|
1158
1432
|
dragHint: "Drag to reorder",
|
|
1159
1433
|
secSegments: "Stats fields",
|
|
1160
1434
|
secSegmentsHint: "Select to show; drag to reorder.",
|
|
@@ -1171,7 +1445,7 @@ window.__ModuleLoader__.load({
|
|
|
1171
1445
|
segTpsHint: "Average output rate, in tokens per second.",
|
|
1172
1446
|
segSessionTimeHint: "Total run time of the current session.",
|
|
1173
1447
|
segCostHint: "Estimated session cost, including the main model, subagents, and helper calls.",
|
|
1174
|
-
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.",
|
|
1175
1449
|
segBalanceHint: "Account balance, refreshed every minute.",
|
|
1176
1450
|
priceCurrent: "Current session:",
|
|
1177
1451
|
modelUnknown: "unknown",
|
|
@@ -1701,14 +1975,21 @@ window.__ModuleLoader__.load({
|
|
|
1701
1975
|
};
|
|
1702
1976
|
}
|
|
1703
1977
|
|
|
1704
|
-
/**
|
|
1978
|
+
/** 官方那种分段小条(分项面板里那条)。子代理「命中」那段挂 dsb-bar-sub(75% 浅灰)。 */
|
|
1705
1979
|
function meterBar(meter) {
|
|
1706
1980
|
return h("span", { className: "dsb-bar", style: barTrackStyle(meter.compact) },
|
|
1707
|
-
meter.segments.map((segment, index) =>
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
+
}));
|
|
1712
1993
|
}
|
|
1713
1994
|
|
|
1714
1995
|
/**
|
|
@@ -1814,19 +2095,53 @@ window.__ModuleLoader__.load({
|
|
|
1814
2095
|
};
|
|
1815
2096
|
}
|
|
1816
2097
|
|
|
1817
|
-
|
|
2098
|
+
/**
|
|
2099
|
+
* 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
|
|
2100
|
+
* 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
|
|
2101
|
+
* `sub` 是子代理(队友)的三段;有子代理用量时,条上拆成 6 小段:
|
|
2102
|
+
* **子代理块在左、主会话块在右**:[子·命中][子·未命中][子·输出][主·命中][主·未命中][主·输出]。
|
|
2103
|
+
* 子·命中用 75% 浅灰(与活跃总览高峰段同款),子·未命中 / 子·输出用正常主色;
|
|
2104
|
+
* **数字与行仍是合并口径**(主 + 子),不新增行。
|
|
2105
|
+
*/
|
|
2106
|
+
function tokenPanelView(cached, missInput, output, t, sub) {
|
|
1818
2107
|
const total = cached + missInput + output;
|
|
1819
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
|
+
};
|
|
1820
2116
|
const parts = [
|
|
1821
|
-
|
|
1822
|
-
{ label: t("
|
|
1823
|
-
{ label: t("
|
|
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 }
|
|
1824
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
|
+
});
|
|
1825
2142
|
return {
|
|
1826
2143
|
totalText: formatTokensExact(total),
|
|
1827
|
-
segments:
|
|
1828
|
-
.map((one) => ({ color: one.color, width: (one.value / total) * 100 }))
|
|
1829
|
-
.filter((one) => one.width > 0),
|
|
2144
|
+
segments: segments,
|
|
1830
2145
|
rows: parts.map((one) => ({
|
|
1831
2146
|
label: one.label,
|
|
1832
2147
|
color: one.color,
|
|
@@ -1852,6 +2167,102 @@ window.__ModuleLoader__.load({
|
|
|
1852
2167
|
]);
|
|
1853
2168
|
}
|
|
1854
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
|
+
|
|
1855
2266
|
/* ------------------------------------------------------ 活跃总览柱状图 */
|
|
1856
2267
|
/** 柱状图用的三色:与令牌面板的三段同色(命中缓存=灰 / 未命中=紫 / 输出=蓝)。 */
|
|
1857
2268
|
const WEEK_COLORS = {
|
|
@@ -1875,7 +2286,16 @@ window.__ModuleLoader__.load({
|
|
|
1875
2286
|
tool: "var(--dsw-static-blue-450)"
|
|
1876
2287
|
};
|
|
1877
2288
|
|
|
1878
|
-
/**
|
|
2289
|
+
/**
|
|
2290
|
+
* 日期键(YYYY-MM-DD),用来认「今天」那根柱子。
|
|
2291
|
+
*
|
|
2292
|
+
* 按**装机者所在时区**取,与 host 的 localDateKey 同一口径:使用者看的是自己
|
|
2293
|
+
* 日历上的今天/昨天。曾经固定用北京时间,UTC+8 装机时看不出差别,非 UTC+8 的
|
|
2294
|
+
* 机器上整块活跃总览会错一天(「今天」高亮成昨天、未来/过去的灰阶也偏)。
|
|
2295
|
+
*
|
|
2296
|
+
* 注意与 isPeakTime 区分:**峰谷计价**仍按北京时间,那是 DeepSeek 的官方规则;
|
|
2297
|
+
* 只有「日期归属」跟着装机者时区走。
|
|
2298
|
+
*/
|
|
1879
2299
|
function dateKeyOf(ms) {
|
|
1880
2300
|
const d = new Date(ms === undefined || ms === null ? Date.now() : ms);
|
|
1881
2301
|
const pad = (n) => (n < 10 ? "0" + n : String(n));
|
|
@@ -1923,6 +2343,73 @@ window.__ModuleLoader__.load({
|
|
|
1923
2343
|
]);
|
|
1924
2344
|
}
|
|
1925
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
|
+
|
|
1926
2413
|
/**
|
|
1927
2414
|
* 悬停浮层的内容:日期(或周区间)+ 三项总计 → 命中/未命中/输出三行 → API 请求次数 → 消费金额。
|
|
1928
2415
|
* 六行全部走「名称左、数值右」的双边对齐;数量精确到个位并带千分位,不做缩写。
|
|
@@ -2128,7 +2615,6 @@ window.__ModuleLoader__.load({
|
|
|
2128
2615
|
const hasPeak = pkHit + pkMiss + pkOut > 0;
|
|
2129
2616
|
return h("span", {
|
|
2130
2617
|
className: "dsb-day"
|
|
2131
|
-
+ (day.date === tip.today ? " dsb-day-today" : "")
|
|
2132
2618
|
+ (activeBar === index ? " dsb-day-active" : ""),
|
|
2133
2619
|
key: "__wd" + index,
|
|
2134
2620
|
/* 悬停整列都让判定区变灰 —— 没有用量的天也一样;浮层只在有用量时弹 */
|
|
@@ -2172,9 +2658,7 @@ window.__ModuleLoader__.load({
|
|
|
2172
2658
|
style: panelStyle(pos),
|
|
2173
2659
|
/* 吃下鼠标事件:不穿透到下面,也不冒泡回去把气泡关掉 */
|
|
2174
2660
|
onClick: stopTipClick,
|
|
2175
|
-
onMouseDown: stopTipClick
|
|
2176
|
-
/* 诊断:定位回调把「模式 + 基准 + 结果」写在这里(不可见),同时会回传给 host 落盘 */
|
|
2177
|
-
"data-dsb-align": pos === null || pos === undefined || pos.align === undefined ? undefined : pos.align
|
|
2661
|
+
onMouseDown: stopTipClick
|
|
2178
2662
|
}, [
|
|
2179
2663
|
h("span", { className: "dsb-meter-head", key: "__wh" }, [
|
|
2180
2664
|
h("span", { className: "dsb-meter-headline", key: "__wt" }, tip.title)
|
|
@@ -2189,22 +2673,72 @@ window.__ModuleLoader__.load({
|
|
|
2189
2673
|
}
|
|
2190
2674
|
|
|
2191
2675
|
/**
|
|
2192
|
-
*
|
|
2193
|
-
*
|
|
2676
|
+
* 长列表分列:**每一竖列最多三行**,超过就多开一竖列(列数不设上限)。
|
|
2677
|
+
* 列网格是 `flex-wrap:nowrap`(用户拍板:**绝不折行**),一排放不下就横向滚动;
|
|
2678
|
+
* 在那之前先**逐档加每列行数**(3 → 4 → 5 …,上限见 LIST_ROWS_PER_COLUMN_MAX)——
|
|
2679
|
+
* 行数一加,列数就变少,放得下的机会更大,而且气泡宽度会跟着收窄(用户称"理解 B")。
|
|
2194
2680
|
*/
|
|
2195
2681
|
const LIST_ROWS_PER_COLUMN = 3;
|
|
2196
|
-
|
|
2197
|
-
|
|
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;
|
|
2198
2687
|
const out = [];
|
|
2199
2688
|
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size));
|
|
2200
2689
|
return out;
|
|
2201
2690
|
}
|
|
2202
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
|
+
|
|
2203
2737
|
/**
|
|
2204
2738
|
* 两列面板:标题 + 主数值,下面若干「名称 / 数值」行(用时、极值、命中率、余额、会话数据这类段用)。
|
|
2205
2739
|
* 行上带 sub 的(会话数据那三行)点一下就把整个气泡换成子视图:标题变成子视图名、内容换成列表,
|
|
2206
2740
|
* 子视图沿用主气泡的字号字色(不是二次气泡),点标题回到上一层。
|
|
2207
|
-
*
|
|
2741
|
+
* 子视图的列表每列最多三行、超了往右加列(工具调用那档按量测逐档加到 4、5 … 直到不折行)。
|
|
2208
2742
|
*/
|
|
2209
2743
|
function rowsPanel(tip, pos, ref, sub) {
|
|
2210
2744
|
const rows = tip.rows === undefined ? [] : tip.rows;
|
|
@@ -2213,7 +2747,8 @@ window.__ModuleLoader__.load({
|
|
|
2213
2747
|
? null
|
|
2214
2748
|
: rows[openedAt].sub;
|
|
2215
2749
|
/* 一行里的三块:色块(可选,与上面那条色条一一对应)+ 名称 + 数值。
|
|
2216
|
-
|
|
2750
|
+
名称也可能是节点数组(手动调用 / 手动压缩的名字后要跟上标星号,压缩的星号还得夹在
|
|
2751
|
+
序号后面 —— 拼成字符串就插不进去了),这里原样交给 React 当子节点。
|
|
2217
2752
|
可点行与普通行共用这一份,别各写一遍。 */
|
|
2218
2753
|
const parts = (row, key) => [
|
|
2219
2754
|
row.color === undefined || row.color === null
|
|
@@ -2242,7 +2777,14 @@ window.__ModuleLoader__.load({
|
|
|
2242
2777
|
}, parts(row, "__xr" + index));
|
|
2243
2778
|
}));
|
|
2244
2779
|
} else {
|
|
2245
|
-
|
|
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);
|
|
2246
2788
|
wide = columns.length > 1;
|
|
2247
2789
|
/* 一列时不套外层(与其它面板的行结构完全一致),分列才多一层;
|
|
2248
2790
|
子视图带 tight 的(工具调用)另挂一个类,把名字与数值的间隙收紧 */
|
|
@@ -2256,7 +2798,11 @@ window.__ModuleLoader__.load({
|
|
|
2256
2798
|
: h("span", { className: cls, key: "__xrows" },
|
|
2257
2799
|
(columns[0] === undefined ? [] : columns[0]).map((row, index) => cell(row, "__r" + index)));
|
|
2258
2800
|
}
|
|
2259
|
-
|
|
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 : []);
|
|
2260
2806
|
return h("span", {
|
|
2261
2807
|
className: wide === true ? "dsb-tip dsb-meter dsb-meter-wide" : "dsb-tip dsb-meter",
|
|
2262
2808
|
key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick
|
|
@@ -2277,12 +2823,42 @@ window.__ModuleLoader__.load({
|
|
|
2277
2823
|
? null
|
|
2278
2824
|
: h("span", { className: "dsb-meter-figures", key: "__xhf" }, tip.head)
|
|
2279
2825
|
]),
|
|
2280
|
-
/*
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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
|
+
}))
|
|
2286
2862
|
: h("span", { className: "dsb-meter-divider", key: "__xdiv" }),
|
|
2287
2863
|
body
|
|
2288
2864
|
]);
|
|
@@ -2308,9 +2884,35 @@ window.__ModuleLoader__.load({
|
|
|
2308
2884
|
});
|
|
2309
2885
|
}
|
|
2310
2886
|
|
|
2311
|
-
/**
|
|
2312
|
-
|
|
2313
|
-
|
|
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);
|
|
2314
2916
|
return { kind: "tokens", title: title, panel: panel === null ? dashTokenPanel(t) : panel };
|
|
2315
2917
|
}
|
|
2316
2918
|
|
|
@@ -2407,109 +3009,152 @@ window.__ModuleLoader__.load({
|
|
|
2407
3009
|
return head + (rest < 10 ? "零" + DIGITS[rest] : under100(rest));
|
|
2408
3010
|
}
|
|
2409
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
|
+
|
|
2410
3030
|
/** 子视图没有内容时给一行横杠:气泡任何情况下都要打得开,也不能是一片空白。 */
|
|
2411
3031
|
function emptyList(title) {
|
|
2412
3032
|
return { title: title, rows: [{ label: title, value: "-" }] };
|
|
2413
3033
|
}
|
|
2414
3034
|
|
|
2415
|
-
/**
|
|
2416
|
-
|
|
2417
|
-
|
|
3035
|
+
/**
|
|
3036
|
+
* 技能注入子视图:只列名字,**不显示统计次数**(用户明确要求:次数只用来排序)。
|
|
3037
|
+
* 同名注入在内部聚合计数,排序 = **名字短的在前** → 注入次数多的在前 → 字典序;
|
|
3038
|
+
* 次数只参与排序,不进界面(与工具调用「次」那套无关)。
|
|
3039
|
+
* 手动调用的技能(`manual: true`)名字后跟上标星号;只要其中有一次是手动就算。
|
|
3040
|
+
* 全局提示词(AGENTS.md 这类)**固定排第一**:它也是注入的一种,但不是技能调用,
|
|
3041
|
+
* 不参与上面那套技能排序;其余技能照常排。
|
|
3042
|
+
*/
|
|
2418
3043
|
function skillList(skills, globalInstruction, t) {
|
|
2419
3044
|
const title = t("tipCountsSkills");
|
|
2420
3045
|
const names = [];
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
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);
|
|
2426
3069
|
});
|
|
2427
3070
|
}
|
|
2428
3071
|
if (names.length === 0) return emptyList(title);
|
|
2429
|
-
|
|
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
|
+
};
|
|
2430
3084
|
}
|
|
2431
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
|
+
|
|
2432
3095
|
/**
|
|
2433
|
-
*
|
|
2434
|
-
*
|
|
2435
|
-
*
|
|
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`(两位小数占比),给状态栏的悬停提示用。
|
|
2436
3104
|
*/
|
|
2437
|
-
const TOOL_LABELS_ZH = {
|
|
2438
|
-
pwsh: "命令行",
|
|
2439
|
-
bash: "命令行",
|
|
2440
|
-
read: "读取文件",
|
|
2441
|
-
write: "写入文件",
|
|
2442
|
-
edit: "修改文件",
|
|
2443
|
-
glob: "查找文件",
|
|
2444
|
-
grep: "搜索内容",
|
|
2445
|
-
todo_write: "待办清单",
|
|
2446
|
-
skill: "技能",
|
|
2447
|
-
subagent: "子智能体",
|
|
2448
|
-
subagent_fork: "子智能体(继承)",
|
|
2449
|
-
ask_user_question: "询问用户",
|
|
2450
|
-
create_goal: "新建目标",
|
|
2451
|
-
update_goal: "更新目标",
|
|
2452
|
-
get_goal: "查看目标",
|
|
2453
|
-
present: "展示文件",
|
|
2454
|
-
web_search: "联网搜索",
|
|
2455
|
-
web_fetch: "抓取网页",
|
|
2456
|
-
job_list: "后台任务列表",
|
|
2457
|
-
job_output: "后台任务输出",
|
|
2458
|
-
job_kill: "取消后台任务",
|
|
2459
|
-
spawn_teammate: "新增队友",
|
|
2460
|
-
send_message: "发送消息",
|
|
2461
|
-
wait_agent: "等待队友",
|
|
2462
|
-
interrupt_agent: "中断队友",
|
|
2463
|
-
list_agents: "队友列表",
|
|
2464
|
-
team_task_create: "新建任务",
|
|
2465
|
-
team_task_list: "任务列表",
|
|
2466
|
-
team_task_get: "查看任务",
|
|
2467
|
-
team_task_update: "更新任务",
|
|
2468
|
-
exit_plan_mode: "退出计划模式",
|
|
2469
|
-
load_workspace_dependencies: "加载工作区依赖",
|
|
2470
|
-
find_dsh_plugin: "查找插件",
|
|
2471
|
-
workflow: "工作流"
|
|
2472
|
-
};
|
|
2473
|
-
|
|
2474
|
-
/** 工具名 → 显示名:中文界面查表(查不到给原名),英文界面直接用原名。 */
|
|
2475
|
-
function toolLabel(name, t) {
|
|
2476
|
-
if (detectLocale(t) !== "zh") return name;
|
|
2477
|
-
const label = TOOL_LABELS_ZH[name];
|
|
2478
|
-
return label === undefined ? name : label;
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
|
-
/** 「工具调用」子视图:**先按显示名长短(短的在前),同名长短再按调用次数降序**。
|
|
2482
|
-
名字短、列多,标 tight 让名字与次数之间的间隙收到 1ch。 */
|
|
2483
3105
|
function toolList(counts, t) {
|
|
2484
3106
|
const title = t("tipCountsToolCalls");
|
|
2485
3107
|
if (counts === null || typeof counts !== "object") return Object.assign(emptyList(title), { tight: true });
|
|
2486
|
-
const
|
|
2487
|
-
.map((name) => ({ label:
|
|
3108
|
+
const entries = Object.keys(counts)
|
|
3109
|
+
.map((name) => ({ label: name, count: Number(counts[name]) || 0 }))
|
|
2488
3110
|
.filter((one) => one.count > 0)
|
|
2489
3111
|
.sort((a, b) => a.label.length - b.label.length
|
|
2490
3112
|
|| b.count - a.count
|
|
2491
|
-
|| (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)))
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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 };
|
|
2495
3128
|
}
|
|
2496
3129
|
|
|
2497
|
-
/**
|
|
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
|
+
*/
|
|
2498
3140
|
function compactList(compacts, t) {
|
|
2499
3141
|
const title = t("tipCountsCompactAt");
|
|
2500
|
-
if (compacts === null || compacts.length === 0) return emptyList(title);
|
|
3142
|
+
if (compacts === null || compacts === undefined || compacts.length === 0) return emptyList(title);
|
|
2501
3143
|
const ordinal = detectLocale(t) === "zh";
|
|
2502
3144
|
const rows = compacts.map((one, at) => {
|
|
2503
3145
|
const index = at + 1;
|
|
2504
3146
|
const word = ordinal === true ? chineseNumber(index) : null;
|
|
3147
|
+
const text = word === null ? String(index) : word;
|
|
2505
3148
|
const mark = one === null || one === undefined ? {} : one;
|
|
2506
3149
|
const turn = typeof mark.turn === "number" ? mark.turn : null;
|
|
2507
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 });
|
|
2508
3153
|
return {
|
|
2509
|
-
label:
|
|
2510
|
-
value: turn
|
|
2511
|
-
? "
|
|
2512
|
-
: (
|
|
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 }))
|
|
2513
3158
|
};
|
|
2514
3159
|
});
|
|
2515
3160
|
return { title: title, rows: rows };
|
|
@@ -2520,6 +3165,150 @@ window.__ModuleLoader__.load({
|
|
|
2520
3165
|
return typeof value === "string" && value.length > 0 ? value : "0.00";
|
|
2521
3166
|
}
|
|
2522
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
|
+
|
|
2523
3312
|
/** 单次调用的缓存命中率(%);这次调用一个输入 token 都没有时返回 null(算不出比例)。 */
|
|
2524
3313
|
function hitRateOf(call) {
|
|
2525
3314
|
const denominator = (call.input || 0) + (call.cacheRead || 0) + (call.cacheWrite || 0);
|
|
@@ -2803,33 +3592,51 @@ window.__ModuleLoader__.load({
|
|
|
2803
3592
|
const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
|
|
2804
3593
|
? []
|
|
2805
3594
|
: sessionUsage.calls;
|
|
2806
|
-
/* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
|
|
2807
|
-
累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。
|
|
2808
|
-
**每条还要按它自己条目的币种折到显示币种**:会话里 A 是人民币、B 是美元时,
|
|
2809
|
-
底栏给的是一个数字(折到主模型那种币),不是两种货币相加。 */
|
|
2810
3595
|
const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
/*
|
|
2821
|
-
host 按 parentSession
|
|
2822
|
-
不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。
|
|
2823
|
-
两段数据(整会话 / 本轮窗口)各自拿到才用,没拿到的那段当 0。 */
|
|
3596
|
+
/* 被 host 的 MAX_CALLS 截掉的那部分(host 按「模型 + 峰谷档」累计好给回来)。
|
|
3597
|
+
不补这一段的话,长会话的费用只算了最近 4000 条 —— 实测 5000 次少 20%、
|
|
3598
|
+
2 万次少 80%,而「总计用量」用的是官方全量数据,两者会在同一个界面上对不上。
|
|
3599
|
+
档位由 host 按北京时间判定(与逐条计价同一套规则),所以这里直接按档取价即可。 */
|
|
3600
|
+
const overflow = sessionUsage === undefined || sessionUsage === null
|
|
3601
|
+
|| sessionUsage.overflow === undefined || sessionUsage.overflow === null
|
|
3602
|
+
|| typeof sessionUsage.overflow !== "object"
|
|
3603
|
+
? {}
|
|
3604
|
+
: sessionUsage.overflow;
|
|
3605
|
+
/* 队友(子智能体)是**独立会话**,它的用量不在本会话调用表里,
|
|
3606
|
+
host 按 parentSession 算好单独给回来;这里把金额并进「总计」,
|
|
3607
|
+
不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。 */
|
|
2824
3608
|
const sub = subagentOf(src.subagent, "session");
|
|
2825
3609
|
const subCost = subagentCostOf(src.subagent, "session", symbolCurrency);
|
|
2826
|
-
|
|
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
|
+
});
|
|
2827
3633
|
return {
|
|
2828
3634
|
id: id,
|
|
2829
|
-
/*
|
|
2830
|
-
text: t("f_cost", { symbol: symbol, cost:
|
|
2831
|
-
/* token 计数并进总计:本会话的 +
|
|
2832
|
-
|
|
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)
|
|
2833
3640
|
};
|
|
2834
3641
|
}
|
|
2835
3642
|
|
|
@@ -2868,7 +3675,7 @@ window.__ModuleLoader__.load({
|
|
|
2868
3675
|
const subTurn = subagentOf(src.subagent, "turn");
|
|
2869
3676
|
/* 队友这一段折到显示币种后的金额(逐桶折算,见 subagentDisplayCost) */
|
|
2870
3677
|
const subTurnCost = subagentCostOf(src.subagent, "turn", symbolCurrency);
|
|
2871
|
-
const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t);
|
|
3678
|
+
const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t, subTurn);
|
|
2872
3679
|
/* 「本轮」同轮内只增不减(用户实测要求):把这次的金额过一遍粘性下限。
|
|
2873
3680
|
窗口起点在一轮内可能移动(父会话日志多一条 user/message、折叠出的最早时间戳变化、
|
|
2874
3681
|
host 收敛起点),窗口一变数字就会小一截;这里把已经显示过的最大值兜住,
|
|
@@ -2995,12 +3802,19 @@ window.__ModuleLoader__.load({
|
|
|
2995
3802
|
const balSymbol = balance.isPercent === true || isCredits === true
|
|
2996
3803
|
? ""
|
|
2997
3804
|
: currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
|
|
2998
|
-
/*
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3805
|
+
/* 积分口径:段只显示总积分;**宿主给了非空 rewards 时**才生成「积分套餐」气泡
|
|
3806
|
+
(kind:'credits',每个套餐一行 + 一条已用进度条,面板外壳与令牌面板同一套样式)。
|
|
3807
|
+
rewards 为空 / 缺字段 / 旧插件 → 照旧不给 tip:没有可展开的内容,
|
|
3808
|
+
弹一个只有标题的空气泡只会让人以为还能点。 */
|
|
3002
3809
|
if (isCredits === true) {
|
|
3003
|
-
|
|
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
|
+
};
|
|
3004
3818
|
}
|
|
3005
3819
|
/* 分隔线换成色条,两种情况各一套:
|
|
3006
3820
|
金额余额(DeepSeek 等)= 充值 : 赠金,按金额占比分两段(紫 / 蓝);
|
|
@@ -3086,8 +3900,9 @@ window.__ModuleLoader__.load({
|
|
|
3086
3900
|
* 两条不依赖官方构建 hash 的要点:
|
|
3087
3901
|
* 1. 用 .dsb-root 认自己,而不是官方组件的类名(hash 会随 DSH 更新变,2.0.10 踩过);
|
|
3088
3902
|
* 2. 自己带标记与“后代里带标记”都要判 —— :has() 只看后代,漏掉前者会把自己的行也藏掉。
|
|
3089
|
-
* 整条规则挂在 html.dsb-on
|
|
3090
|
-
*
|
|
3903
|
+
* 整条规则挂在 html.dsb-on 上:这个类由插件挂载/卸载时增删(设置页里
|
|
3904
|
+
* **没有**「启用状态栏」开关,别按那个理解)。卸下后官方原本的统计行与
|
|
3905
|
+
* 上下文按钮随即恢复(否则关掉插件只剩一片空白)。 */
|
|
3091
3906
|
'html.dsb-on [data-slot="conversation.composer.dock"] > *:not(.dsb-root):not(:has(.dsb-root)){display:none !important}',
|
|
3092
3907
|
/* 官方新版在输入卡片下方右侧另有一个上下文占用按钮:已被本插件的圆环接管,藏掉它。
|
|
3093
3908
|
* 用 aria-label 认它(中文含"上下文"、英文含"context used",随语言变),不碰官方 hash 类名。 */
|
|
@@ -3114,8 +3929,35 @@ window.__ModuleLoader__.load({
|
|
|
3114
3929
|
"@media (prefers-reduced-motion: reduce){.dsb-ring-running .dsb-ring-fill{animation:none}}",
|
|
3115
3930
|
".dsb-ring-error .dsb-ring-fill{stroke:#ef4444}",
|
|
3116
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 负责。 */
|
|
3117
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}",
|
|
3118
|
-
|
|
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}",
|
|
3119
3961
|
/* 宽度按内容自适应:width:max-content 让气泡收到"最长那一行"的宽度,
|
|
3120
3962
|
* 行内标签与数值之间至少留 5ch(.dsb-meter-value 的 padding-left),所以不会出现大片留白;
|
|
3121
3963
|
* 内容超过 max-width 时退回换行(white-space:normal)。 */
|
|
@@ -3142,6 +3984,21 @@ window.__ModuleLoader__.load({
|
|
|
3142
3984
|
/* 两列面板里的行:标签与数值之间至少留 5ch(工具调用那个子视图另有一条更紧的覆盖,
|
|
3143
3985
|
见 .dsb-meter-tight),其余行在这个宽度里右对齐。 */
|
|
3144
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}",
|
|
3145
4002
|
/* 工具调用子视图:名字短、列多,5ch 太占宽度,收到 1ch */
|
|
3146
4003
|
".dsb-meter-tight .dsb-meter-value{padding-left:1ch}",
|
|
3147
4004
|
/* 可点行(会话数据那几行):光标 + 悬停底色,跟设置页那些可点项同一套反馈。
|
|
@@ -3149,13 +4006,28 @@ window.__ModuleLoader__.load({
|
|
|
3149
4006
|
上下内边距跟 .dsb-meter-row 保持一致(1/3),可点与不可点的行文字才不会错位。 */
|
|
3150
4007
|
".dsb-meter-row-click{cursor:pointer;margin:0 -4px;padding:1px 4px 3px;border-radius:999px}",
|
|
3151
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}",
|
|
3152
4012
|
/* 子视图标题就是返回入口:不加返回箭头,悬停变亮 */
|
|
3153
4013
|
".dsb-meter-back{cursor:pointer}",
|
|
3154
4014
|
".dsb-meter-back:hover{color:var(--dsw-alias-label-secondary)}",
|
|
3155
4015
|
/* 子视图列表分列:每列最多三行(由 listColumns 决定),每列自己两列对齐。
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
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}",
|
|
3159
4031
|
".dsb-tip.dsb-meter-wide{max-width:min(880px,100vw - 24px)}",
|
|
3160
4032
|
/* 活跃总览:标题 → 分隔线 → 数值行 → 左边柱状图 + 右边当月活跃度 */
|
|
3161
4033
|
".dsb-week{display:flex;flex-direction:column;gap:4px}",
|
|
@@ -3213,9 +4085,9 @@ window.__ModuleLoader__.load({
|
|
|
3213
4085
|
".dsb-tip.dsb-hovercard .dsb-meter-rows{margin-top:2px}",
|
|
3214
4086
|
".dsb-tip.dsb-hovercard .dsb-meter-row{padding:0;gap:4px}",
|
|
3215
4087
|
".dsb-tip.dsb-hovercard .dsb-meter-figures{font-weight:400;padding-left:5ch}",
|
|
3216
|
-
/* 柱的悬停:::before 只负责把热区左右各撑 2px
|
|
3217
|
-
|
|
3218
|
-
|
|
4088
|
+
/* 柱的悬停:::before 只负责把热区左右各撑 2px(透明、看不见);
|
|
4089
|
+
变灰反馈由下面的 `.dsb-day-active .dsb-day-track` 画(JS 状态加类,
|
|
4090
|
+
严格不超出柱身那 5px)。用 :hover 的话划过柱间缝隙会一闪一闪。 */
|
|
3219
4091
|
".dsb-day{position:relative;display:flex;flex-direction:column;align-items:center;gap:3px;width:5px}",
|
|
3220
4092
|
/* 热区:左右各撑 2px,透明看不见(让鼠标不会掉进柱间 4px 的缝) */
|
|
3221
4093
|
".dsb-day::before{content:\"\";position:absolute;left:-2px;right:-2px;top:0;bottom:0;border-radius:3px}",
|
|
@@ -3343,7 +4215,6 @@ window.__ModuleLoader__.load({
|
|
|
3343
4215
|
又排在后面,会把高度覆盖回去(实测踩过:用户看到"又变高了")。
|
|
3344
4216
|
自定义模型的名称框走通用规则,保持 28px。 */
|
|
3345
4217
|
".dsb-settings .dsb-field input[type=text],.dsb-settings .dsb-field input[type=number]{width:52px;height:24px;text-align:right}",
|
|
3346
|
-
".dsb-settings input[type=number]{text-align:right}",
|
|
3347
4218
|
".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
|
|
3348
4219
|
".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
|
|
3349
4220
|
".dsb-settings input::placeholder{color:var(--dsw-alias-label-tertiary);opacity:.5}",
|
|
@@ -3371,6 +4242,20 @@ window.__ModuleLoader__.load({
|
|
|
3371
4242
|
".dsb-settings .dsb-star-wrap:hover .dsb-star-tip,.dsb-settings .dsb-star-wrap:focus-within .dsb-star-tip{visibility:visible;opacity:1}",
|
|
3372
4243
|
/* 导航项后面的「发现新版本」胶囊:细描边圆角胶囊 + 次级文字色,字号刻意比导航文字小一档 */
|
|
3373
4244
|
".dsb-nav-pill{box-sizing:border-box;margin-left:4px;padding:0 5px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:999px;font-size:8px;line-height:1.4;font-weight:400;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
|
|
4245
|
+
/* 开发模式**不给可见开关**:入口是页脚版本号的「连点 5 次」。
|
|
4246
|
+
胶囊做成标题的上标角标(像 x² 的那个 2):
|
|
4247
|
+
· 行内 flex + 靠左对齐 → 胶囊紧跟着标题文字,不甩到面板最右;
|
|
4248
|
+
· align-items:flex-start → 胶囊浮在标题文字的右上角,而不是垂直居中。
|
|
4249
|
+
三个刻意的取值:
|
|
4250
|
+
· font-size 与「发现新版本」那枚导航胶囊**逐字一致**(都走 .dsb-nav-pill 的 8px),
|
|
4251
|
+
所以这里不覆盖字号 —— 覆盖过一次(10px)就显得比导航那枚大,两处观感不统一。
|
|
4252
|
+
· **不做负 margin-top**:父级 .dsb-head 是纵向 flex,把胶囊往上顶出容器会被裁掉
|
|
4253
|
+
(实测缺陷:胶囊上边框被切)。
|
|
4254
|
+
· 用 `margin-top:2px` **往下压一点**:标题是 18px、行盒有约 2px 的行距余量,
|
|
4255
|
+
胶囊要落在这段余量里,顶端才不高于标题文字的上边界。 */
|
|
4256
|
+
".dsb-settings .dsb-titlerow{display:flex;align-items:flex-start;justify-content:flex-start;gap:4px}",
|
|
4257
|
+
".dsb-settings .dsb-dev-pill{margin-left:0;margin-top:2px;padding:0 5px;cursor:default}",
|
|
4258
|
+
".dsb-settings .dsb-version{user-select:none;-webkit-user-select:none}",
|
|
3374
4259
|
].join("");
|
|
3375
4260
|
|
|
3376
4261
|
/* 导航图标使用外部传入的 gauge(24x24 lucide,stroke=currentColor,随主题变色) */
|
|
@@ -3383,15 +4268,41 @@ window.__ModuleLoader__.load({
|
|
|
3383
4268
|
/* 导航项里挂「发现新版本」胶囊用:标记本插件那一项,避免胶囊文字混进文案后匹配不上。 */
|
|
3384
4269
|
const NAV_MARK = "data-dsb-nav";
|
|
3385
4270
|
|
|
3386
|
-
/* 宿主上下文(apply 时存下来):一键更新要用它的 remote 命名空间。 */
|
|
3387
|
-
let pluginCtx = null;
|
|
3388
|
-
|
|
3389
4271
|
/**
|
|
3390
4272
|
* 版本自检结果 —— 导航胶囊和设置页那颗「更新」按钮共用这一份,两边同进同出。
|
|
3391
4273
|
* 默认什么都没有:只有 registry 上确实更新才显示,查不到就安静地不显示。
|
|
3392
|
-
* note 是更新动作的结果:null / "done" / "failed"
|
|
4274
|
+
* note 是更新动作的结果:null / "done" / "failed"。
|
|
4275
|
+
* failedTarget 记「失败的那次试的是哪个版本」:失败态点一下退回可重试时,
|
|
4276
|
+
* 文案要回到那个版本号(而不是 last 里可能已被刷新的别的版本)。
|
|
3393
4277
|
*/
|
|
3394
|
-
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 };
|
|
4279
|
+
|
|
4280
|
+
/**
|
|
4281
|
+
* 开发模式(连点版本号 5 次进入):让作者在没有新版本的时候也能看到更新按钮的
|
|
4282
|
+
* **全部**状态与动效,用来检查「点下去有没有反馈、文案看不看得懂、失败会不会静默」。
|
|
4283
|
+
*
|
|
4284
|
+
* 打开后:
|
|
4285
|
+
* - 按钮**常驻**显示(不看 registry 有没有新版),版本号用一个假的演示版本;
|
|
4286
|
+
* - 每点一次,依次演示「更新中 → 已更新 → 更新失败 → 回到空闲」四种状态;
|
|
4287
|
+
* - **绝不发安装请求**:走的是纯本地的状态机,不碰任何网络与子进程。
|
|
4288
|
+
*
|
|
4289
|
+
* 为什么不给一个可见的开关:普通用户装了这插件,界面上不该多出一个面向作者的调试项。
|
|
4290
|
+
* 入口做成「连点版本号 5 次」——记得住、够隐蔽,也不会被误触(这是常见的开发者模式手势)。
|
|
4291
|
+
* 进入后标题右侧挂一枚「开发模式」胶囊,明确告诉你当前处于这个状态;再连点 5 次退出。
|
|
4292
|
+
*
|
|
4293
|
+
* 它只影响本插件的界面,不改变任何真实配置;退出即恢复原样。
|
|
4294
|
+
*/
|
|
4295
|
+
let devMode = false;
|
|
4296
|
+
/** 演示用的假版本号:一眼看出不是真版本,避免被误当成真实更新。 */
|
|
4297
|
+
const DEV_VERSION = "9.9.9";
|
|
4298
|
+
/** 演示用的假失败原因,走的就是真实失败时那条显示路径。 */
|
|
4299
|
+
const DEV_FAIL_REASON = "release-age";
|
|
4300
|
+
/** 「更新中」演示的时长:够看清三个点的动效,又不至于让人等。 */
|
|
4301
|
+
const DEV_BUSY_MS = 2200;
|
|
4302
|
+
/** 进入/退出开发模式需要连点几次版本号。 */
|
|
4303
|
+
const DEV_CLICKS = 5;
|
|
4304
|
+
/** 连点计数:两次点击间隔超过这个毫秒数就重新计数(否则慢慢点 5 次也会进去)。 */
|
|
4305
|
+
const DEV_CLICK_WINDOW_MS = 1500;
|
|
3395
4306
|
const updateListeners = new Set();
|
|
3396
4307
|
function subscribeUpdate(fn) {
|
|
3397
4308
|
updateListeners.add(fn);
|
|
@@ -3413,7 +4324,13 @@ window.__ModuleLoader__.load({
|
|
|
3413
4324
|
if (has !== null && typeof has.remove === "function") has.remove();
|
|
3414
4325
|
return;
|
|
3415
4326
|
}
|
|
3416
|
-
|
|
4327
|
+
/* 已经挂上就只更新文字。**必须先比值**:写 textContent 即使内容一样也会
|
|
4328
|
+
替换文本节点、产生 childList 记录,而上面那个 observer 监听的正是
|
|
4329
|
+
childList+subtree —— 不比较就会「写 → 触发 → 下一帧再写」一直空转。 */
|
|
4330
|
+
if (has !== null) {
|
|
4331
|
+
if (has.textContent !== text) has.textContent = text;
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
3417
4334
|
const pill = document.createElement("span");
|
|
3418
4335
|
pill.className = "dsb-nav-pill";
|
|
3419
4336
|
pill.textContent = text;
|
|
@@ -3436,38 +4353,223 @@ window.__ModuleLoader__.load({
|
|
|
3436
4353
|
.catch(() => null);
|
|
3437
4354
|
}
|
|
3438
4355
|
|
|
3439
|
-
/**
|
|
4356
|
+
/** 从更新结果里抠出人能看懂的原因;拿不到就给空串。 */
|
|
3440
4357
|
function reasonOf(result) {
|
|
3441
4358
|
const raw = result === null || result === undefined ? null : result.reason;
|
|
3442
4359
|
return typeof raw === "string" && raw.length > 0 ? raw : "";
|
|
3443
4360
|
}
|
|
3444
4361
|
|
|
3445
4362
|
/**
|
|
3446
|
-
*
|
|
3447
|
-
*
|
|
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
|
+
|
|
4383
|
+
/**
|
|
4384
|
+
* 一键更新:调**本插件自己的** host 路由,由它自己跑 pnpm 把新版本装上。
|
|
4385
|
+
*
|
|
4386
|
+
* 为什么不用 DSH 官方的 `ctx.remote.pluginManager.installBundle`:
|
|
4387
|
+
* 1. 那个服务由「插件管理器」这个**别的插件**提供。没装它的 profile 上,
|
|
4388
|
+
* 读 `ctx.remote` 会直接抛错(DSH 的依赖注入对未声明服务就这个行为),
|
|
4389
|
+
* 异常冒出 onClick 没人接 —— 界面表现就是「点了完全没反应」。
|
|
4390
|
+
* 2. 它跑裸 `pnpm add pkg@版本`,会被 pnpm 11 的 24 小时发布年龄策略拦下
|
|
4391
|
+
* (报 NO_MATURE_MATCHING_VERSION),新版本刚发布时装不上。
|
|
4392
|
+
*
|
|
4393
|
+
* 所以更新必须由插件自己完成:host 端先把这个精确版本写进 profile 的
|
|
4394
|
+
* `minimumReleaseAgeExclude` 放行,再用精确版本安装。
|
|
4395
|
+
*
|
|
4396
|
+
* **装完要重启 DSH,光刷新页面不生效**:插件包元数据(含 client.js 的 revision)
|
|
4397
|
+
* 在 DSH 里「缓存至重启」(见 dsh-client-modules 的说明),刷新只会重新拉同一份
|
|
4398
|
+
* 旧 bundle。官方插件管理器也是这个口径(「已安装,下次启动后加载」)。
|
|
4399
|
+
* 按钮文案据此提示重启,而不是刷新。
|
|
3448
4400
|
*/
|
|
3449
4401
|
function startPluginUpdate() {
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
/* 官方桌面端一定带插件管理器;真取不到就按失败处理,不另开一个用户看不懂的分支 */
|
|
3453
|
-
if (manager === null || manager === undefined || typeof manager.installBundle !== "function") {
|
|
3454
|
-
setUpdateState({ busy: false, note: "failed", reason: null });
|
|
3455
|
-
return;
|
|
3456
|
-
}
|
|
4402
|
+
/* 开发模式下只演示:不真实安装(见 runDevPreview) */
|
|
4403
|
+
if (devMode === true) { runDevPreview(); return; }
|
|
3457
4404
|
if (updateState.latest === null || updateState.busy === true) return;
|
|
3458
4405
|
const target = updateState.latest;
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
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
|
+
};
|
|
4422
|
+
window.fetch(SELF_UPDATE_URL, {
|
|
4423
|
+
method: "POST",
|
|
4424
|
+
headers: { "content-type": "application/json" },
|
|
4425
|
+
body: JSON.stringify({ version: target })
|
|
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
|
+
})
|
|
4435
|
+
.then((payload) => {
|
|
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 });
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4443
|
+
/* 宿主明确回了失败原因(release-age / verify-failed / pnpm-failed…):
|
|
4444
|
+
宿主自己已经记过日志,这里补的是"客户端看到的那一份",两边能对上。 */
|
|
4445
|
+
fail(reasonOf(payload), { stage: "host", detail: JSON.stringify(payload).slice(0, 300) });
|
|
4446
|
+
})
|
|
4447
|
+
.catch((error) => {
|
|
4448
|
+
const detail = error !== null && error !== undefined && typeof error.message === "string"
|
|
4449
|
+
? error.message
|
|
4450
|
+
: String(error);
|
|
4451
|
+
fail("network", { stage: "request", detail: detail });
|
|
4452
|
+
});
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
/**
|
|
4456
|
+
* 开发模式下的一次点击:纯本地状态机,走完「更新中 → 已更新 → 更新失败 → 空闲」。
|
|
4457
|
+
*
|
|
4458
|
+
* 用真的 setUpdateState / 真的渲染分支,所以看到的动效、文案、按钮禁用行为
|
|
4459
|
+
* 与真实更新**完全一致**;唯一的区别是不发请求、不安装。
|
|
4460
|
+
*/
|
|
4461
|
+
function runDevPreview() {
|
|
4462
|
+
const phase = updateState.note;
|
|
4463
|
+
if (updateState.busy === true) return;
|
|
4464
|
+
if (phase === null) {
|
|
4465
|
+
/* ① 更新中:亮起那三个点 */
|
|
4466
|
+
setUpdateState({ busy: true, note: null, reason: null, latest: DEV_VERSION });
|
|
4467
|
+
devTimer = setTimeout(() => {
|
|
4468
|
+
devTimer = null;
|
|
4469
|
+
/* ② 已更新 */
|
|
4470
|
+
setUpdateState({ busy: false, note: "done", reason: null });
|
|
4471
|
+
}, DEV_BUSY_MS);
|
|
4472
|
+
return;
|
|
4473
|
+
}
|
|
4474
|
+
if (phase === "done") {
|
|
4475
|
+
/* ③ 更新失败:走真实失败那条显示路径 */
|
|
4476
|
+
setUpdateState({ busy: false, note: "failed", reason: DEV_FAIL_REASON });
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4479
|
+
/* ④ 回到空闲,可以再点一遍 */
|
|
4480
|
+
setUpdateState({ busy: false, note: null, reason: null, latest: DEV_VERSION });
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
/** 开发模式里「更新中」那一步的定时器;退出开发模式或卸载时要清掉。 */
|
|
4484
|
+
let devTimer = null;
|
|
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
|
+
|
|
4519
|
+
/** 开发模式当前是否开着(给测试与自检读)。 */
|
|
4520
|
+
function isDevMode() {
|
|
4521
|
+
return devMode;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
/**
|
|
4525
|
+
* 开关开发模式。
|
|
4526
|
+
* 打开时把状态摆成「有新版本可更新」的样子(但版本号是假的一眼可辨),
|
|
4527
|
+
* 关闭时清掉定时器并恢复成「没有更新」。
|
|
4528
|
+
*/
|
|
4529
|
+
function setDevMode(on) {
|
|
4530
|
+
devMode = on === true;
|
|
4531
|
+
if (devTimer !== null) { clearTimeout(devTimer); devTimer = null; }
|
|
4532
|
+
if (devMode === true) {
|
|
4533
|
+
setUpdateState({ hasUpdate: true, latest: DEV_VERSION, busy: false, note: null, reason: null });
|
|
4534
|
+
} else {
|
|
4535
|
+
/* 关掉时**同步**把演示相关状态一次清干净:忙碌态、结论、演示版本、按钮可见性。
|
|
4536
|
+
这几项都先落到「没有更新」,再去问真实的 registry 结果 —— 反过来(先等网络)
|
|
4537
|
+
会让按钮在关掉之后还多显示一会儿,看起来像没关掉。
|
|
4538
|
+
清 busy 尤其必要:演示可能正停在「更新中」,只撤按钮不撤 busy,
|
|
4539
|
+
界面会留在一个永远转下去的「正在更新中」。 */
|
|
4540
|
+
setUpdateState({ hasUpdate: false, latest: null, busy: false, note: null, reason: null });
|
|
4541
|
+
loadUpdateFlag().then((result) => {
|
|
4542
|
+
if (result !== null) setUpdateState(result);
|
|
4543
|
+
});
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
/**
|
|
4548
|
+
* 版本号被点了一下:连点 DEV_CLICKS 次进入(或退出)开发模式。
|
|
4549
|
+
*
|
|
4550
|
+
* 为什么用「连点」而不是一个可见开关:普通用户装了这插件,界面上不该多出
|
|
4551
|
+
* 一个面向作者的调试项。连点是常见的开发者模式手势,记得住、不会被误触。
|
|
4552
|
+
* 两次点击间隔超过 DEV_CLICK_WINDOW_MS 就重新计数 —— 否则慢慢点 5 次也会进去。
|
|
4553
|
+
*
|
|
4554
|
+
* @returns {object} 供测试断言的计数快照
|
|
4555
|
+
*/
|
|
4556
|
+
function onVersionClick() {
|
|
4557
|
+
const now = Date.now();
|
|
4558
|
+
if (devClickAt !== 0 && now - devClickAt > DEV_CLICK_WINDOW_MS) devClickCount = 0;
|
|
4559
|
+
devClickAt = now;
|
|
4560
|
+
devClickCount += 1;
|
|
4561
|
+
if (devClickCount >= DEV_CLICKS) {
|
|
4562
|
+
devClickCount = 0;
|
|
4563
|
+
devClickAt = 0;
|
|
4564
|
+
setDevMode(devMode !== true);
|
|
4565
|
+
}
|
|
4566
|
+
return { count: devClickCount, on: devMode };
|
|
3469
4567
|
}
|
|
3470
4568
|
|
|
4569
|
+
/** 连点计数与最近一次点击时刻。 */
|
|
4570
|
+
let devClickCount = 0;
|
|
4571
|
+
let devClickAt = 0;
|
|
4572
|
+
|
|
3471
4573
|
/**
|
|
3472
4574
|
* 把设置导航里本插件那一项的前导图标换成 gauge。
|
|
3473
4575
|
* 定位方式是按导航文案匹配(DSH 没给导航项留 id 属性),匹配不到就什么都不做。
|
|
@@ -3505,10 +4607,13 @@ window.__ModuleLoader__.load({
|
|
|
3505
4607
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
3506
4608
|
/* 版本状态一变就重挂一次胶囊:查完发现没新版(撤掉)、或有新版(挂上) */
|
|
3507
4609
|
const stopWatch = subscribeUpdate(apply);
|
|
3508
|
-
/* 启动自检一次。卸载之后结果才到的话直接丢弃,别再往 DOM 里塞东西。
|
|
4610
|
+
/* 启动自检一次。卸载之后结果才到的话直接丢弃,别再往 DOM 里塞东西。
|
|
4611
|
+
开发模式期间也要丢弃:它是模块级开关,用户可能在这次网络请求返回前就连点
|
|
4612
|
+
进去了,此时真实结果会把演示状态冲掉 —— 胶囊还在、按钮却没了。 */
|
|
3509
4613
|
let alive = true;
|
|
3510
4614
|
loadUpdateFlag().then((result) => {
|
|
3511
4615
|
if (alive !== true || result === null) return;
|
|
4616
|
+
if (devMode === true) return;
|
|
3512
4617
|
setUpdateState(result);
|
|
3513
4618
|
});
|
|
3514
4619
|
return () => {
|
|
@@ -3546,7 +4651,6 @@ window.__ModuleLoader__.load({
|
|
|
3546
4651
|
const t = props.t;
|
|
3547
4652
|
const useProjection = props.useProjection;
|
|
3548
4653
|
const useSession = props.useSession;
|
|
3549
|
-
const useSessions = props.useSessions;
|
|
3550
4654
|
const sessionId = props.sessionId;
|
|
3551
4655
|
const cfg = useConfig();
|
|
3552
4656
|
|
|
@@ -3596,7 +4700,20 @@ window.__ModuleLoader__.load({
|
|
|
3596
4700
|
method: "POST",
|
|
3597
4701
|
headers: { "content-type": "application/json" },
|
|
3598
4702
|
body: JSON.stringify({ models: models })
|
|
3599
|
-
}).
|
|
4703
|
+
}).then((response) => {
|
|
4704
|
+
/* 失败要说出来。原先只有 `.catch(() => {})`,而 fetch 对 4xx/5xx 是
|
|
4705
|
+
**resolve** 不是 reject —— 上报被拒(如体积超限返回 400)时既不进
|
|
4706
|
+
catch 也没人看 response,于是用户的价目表一直送不到 host。
|
|
4707
|
+
后果分两种:**新产生**的用量会按内置官方价计费(不是用户填的价);
|
|
4708
|
+
只在自定义表里的模型则永远算不出钱、一直留在欠费明细里。
|
|
4709
|
+
已算好的费用不受影响,也不会被改写 —— 那是既定语义。
|
|
4710
|
+
底栏本身不受影响,所以只告警、不改界面。 */
|
|
4711
|
+
if (response.ok !== true) {
|
|
4712
|
+
console.warn("[dsh-desktop-statusbar] 价格库上报被拒 HTTP " + String(response.status) + ",host 会继续用内置参考价给新用量记账");
|
|
4713
|
+
}
|
|
4714
|
+
}).catch((error) => {
|
|
4715
|
+
console.warn("[dsh-desktop-statusbar] 价格库上报失败:" + String(error && error.message));
|
|
4716
|
+
});
|
|
3600
4717
|
}, [reportedPrices]);
|
|
3601
4718
|
|
|
3602
4719
|
const running = pick(useSession, (s) => s.running, undefined);
|
|
@@ -3618,21 +4735,51 @@ window.__ModuleLoader__.load({
|
|
|
3618
4735
|
/* 「本轮」显示值的粘性下限盒子(同轮内只增不减,换轮作废):一个可变盒子,
|
|
3619
4736
|
由 lastCost 段在渲染时更新,跨渲染靠这个 ref 留住(见 applyTurnFloor)。 */
|
|
3620
4737
|
const lastCostHoldRef = react.useRef({ sessionId: null, turnKey: null, value: 0 });
|
|
3621
|
-
/*
|
|
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
|
+
});
|
|
4746
|
+
/* 点开「总计」气泡时 +1:强制重拉一次队友用量(别的触发条件都带 SUBAGENT_REFRESH_MS 节流) */
|
|
3622
4747
|
const [subagentTick, setSubagentTick] = react.useState(0);
|
|
3623
4748
|
const [tipId, setTipId] = react.useState(null);
|
|
3624
4749
|
const [tipPos, setTipPos] = react.useState(null);
|
|
3625
4750
|
/* 气泡里的下钻层:null = 根视图,数字 = 点开了第几行(会话数据那三行的子视图)。
|
|
3626
4751
|
换字段、收起气泡都要清掉,否则下次打开会停在上次的子视图上。 */
|
|
3627
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);
|
|
3628
4759
|
/* 柱 / 方块 / 每周底条的悬停浮层:内容 + 触发元素的位置(自己算 fixed 坐标) */
|
|
3629
4760
|
const [hoverCard, setHoverCard] = react.useState(null);
|
|
3630
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);
|
|
3631
4775
|
/* 当前悬停的是哪一周(用来给那条底板加深);悬停方块/柱子时为 null */
|
|
3632
4776
|
const [hoverWeek, setHoverWeek] = react.useState(null);
|
|
3633
4777
|
/* 当前悬停的是哪一根柱(那一列的判定区变灰);不在柱上时为 null */
|
|
3634
4778
|
const [hoverBar, setHoverBar] = react.useState(null);
|
|
3635
4779
|
const hoverRef = react.useRef(null);
|
|
4780
|
+
/* 工具调用彩条悬停时「跟着鼠标走」:鼠标坐标(clientX / clientY)。
|
|
4781
|
+
仅鼠标跟随型浮层(payload.follow === true)设置;活跃总览的柱/方块不设,仍贴整个气泡上方。 */
|
|
4782
|
+
const [hoverMouse, setHoverMouse] = react.useState(null);
|
|
3636
4783
|
const hoverTimerRef = react.useRef(null);
|
|
3637
4784
|
/* 收起延后一小会儿:鼠标在相邻的空位格之间横向移动会先 leave 再 enter,
|
|
3638
4785
|
当场清空就会一闪一闪;紧接着的新悬停会把定时器取消掉,等于没关过。 */
|
|
@@ -3644,9 +4791,13 @@ window.__ModuleLoader__.load({
|
|
|
3644
4791
|
setHoverPos(null);
|
|
3645
4792
|
setHoverWeek(null);
|
|
3646
4793
|
setHoverBar(null);
|
|
4794
|
+
setHoverPlain(null);
|
|
4795
|
+
setHoverMouse(null);
|
|
3647
4796
|
}, 110);
|
|
3648
4797
|
}, []);
|
|
3649
|
-
/* payload 为对象:rows 有值时弹浮层,
|
|
4798
|
+
/* payload 为对象:rows 有值时弹浮层,plain 有值时只显示一行文字(无气泡),
|
|
4799
|
+
week 有值时那条底板加深,bar 有值时那根柱的判定区变灰。
|
|
4800
|
+
follow === true 时浮层跟着鼠标走(贴鼠标上方、水平居中)。
|
|
3650
4801
|
传空表示离开判定区(延后收起,避免在相邻区域间横向移动时闪烁)。 */
|
|
3651
4802
|
const onHover = react.useCallback((event, payload) => {
|
|
3652
4803
|
if (payload === null || payload === undefined) {
|
|
@@ -3658,10 +4809,61 @@ window.__ModuleLoader__.load({
|
|
|
3658
4809
|
hoverTimerRef.current = null;
|
|
3659
4810
|
}
|
|
3660
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);
|
|
3661
4818
|
setHoverCard(payload.rows === undefined ? null : payload.rows);
|
|
3662
4819
|
setHoverWeek(typeof payload.week === "number" ? payload.week : null);
|
|
3663
4820
|
setHoverBar(typeof payload.bar === "number" ? payload.bar : null);
|
|
3664
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]);
|
|
3665
4867
|
react.useEffect(() => () => {
|
|
3666
4868
|
if (hoverTimerRef.current !== null) clearTimeout(hoverTimerRef.current);
|
|
3667
4869
|
}, []);
|
|
@@ -3897,7 +5099,7 @@ window.__ModuleLoader__.load({
|
|
|
3897
5099
|
跟着 scroll 收起的话,气泡会在这些时刻莫名其妙消失。 */
|
|
3898
5100
|
react.useEffect(() => {
|
|
3899
5101
|
if (tipId === null) return undefined;
|
|
3900
|
-
const close = () => { setTipId(null); setTipPos(null); setTipSub(null); };
|
|
5102
|
+
const close = () => { setTipId(null); setTipPos(null); setTipSub(null); setCreditSub(null); };
|
|
3901
5103
|
const onDocClick = (event) => {
|
|
3902
5104
|
if (rootRef.current !== null && rootRef.current.contains(event.target)) return;
|
|
3903
5105
|
close();
|
|
@@ -3912,10 +5114,6 @@ window.__ModuleLoader__.load({
|
|
|
3912
5114
|
};
|
|
3913
5115
|
}, [tipId]);
|
|
3914
5116
|
|
|
3915
|
-
/* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
|
|
3916
|
-
最左、最右那两项贴边对齐,其余跟该字段居中对齐,最后按视口左右夹住。
|
|
3917
|
-
用 layout effect 是为了在浏览器绘制前就落位,不会先闪一下再跳。
|
|
3918
|
-
anchorRef 存的是触发项节点本身,每次都重新量 —— 窗口缩放后位置才是对的。 */
|
|
3919
5117
|
/* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
|
|
3920
5118
|
**不设依赖数组**:下钻换子视图、数字位数变化都会改宽度,坐标必须跟着重算 ——
|
|
3921
5119
|
只在 tipId 变时算的话,换子视图后会沿用上一个宽度,气泡就偏到一边去。
|
|
@@ -3936,6 +5134,17 @@ window.__ModuleLoader__.load({
|
|
|
3936
5134
|
const width = node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
|
|
3937
5135
|
? node.getBoundingClientRect().width
|
|
3938
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);
|
|
3939
5148
|
const centered = anchor.left + anchor.width / 2 - width / 2;
|
|
3940
5149
|
/* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
|
|
3941
5150
|
左 —— 可见左边缘压在上下文占用那个**圆圈灰底**的左边。灰底是圆环按钮自己画的
|
|
@@ -3962,20 +5171,23 @@ window.__ModuleLoader__.load({
|
|
|
3962
5171
|
? lastSeg.getBoundingClientRect().right
|
|
3963
5172
|
: anchor.right;
|
|
3964
5173
|
const barRightIn = barRight - TIP_OUTLINE_PX;
|
|
3965
|
-
/*
|
|
3966
|
-
|
|
5174
|
+
/* 气泡比底栏还宽时(工具调用那种多列面板),先把宽度压到底栏跨度以内 ——
|
|
5175
|
+
否则右边界怎么挪都会顶出去。
|
|
5176
|
+
**注意:多出来的列不是折到下一排**(`flex-wrap` 已是 nowrap,用户明确要求永不折行),
|
|
5177
|
+
而是由内层 `.dsb-meter-cols` 横向滚动(`overflow-x:auto`)接住;
|
|
5178
|
+
行数在 12 行以内时优先加行把列数压下来,列少了气泡自然变窄(这就是"自适应宽度")。 */
|
|
3967
5179
|
const span = barRightIn - barLeft;
|
|
3968
5180
|
const maxWidth = span > 0 ? Math.min(span, window.innerWidth - 24) : null;
|
|
3969
5181
|
const maxLeft = Math.min(barRightIn - width, window.innerWidth - width - 8);
|
|
3970
5182
|
const left = Math.max(barLeft, Math.min(centered, maxLeft));
|
|
3971
5183
|
const bottom = Math.max(8, window.innerHeight - anchor.top + 6);
|
|
3972
|
-
/*
|
|
5184
|
+
/* 诊断串:只用于下面回传给 host 落盘(不写进 DOM,浏览器也不显示) */
|
|
3973
5185
|
const align = "center left=" + left.toFixed(1) + " ring=" + barLeft.toFixed(1)
|
|
3974
5186
|
+ " bar=" + barRight.toFixed(1) + " anchor=" + Math.round(anchor.left) + " w=" + Math.round(width);
|
|
3975
5187
|
setTipPos((prev) => (prev !== null && prev.left === left && prev.bottom === bottom
|
|
3976
|
-
&& prev.maxWidth === maxWidth
|
|
5188
|
+
&& prev.maxWidth === maxWidth
|
|
3977
5189
|
? prev
|
|
3978
|
-
: { left: left, bottom: bottom, maxWidth: maxWidth
|
|
5190
|
+
: { left: left, bottom: bottom, maxWidth: maxWidth }));
|
|
3979
5191
|
/* 诊断回传:本机打不开控制台也能核对,host 会落到 <DSH_HOME>/dsh-status-bar/tip-diag.log。
|
|
3980
5192
|
只在坐标真的变了时才回传,不然每次渲染都发一遍会把日志刷满。 */
|
|
3981
5193
|
if (tipDiagRef.current !== align) {
|
|
@@ -3995,21 +5207,71 @@ window.__ModuleLoader__.load({
|
|
|
3995
5207
|
}
|
|
3996
5208
|
});
|
|
3997
5209
|
|
|
3998
|
-
/*
|
|
5210
|
+
/* 悬停定位:两种情形。
|
|
5211
|
+
① 工具调用彩条的纯文字提示(hoverPlain)—— **垂直方向固定**:与气泡标题文字居中对齐,
|
|
5212
|
+
不随鼠标上下移动;只有 left 跟着鼠标水平居中(用户拍板:只左右移动、不上下移动)。
|
|
5213
|
+
水平位置用 hoverMouse 记下的鼠标坐标(onHover 时写入)。
|
|
5214
|
+
② 活跃总览的柱 / 方块 / 底板 —— 固定贴在气泡正上方、左右同宽。
|
|
5215
|
+
hoverMouse 由 onHover 记录;离开后由 closeHoverSoon 清空。 */
|
|
3999
5216
|
useTipLayout(() => {
|
|
4000
|
-
if (hoverCard === null) return;
|
|
5217
|
+
if (hoverCard === null && hoverPlain === null) return;
|
|
4001
5218
|
const node = hoverRef.current;
|
|
4002
5219
|
const panel = tipRef.current;
|
|
4003
5220
|
if (node === null || node === undefined || panel === null || panel === undefined) return;
|
|
4004
5221
|
if (typeof node.getBoundingClientRect !== "function" || typeof panel.getBoundingClientRect !== "function") return;
|
|
4005
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
|
+
}
|
|
4006
5268
|
const box = panel.getBoundingClientRect();
|
|
4007
5269
|
setHoverPos({
|
|
4008
5270
|
left: Math.max(8, box.left),
|
|
4009
5271
|
top: Math.max(8, box.top - size.height - 6),
|
|
4010
5272
|
width: box.width
|
|
4011
5273
|
});
|
|
4012
|
-
}, [hoverCard]);
|
|
5274
|
+
}, [hoverCard, hoverPlain, hoverMouse]);
|
|
4013
5275
|
|
|
4014
5276
|
/* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上 */
|
|
4015
5277
|
react.useEffect(() => {
|
|
@@ -4019,7 +5281,6 @@ window.__ModuleLoader__.load({
|
|
|
4019
5281
|
}
|
|
4020
5282
|
}, [tipId]);
|
|
4021
5283
|
|
|
4022
|
-
/* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
|
|
4023
5284
|
/* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
|
|
4024
5285
|
react.useEffect(() => {
|
|
4025
5286
|
if (typeof document === "undefined" || document === null) return undefined;
|
|
@@ -4102,7 +5363,9 @@ window.__ModuleLoader__.load({
|
|
|
4102
5363
|
progress: progress, subagent: subagent,
|
|
4103
5364
|
/* 「本轮」同轮粘性下限的载体:sessionId/turnKey 说明"这份下限属于谁",
|
|
4104
5365
|
lastCostHold 是个可变盒子(ref 里那个对象),段里渲染时按需更新它 —— 见 applyTurnFloor。 */
|
|
4105
|
-
sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current
|
|
5366
|
+
sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current,
|
|
5367
|
+
/* 「总计」增量式账本的载体(见 applyTotalFloor 的注释) */
|
|
5368
|
+
totalHold: totalHoldRef.current
|
|
4106
5369
|
};
|
|
4107
5370
|
|
|
4108
5371
|
const views = [];
|
|
@@ -4133,6 +5396,9 @@ window.__ModuleLoader__.load({
|
|
|
4133
5396
|
const toggleTip = (id, event) => {
|
|
4134
5397
|
/* 换字段(含收起)一律回到根视图,别把上一个字段的下钻层带过来 */
|
|
4135
5398
|
setTipSub(null);
|
|
5399
|
+
setCreditSub(null);
|
|
5400
|
+
/* 工具调用子视图的行数也回到默认 3:换字段 / 重开都重新量 */
|
|
5401
|
+
setToolRows(LIST_ROWS_PER_COLUMN);
|
|
4136
5402
|
if (tipId === id) {
|
|
4137
5403
|
setTipId(null);
|
|
4138
5404
|
setTipPos(null);
|
|
@@ -4141,7 +5407,8 @@ window.__ModuleLoader__.load({
|
|
|
4141
5407
|
/* 点开余额 / 峰谷用量:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
|
|
4142
5408
|
if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
|
|
4143
5409
|
if (id === "status" && dailyReloadRef.current !== null) dailyReloadRef.current();
|
|
4144
|
-
/* 总计:队友的数字也要重取一次 ——
|
|
5410
|
+
/* 总计:队友的数字也要重取一次 —— 节流靠的是那条每秒时钟(SUBAGENT_REFRESH_MS),
|
|
5411
|
+
而会话完全空闲时
|
|
4145
5412
|
它不跳(睡到下一个峰谷边界),光靠它会让数字停在旧值 */
|
|
4146
5413
|
if (id === "cost" && subagentReloadRef.current !== null) subagentReloadRef.current();
|
|
4147
5414
|
const target = event !== undefined && event !== null ? event.currentTarget : null;
|
|
@@ -4154,9 +5421,33 @@ window.__ModuleLoader__.load({
|
|
|
4154
5421
|
const tipPanelOf = (tip) => {
|
|
4155
5422
|
if (tip === undefined || tip === null) return null;
|
|
4156
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
|
+
}
|
|
4157
5432
|
if (tip.kind === "meter") return meterPanel(tip.meter, t, tipPos, tipRef);
|
|
4158
5433
|
if (tip.kind === "bars") return weekBarsPanel(tip, t, tipPos, tipRef, onHover, { week: hoverWeek, bar: hoverBar });
|
|
4159
|
-
return rowsPanel(tip, tipPos, tipRef, {
|
|
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
|
+
});
|
|
4160
5451
|
};
|
|
4161
5452
|
/* 圆环自己就是触发项:点开上下文占用面板;峰谷那两个字不带气泡 */
|
|
4162
5453
|
const contextTip = contextMeter === null ? null : { kind: "meter", meter: contextMeter };
|
|
@@ -4190,6 +5481,26 @@ window.__ModuleLoader__.load({
|
|
|
4190
5481
|
? [h("span", { key: "__txt" }, view.text), tipId === view.id ? tipPanelOf(tip) : null]
|
|
4191
5482
|
: view.text));
|
|
4192
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
|
+
}
|
|
4193
5504
|
/* 悬停浮层:与主气泡同一套质感,fixed 定位在触发元素上方。
|
|
4194
5505
|
必须先挂上节点(此时 pos 还是 null、不给 left/bottom),
|
|
4195
5506
|
layout effect 才量得到宽度并把坐标算出来 —— 否则会永远卡在"没有坐标"而不显示。 */
|
|
@@ -4259,6 +5570,26 @@ window.__ModuleLoader__.load({
|
|
|
4259
5570
|
const t = props.t;
|
|
4260
5571
|
const cfg = useConfig();
|
|
4261
5572
|
const update = useUpdateState();
|
|
5573
|
+
/* 开发模式是本组件内的界面状态;它不写配置、不动真实更新。
|
|
5574
|
+
**不给它可见开关**:入口是页脚版本号的「连点 5 次」(见 onVersionClick)。
|
|
5575
|
+
进入后标题右侧挂一枚「开发模式」胶囊,这是唯一的可见迹象。 */
|
|
5576
|
+
const [devOn, setDevOn] = react.useState(false);
|
|
5577
|
+
/* 版本号被点击:累计连点次数,够 5 次就切换开发模式。
|
|
5578
|
+
计数与时间窗在模块级(onVersionClick),这里只负责把结果同步给 React。 */
|
|
5579
|
+
const onVersionClicked = () => {
|
|
5580
|
+
const state = onVersionClick();
|
|
5581
|
+
setDevOn(state.on === true);
|
|
5582
|
+
};
|
|
5583
|
+
/* 卸载时清掉演示的定时器,并把开发模式**一并关掉**。
|
|
5584
|
+
只清定时器是不够的:devMode 是模块级变量,而 devOn 是本组件的 useState。
|
|
5585
|
+
卸载后重挂时 devOn 回到 false,devMode 若还是 true,两者就不同步了 ——
|
|
5586
|
+
界面看不见「开发模式」胶囊,更新按钮却仍显示演示版本号,点下去走
|
|
5587
|
+
startPluginUpdate 的 devMode 分支只播演示、不安装也不报错,用户无从判断。
|
|
5588
|
+
只在**真的开着**时才复位:setDevMode(false) 会顺手重查一次 registry,
|
|
5589
|
+
没开还调用等于每次卸载白打一次网络请求,并且会把已有的真实更新提示清掉。 */
|
|
5590
|
+
react.useEffect(() => () => {
|
|
5591
|
+
if (devMode === true) setDevMode(false);
|
|
5592
|
+
}, []);
|
|
4262
5593
|
|
|
4263
5594
|
/* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
|
|
4264
5595
|
原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
|
|
@@ -4566,7 +5897,15 @@ window.__ModuleLoader__.load({
|
|
|
4566
5897
|
const sections = [];
|
|
4567
5898
|
|
|
4568
5899
|
sections.push(h("div", { className: "dsb-head", key: "__head" }, [
|
|
4569
|
-
|
|
5900
|
+
/* 标题那一行:开发模式开着时,标题文字右上角跟一枚胶囊 —— 像 x² 的上标那样
|
|
5901
|
+
紧贴着标题,而不是甩到面板最右边(那样看不出是「这个标题的」标记)。
|
|
5902
|
+
关着时这一行与原来**完全一样** —— 不多任何元素。 */
|
|
5903
|
+
h("div", { className: "dsb-titlerow", key: "__titlerow" },
|
|
5904
|
+
h("h2", { className: "dsb-pagetitle" }, t("sectionTitle")),
|
|
5905
|
+
devOn === true
|
|
5906
|
+
? h("span", { className: "dsb-nav-pill dsb-dev-pill", key: "__devpill", title: t("devModeHint") }, t("devModeBadge"))
|
|
5907
|
+
: null
|
|
5908
|
+
),
|
|
4570
5909
|
h("p", { className: "dsb-intro" }, t("intro"))
|
|
4571
5910
|
]));
|
|
4572
5911
|
|
|
@@ -4994,9 +6333,17 @@ window.__ModuleLoader__.load({
|
|
|
4994
6333
|
type: "button",
|
|
4995
6334
|
className: "dsb-action",
|
|
4996
6335
|
key: "__star",
|
|
4997
|
-
/*
|
|
6336
|
+
/* 点完**主动松开焦点**再跳转。气泡的显示条件是 `:hover` 或 `:focus-within`;
|
|
6337
|
+
点击会让按钮获得 DOM 焦点,而跳去浏览器只让**窗口**失焦、DOM 焦点还留在按钮上 ——
|
|
6338
|
+
于是 `:focus-within` 一直为真,用户关掉浏览器回来时气泡就卡住常显(实测缺陷)。
|
|
6339
|
+
blur 掉之后气泡回到只跟鼠标悬停走,行为才符合预期。
|
|
6340
|
+
window.open 会被主窗口的 setWindowOpenHandler 接住,转 shell.openExternal → 默认浏览器;
|
|
4998
6341
|
noopener 只是不给新窗口留 window.opener(这里窗口本来就被拒了) */
|
|
4999
|
-
onClick: () =>
|
|
6342
|
+
onClick: (event) => {
|
|
6343
|
+
const button = event === null || event === undefined ? null : event.currentTarget;
|
|
6344
|
+
if (button !== null && button !== undefined && typeof button.blur === "function") button.blur();
|
|
6345
|
+
window.open(REPO_URL, "_blank", "noopener");
|
|
6346
|
+
}
|
|
5000
6347
|
}, t("starRepo"))
|
|
5001
6348
|
]),
|
|
5002
6349
|
h("button", {
|
|
@@ -5026,28 +6373,55 @@ window.__ModuleLoader__.load({
|
|
|
5026
6373
|
});
|
|
5027
6374
|
}
|
|
5028
6375
|
}, t("reset")),
|
|
5029
|
-
/* 有新版才出现:与导航胶囊同一个来源(updateState
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
6376
|
+
/* 有新版才出现:与导航胶囊同一个来源(updateState)。
|
|
6377
|
+
点它由本插件自己的 host 端执行更新(不依赖插件管理器,也不吃 pnpm 的
|
|
6378
|
+
24 小时发布年龄限制)。三种状态都写在按钮自己身上:空闲报版本号、
|
|
6379
|
+
更新中带动效点、失败就说「更新失败」。
|
|
6380
|
+
|
|
6381
|
+
**失败态仍然可点**:点一下只是退回「更新到 X.X.X 版本」,让用户看清
|
|
6382
|
+
自己点过了、可以再点一次真重试(见 dismissUpdateFailure 的说明)。
|
|
6383
|
+
唯一 disabled 的是「更新中」——那时候不能重复发起。
|
|
6384
|
+
|
|
6385
|
+
**装完必须重启 DSH,刷新页面没有用** —— 这不是保守说法,是 DSH 的设计:
|
|
6386
|
+
· `@deepseek-ai/dsh-hmr` 的已知限制原文:「通过插件管理器替换已安装包版本
|
|
6387
|
+
仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。」
|
|
6388
|
+
· `dsh-client-modules` 的包元数据(含 client.js 的 revision)
|
|
6389
|
+
「缓存至重启」,所以刷新只会重新拉同一份旧 bundle。
|
|
6390
|
+
· 官方插件管理器的文案也是同一口径:「已安装,下次启动后加载。」
|
|
6391
|
+
桌面宿主只提供退出能力、没有自我重启能力(`app.relaunch()` 只在开发模式
|
|
6392
|
+
的菜单里),所以这里只如实提示,不假装能替用户重启。 */
|
|
6393
|
+
update.hasUpdate === true
|
|
5033
6394
|
? h("button", {
|
|
5034
6395
|
type: "button",
|
|
5035
6396
|
className: "dsb-action",
|
|
5036
6397
|
key: "__update",
|
|
5037
6398
|
disabled: update.busy === true,
|
|
5038
|
-
onClick:
|
|
6399
|
+
onClick: onUpdateClick
|
|
5039
6400
|
}, update.busy === true
|
|
5040
6401
|
? [t("updateRunning"), h("span", { className: "dsb-dots", key: "__dots" }, [
|
|
5041
6402
|
h("span", { key: "__d1" }, "."),
|
|
5042
6403
|
h("span", { key: "__d2" }, "."),
|
|
5043
6404
|
h("span", { key: "__d3" }, ".")
|
|
5044
6405
|
])]
|
|
5045
|
-
: (update.note === "
|
|
5046
|
-
? t("
|
|
5047
|
-
:
|
|
6406
|
+
: (update.note === "done"
|
|
6407
|
+
? t("updateDone")
|
|
6408
|
+
: (update.note === "failed"
|
|
6409
|
+
? t("updateFailed")
|
|
6410
|
+
: t("updateTo", { version: update.latest === null ? "" : update.latest }))))
|
|
5048
6411
|
: null,
|
|
5049
|
-
/*
|
|
5050
|
-
|
|
6412
|
+
/* 版本标记贴右、与行内底边对齐:这行字让「现在跑的是哪一版」一眼可见 ——
|
|
6413
|
+
注意**光刷新页面看不出差别**:插件包元数据缓存至重启,刷新拿到的还是旧 bundle;
|
|
6414
|
+
要换版本必须重启 DSH,重启后这行字才会变成新号。
|
|
6415
|
+
|
|
6416
|
+
它同时是**开发模式的隐藏入口**:连点 5 次进入 / 退出(见 onVersionClick)。
|
|
6417
|
+
挂在版本号上的原因:这个入口面向作者调试,普通用户的界面里不该多出
|
|
6418
|
+
任何可见元素;连点是常见手势,记得住又不会被误触。
|
|
6419
|
+
进入后的可见迹象是标题右侧那枚「开发模式」胶囊,不在这一行。 */
|
|
6420
|
+
h("span", {
|
|
6421
|
+
className: "dsb-version",
|
|
6422
|
+
key: "__version",
|
|
6423
|
+
onClick: onVersionClicked
|
|
6424
|
+
}, "dsh-desktop-statusbar " + VERSION)
|
|
5051
6425
|
]));
|
|
5052
6426
|
|
|
5053
6427
|
return h("div", { className: "dsb-settings" }, sections);
|
|
@@ -5062,12 +6436,26 @@ window.__ModuleLoader__.load({
|
|
|
5062
6436
|
exports.__test = {
|
|
5063
6437
|
nextSubagentState: nextSubagentState,
|
|
5064
6438
|
applyTurnFloor: applyTurnFloor,
|
|
5065
|
-
SUBAGENT_STATE_EMPTY: SUBAGENT_STATE_EMPTY
|
|
6439
|
+
SUBAGENT_STATE_EMPTY: SUBAGENT_STATE_EMPTY,
|
|
6440
|
+
/* 开发模式的驱动入口:测试要能直接触发连点、并读回状态,
|
|
6441
|
+
不然这段只存在于设置页组件里的逻辑没法离线验证。 */
|
|
6442
|
+
setDevMode: setDevMode,
|
|
6443
|
+
runDevPreview: runDevPreview,
|
|
6444
|
+
isDevMode: isDevMode,
|
|
6445
|
+
onVersionClick: onVersionClick,
|
|
6446
|
+
/* 更新按钮的两个点击入口:失败态只退回可重试、其余真发起更新 */
|
|
6447
|
+
onUpdateClick: onUpdateClick,
|
|
6448
|
+
dismissUpdateFailure: dismissUpdateFailure,
|
|
6449
|
+
DEV_VERSION: DEV_VERSION,
|
|
6450
|
+
DEV_FAIL_REASON: DEV_FAIL_REASON,
|
|
6451
|
+
DEV_CLICKS: DEV_CLICKS,
|
|
6452
|
+
DEV_CLICK_WINDOW_MS: DEV_CLICK_WINDOW_MS
|
|
5066
6453
|
};
|
|
5067
6454
|
|
|
5068
6455
|
function apply(ctx) {
|
|
5069
|
-
/*
|
|
5070
|
-
|
|
6456
|
+
/* 注意:这里**不保存 ctx**。更新走本插件自己的 host 路由
|
|
6457
|
+
(见 startPluginUpdate 的说明),界面其余部分也不需要宿主上下文 ——
|
|
6458
|
+
曾经存过一份 `pluginCtx` 只写不读,已删除。 */
|
|
5071
6459
|
ctx.effect(installStyles, "dsh-desktop-statusbar: styles");
|
|
5072
6460
|
ctx.effect(() => ctx.locale.register(NS, { zh: zh, en: en }), "dsh-desktop-statusbar: locale");
|
|
5073
6461
|
ctx.effect(() => installNavIcon(
|