dsh-desktop-statusbar 2.2.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/cordis.patch.yml +10 -1
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/docs/statusbar.en.png +0 -0
- package/docs/statusbar.png +0 -0
- package/lib/balance-api.js +23 -1
- package/lib/client.js +2864 -204
- package/lib/index.js +5622 -4218
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/tools/shot.cjs +28 -10
package/lib/client.js
CHANGED
|
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
*/
|
|
75
75
|
const CONFIG_VERSION = 6;
|
|
76
76
|
/** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
|
|
77
|
-
const VERSION = "
|
|
77
|
+
const VERSION = "3.0.0";
|
|
78
78
|
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
79
79
|
const PRICE_VERSION = 2;
|
|
80
80
|
/** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
|
|
@@ -796,6 +796,115 @@ window.__ModuleLoader__.load({
|
|
|
796
796
|
return subagentSummary(subagent === null || subagent === undefined ? null : subagent[which]);
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
+
/**
|
|
800
|
+
* host 响应里的 `running`(仍在运行的子会话数)。
|
|
801
|
+
* 两段响应都会带同一个数,先取 `turn` 那一段(它与"此刻"最贴近),拿不到再退回 `session` 那段。
|
|
802
|
+
*
|
|
803
|
+
* 返回 `null` 表示**这次两问都没问出结论**(网络失败 / 非 200 / 形状不对),
|
|
804
|
+
* 调用方据此**保留旧值**、不覆盖。这一点是必须的:底栏的时钟靠这个数决定
|
|
805
|
+
* "每秒跳"还是"睡到下一个峰谷边界",一旦把"问不到"当成 0,队友数据与「总计」里
|
|
806
|
+
* 队友那笔金额会一路冻结到几小时后 —— 正是用户报过的那个缺陷。
|
|
807
|
+
* 反过来,两问里只要有一问给出数字就按数字走:明确的 0 必须能落下去
|
|
808
|
+
* (队友真的全部结束时,时钟要能回到省电档),所以不能用"非 0 才更新"的写法。
|
|
809
|
+
*/
|
|
810
|
+
function readSubagentRunning(turnPayload, sessionPayload) {
|
|
811
|
+
const pick = (one) => (one !== null && one !== undefined && typeof one === "object"
|
|
812
|
+
&& typeof one.running === "number" && Number.isFinite(one.running) && one.running >= 0
|
|
813
|
+
? Math.floor(one.running)
|
|
814
|
+
: null);
|
|
815
|
+
const fromTurn = pick(turnPayload);
|
|
816
|
+
const fromSession = pick(sessionPayload);
|
|
817
|
+
if (fromTurn === null && fromSession === null) return null;
|
|
818
|
+
if (fromTurn !== null && fromTurn > 0) return fromTurn;
|
|
819
|
+
if (fromSession !== null && fromSession > 0) return fromSession;
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* 把「这一次的两问结果」折算成 `subagentRunning` 的**下一个值**。
|
|
825
|
+
*
|
|
826
|
+
* 就是 stale-while-revalidate:这次问不到(两个 payload 都不成形状)⇒ 原样返回旧值,
|
|
827
|
+
* 调用方什么都不用判断。单独抽成纯函数,一是让调用点没有"覆盖还是跳过"的分支可写错,
|
|
828
|
+
* 二是这条规则能被离线断言住(与 `nextSubagentState` 同一个套路)。
|
|
829
|
+
*
|
|
830
|
+
* @param {number} prev 当前显示的队友在跑个数
|
|
831
|
+
* @param {object|null} turnPayload 「本轮窗口」那一问的响应
|
|
832
|
+
* @param {object|null} sessionPayload 「整会话」那一问的响应
|
|
833
|
+
* @returns {number} 下一个值(问不到就是 prev)
|
|
834
|
+
*/
|
|
835
|
+
function nextSubagentRunning(prev, turnPayload, sessionPayload) {
|
|
836
|
+
const next = readSubagentRunning(turnPayload, sessionPayload);
|
|
837
|
+
return next === null ? prev : next;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* host 响应里的**分层**信息:`{ team, deep, teamPercent, deepPercent }`。
|
|
842
|
+
*
|
|
843
|
+
* · `team` / `deep` —— 两个层级各自在跑的个数
|
|
844
|
+
* `team` = 在跑的直接后代里**属于团队成员**的个数(Agent Teams 的队员)
|
|
845
|
+
* `deep` = 其余在跑的个数(团队成员下派的孙会话 + 不在团队内的独立子智能体)
|
|
846
|
+
* · `teamPercent` / `deepPercent` —— 那一级里在跑的人的上下文占用:
|
|
847
|
+
* **成员取最大**(多个成员同时跑时,圆环显示占用最多的那个)
|
|
848
|
+
* **更深层取最小**(用户原话:「成员下派的子智能体那一级别……上下文占用要显示最少的那个」)
|
|
849
|
+
* host 算不出来时给 `null`,调用方必须退回 Lead 自己的占用,不能当 0(会把圆环弄空)。
|
|
850
|
+
*
|
|
851
|
+
* 恒有 `running === team + deep`(host 侧保证),但**这里不拿 running 反推** ——
|
|
852
|
+
* 老 host 没有这两个字段时,硬推会把"团队外独立子智能体"误算成成员。
|
|
853
|
+
* 计数字段缺失当 0,百分比字段缺失当 null(老 host / 老夹具照常能跑)。
|
|
854
|
+
*
|
|
855
|
+
* 为什么单独一个函数、不改 `readSubagentRunning` 的返回形状:那个函数被
|
|
856
|
+
* `nextSubagentRunning` 用着,而 `nextSubagentRunning` 的 `null=保留旧值 / 0=真落下`
|
|
857
|
+
* 语义是修过的真实缺陷(时钟不降档),`tests/verify-subagent-total.js` 逐字段断言它。
|
|
858
|
+
* 加字段就得同步改契约和断言,风险不对等 —— 分层只是一份"额外信息",
|
|
859
|
+
* 用同一套"先 turn 段、再 session 段"的取法另算一遍最省事也最安全。
|
|
860
|
+
*
|
|
861
|
+
* 取值规则与 `readSubagentRunning` 一致:任一段给出合法数就用它(host 两问带同一份数)。
|
|
862
|
+
* 计数字段的 0 与"没有该字段"在这里区分得开(后者当 0,语义上等价:都不在跑);
|
|
863
|
+
* 百分比字段则**严格区分 null 与 0** —— 0% 是真实占用,null 是算不出。
|
|
864
|
+
*/
|
|
865
|
+
function readSubagentTiers(turnPayload, sessionPayload) {
|
|
866
|
+
const pickCount = (payload, key) => (payload !== null && payload !== undefined && typeof payload === "object"
|
|
867
|
+
&& typeof payload[key] === "number" && Number.isFinite(payload[key]) && payload[key] >= 0
|
|
868
|
+
? Math.floor(payload[key])
|
|
869
|
+
: null);
|
|
870
|
+
const pickPercent = (payload, key) => (payload !== null && payload !== undefined && typeof payload === "object"
|
|
871
|
+
&& typeof payload[key] === "number" && Number.isFinite(payload[key])
|
|
872
|
+
? Math.max(0, Math.min(100, payload[key]))
|
|
873
|
+
: null);
|
|
874
|
+
const countOf = (key) => {
|
|
875
|
+
const fromTurn = pickCount(turnPayload, key);
|
|
876
|
+
const fromSession = pickCount(sessionPayload, key);
|
|
877
|
+
/* 与 readSubagentRunning 的取舍同构:先看 turn 段,它没有(或为 0)再看 session 段 */
|
|
878
|
+
if (fromTurn !== null && fromTurn > 0) return fromTurn;
|
|
879
|
+
if (fromSession !== null && fromSession > 0) return fromSession;
|
|
880
|
+
return 0;
|
|
881
|
+
};
|
|
882
|
+
const percentOf = (key) => {
|
|
883
|
+
/* 百分比用 `!== null` 判,不用 `> 0`:0% 是合法占用,不能被当成"没有"跳过 */
|
|
884
|
+
const fromTurn = pickPercent(turnPayload, key);
|
|
885
|
+
if (fromTurn !== null) return fromTurn;
|
|
886
|
+
return pickPercent(sessionPayload, key);
|
|
887
|
+
};
|
|
888
|
+
return {
|
|
889
|
+
team: countOf("teamRunning"),
|
|
890
|
+
deep: countOf("deepRunning"),
|
|
891
|
+
teamPercent: percentOf("teamContextPercent"),
|
|
892
|
+
deepPercent: percentOf("deepContextPercent")
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* host 推出来的**本轮起点**(毫秒)。只从「本轮窗口」那一问的响应里读 ——
|
|
898
|
+
* 整会话那一问(`since=0`)返回的起点语义是"最早的那次",拿它当本轮起点会把
|
|
899
|
+
* 整个会话的队友花费都算进本轮(方向正是"偏大")。
|
|
900
|
+
* 非法值(非数字 / 非正 / NaN)一律当 null,调用方据此退回"客户端折叠"或"本轮不并队友"。
|
|
901
|
+
*/
|
|
902
|
+
function readTurnStartMs(payload) {
|
|
903
|
+
if (payload === null || payload === undefined || typeof payload !== "object") return null;
|
|
904
|
+
const value = payload.turnStartMs;
|
|
905
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
906
|
+
}
|
|
907
|
+
|
|
799
908
|
/* ------------------------------------------- 「本轮」稳定性(同轮不跳、换轮归零) */
|
|
800
909
|
/* 底栏每秒问两个窗口(整会话 / 本轮窗口),任一问失败时旧实现会把队友那段当 0,
|
|
801
910
|
于是「本轮」在同轮内跳小、下一次成功又跳大(用户实测)。下面两件纯函数负责把它稳住:
|
|
@@ -805,6 +914,37 @@ window.__ModuleLoader__.load({
|
|
|
805
914
|
/** 空状态:没有会话、没有轮、两段都没有值。 */
|
|
806
915
|
const SUBAGENT_STATE_EMPTY = { sessionId: null, turnKey: null, sessionPayload: null, turnPayload: null };
|
|
807
916
|
|
|
917
|
+
/**
|
|
918
|
+
* 分层在跑的初始值:全 0。`all` 初值是 0 而不是 null —— 首帧没有任何结论时
|
|
919
|
+
* 底栏本来就该按"没人在跑"渲染(圆环走 idle 的透明弧),等价于旧实现 `useState(0)`。
|
|
920
|
+
* 之后每次响应:有结论就覆盖(含 0),没结论才保留旧值(见 nextSubagentTiers)。
|
|
921
|
+
*/
|
|
922
|
+
const SUBAGENT_TIERS_EMPTY = { all: 0, team: 0, deep: 0, teamPercent: null, deepPercent: null };
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 把「这一次的两问结果」折算成分层状态的**下一个值**(纯函数,便于离线断言)。
|
|
926
|
+
*
|
|
927
|
+
* 三条规则:
|
|
928
|
+
* ① `all` 沿用 `nextSubagentRunning` 的 stale-while-revalidate:
|
|
929
|
+
* 这次问不出结论 ⇒ 原样返回 prev.all;明确的 0 ⇒ 落到 0。
|
|
930
|
+
* 这条不能变通 —— 它是修过的真实缺陷(问不到当 0 会让时钟降档、数据冻结)。
|
|
931
|
+
* ② 计数与百分比**跟着 `all` 一起更新或一起保留**:不做"计数更新了但百分比留着旧值"
|
|
932
|
+
* 这种半更新,否则会出现"蓝色在转、弧长却是上一次某个成员的占用"的错配。
|
|
933
|
+
* ③ 没有结论时连百分比一起保留(`prev` 里那份百分比仍属于上一个已知状态)。
|
|
934
|
+
*/
|
|
935
|
+
function nextSubagentTiers(prev, turnPayload, sessionPayload) {
|
|
936
|
+
const nextAll = readSubagentRunning(turnPayload, sessionPayload);
|
|
937
|
+
if (nextAll === null) return prev;
|
|
938
|
+
const tiers = readSubagentTiers(turnPayload, sessionPayload);
|
|
939
|
+
return {
|
|
940
|
+
all: nextAll,
|
|
941
|
+
team: tiers.team,
|
|
942
|
+
deep: tiers.deep,
|
|
943
|
+
teamPercent: tiers.teamPercent,
|
|
944
|
+
deepPercent: tiers.deepPercent
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
808
948
|
/**
|
|
809
949
|
* 队友两段取值的状态机(纯函数,组件只负责调用)。
|
|
810
950
|
*
|
|
@@ -926,7 +1066,10 @@ window.__ModuleLoader__.load({
|
|
|
926
1066
|
/* 账本就地更新:调用方持有的是 useRef 里同一个对象,跨渲染靠它留住累计值 */
|
|
927
1067
|
const box = prev !== null ? prev : {
|
|
928
1068
|
sessionId: null, seen: new Map(), cost: 0, tokens: null,
|
|
929
|
-
priced: false, subCostSettled: 0, debts: new Map()
|
|
1069
|
+
priced: false, subCostSettled: 0, debts: new Map(),
|
|
1070
|
+
/* ① 用的**指纹缓存**(上一拍的 calls 数组 + 它的计数表 + 长度 + 当时的兜底模型)。
|
|
1071
|
+
挂在账本盒子上而不是模块级变量:盒子是 useRef 里那个对象,组件卸载即回收,不泄漏。 */
|
|
1072
|
+
scanCalls: null, scanCounts: null, scanFirsts: null, scanLen: -1, scanFallback: undefined
|
|
930
1073
|
};
|
|
931
1074
|
/* 换会话(含首次):整份状态清空。清空后 seen 为空,
|
|
932
1075
|
下面那段"只结算没见过的调用"自然退化成"结算现有全部调用",不必另写一套初始化。 */
|
|
@@ -1244,6 +1387,10 @@ window.__ModuleLoader__.load({
|
|
|
1244
1387
|
segCost: "会话费用",
|
|
1245
1388
|
segLastCost: "本次费用",
|
|
1246
1389
|
segBalance: "余额",
|
|
1390
|
+
/* 缺数据段的气泡标题与说明(空态也必须点得开,见 emptySegmentTip)。
|
|
1391
|
+
`{label}` 换成该段自己的名字,一个键服务九段。 */
|
|
1392
|
+
tipEmptyTitle: "{label}",
|
|
1393
|
+
tipEmptyBody: "该字段当前没有数据。开发模式下点开它会推进到下一组假数据场景,不会停在这一态。",
|
|
1247
1394
|
f_peak: "高峰",
|
|
1248
1395
|
f_valley: "低谷",
|
|
1249
1396
|
f_counts: "{turns} 轮 {steps} 步",
|
|
@@ -1266,7 +1413,6 @@ window.__ModuleLoader__.load({
|
|
|
1266
1413
|
tipTopUp: "充值余额",
|
|
1267
1414
|
tipGranted: "赠金余额",
|
|
1268
1415
|
tipQuotaLeft: "剩余配额",
|
|
1269
|
-
tipSpent: "累计消费",
|
|
1270
1416
|
tipHitTurn: "本轮命中率",
|
|
1271
1417
|
tipHitHigh: "最高命中率",
|
|
1272
1418
|
tipHitLow: "最低命中率",
|
|
@@ -1276,6 +1422,9 @@ window.__ModuleLoader__.load({
|
|
|
1276
1422
|
tipCountsToolCalls: "工具调用",
|
|
1277
1423
|
/* 手动调用 / 手动压缩那行星号的悬停说明:星号自己 aria-hidden,含义靠这句文字给 */
|
|
1278
1424
|
tipManual: "手动触发",
|
|
1425
|
+
/* 子能力(技能的子文档被读取)名字**后面**那个 † 角标的读屏文案。
|
|
1426
|
+
必须与 tipManual 分开说 —— 让读屏用户也能分清"我手动敲的"与"模型自己去读的子能力"。 */
|
|
1427
|
+
tipSub: "子能力",
|
|
1279
1428
|
/* 压缩明细逐条列时的两段:序号用中文序数(第一次 / 第二次),落点用「第X轮Y步」 */
|
|
1280
1429
|
tipCompactItem: "第{index}次压缩",
|
|
1281
1430
|
tipCompactWhere: "第{turn}轮{step}步",
|
|
@@ -1304,7 +1453,7 @@ window.__ModuleLoader__.load({
|
|
|
1304
1453
|
tipCreditsExpire: " · {date} 到期",
|
|
1305
1454
|
tipCreditsRefreshLatest: " · {date} 刷新(最近)",
|
|
1306
1455
|
tipCreditsExpireLatest: " · {date} 到期(最近)",
|
|
1307
|
-
|
|
1456
|
+
tipCreditsLeft: "剩余 {left} / {total}",
|
|
1308
1457
|
dragHint: "拖动排序",
|
|
1309
1458
|
secSegments: "统计字段",
|
|
1310
1459
|
secSegmentsHint: "勾选显示;按住拖动调整顺序。",
|
|
@@ -1371,6 +1520,9 @@ window.__ModuleLoader__.load({
|
|
|
1371
1520
|
segCost: "Session cost",
|
|
1372
1521
|
segLastCost: "This turn cost",
|
|
1373
1522
|
segBalance: "Balance",
|
|
1523
|
+
/* 缺数据段的气泡标题与说明(见中文那份的同名键) */
|
|
1524
|
+
tipEmptyTitle: "{label}",
|
|
1525
|
+
tipEmptyBody: "This field has no data right now. In dev mode, opening it advances to the next fixture scene instead of trapping you here.",
|
|
1374
1526
|
f_peak: "Peak",
|
|
1375
1527
|
f_valley: "Off-peak",
|
|
1376
1528
|
f_counts: "{turns} Turns · {steps} Steps",
|
|
@@ -1393,7 +1545,6 @@ window.__ModuleLoader__.load({
|
|
|
1393
1545
|
tipTopUp: "Top-up balance",
|
|
1394
1546
|
tipGranted: "Granted balance",
|
|
1395
1547
|
tipQuotaLeft: "Quota left",
|
|
1396
|
-
tipSpent: "Total spent",
|
|
1397
1548
|
tipHitTurn: "Hit rate this turn",
|
|
1398
1549
|
tipHitHigh: "Highest hit rate",
|
|
1399
1550
|
tipHitLow: "Lowest hit rate",
|
|
@@ -1403,6 +1554,8 @@ window.__ModuleLoader__.load({
|
|
|
1403
1554
|
tipCountsToolCalls: "Tool calls",
|
|
1404
1555
|
/* 星号的悬停说明(见中文那份的同名键) */
|
|
1405
1556
|
tipManual: "Manually invoked",
|
|
1557
|
+
/* 子能力角标的读屏文案(见中文那份的 tipSub) */
|
|
1558
|
+
tipSub: "Sub-skill",
|
|
1406
1559
|
/* 压缩明细逐条列时的两段:英文直接用阿拉伯数字,落点「Turn X·Step Y」 */
|
|
1407
1560
|
tipCompactItem: "Compaction {index}",
|
|
1408
1561
|
tipCompactWhere: "Turn {turn}·Step {step}",
|
|
@@ -1428,7 +1581,7 @@ window.__ModuleLoader__.load({
|
|
|
1428
1581
|
tipCreditsExpire: " · expires {date}",
|
|
1429
1582
|
tipCreditsRefreshLatest: " · refreshes {date} (nearest)",
|
|
1430
1583
|
tipCreditsExpireLatest: " · expires {date} (nearest)",
|
|
1431
|
-
|
|
1584
|
+
tipCreditsLeft: "{left} / {total} left",
|
|
1432
1585
|
dragHint: "Drag to reorder",
|
|
1433
1586
|
secSegments: "Stats fields",
|
|
1434
1587
|
secSegmentsHint: "Select to show; drag to reorder.",
|
|
@@ -1995,10 +2148,17 @@ window.__ModuleLoader__.load({
|
|
|
1995
2148
|
/**
|
|
1996
2149
|
* 状态位上的圆环:起点与官方一致在 12 点方向,进度是占用比例,颜色跟随运行状态。
|
|
1997
2150
|
* 轨道在压缩点处挖一个圆头缺口;算不出压缩点时轨道保持整圈。
|
|
2151
|
+
*
|
|
2152
|
+
* 六种状态:`running` 绿(Lead 在跑)/ `teamRunning` 蓝(团队成员在跑)/
|
|
2153
|
+
* `deepRunning` 紫(只有更深层在跑)/ `error` / `approval` / 无类(idle)。
|
|
2154
|
+
* 蓝紫两色复用总计令牌条已有的「输出」蓝与「未命中输入」紫,不新造颜色。
|
|
2155
|
+
* 旋转是 CSS animation(见样式表),不占 JS 定时器。
|
|
1998
2156
|
*/
|
|
1999
2157
|
function contextRing(percent, state, compact) {
|
|
2000
2158
|
const stateClass = state === "running" ? " dsb-ring-running"
|
|
2001
|
-
: (state === "
|
|
2159
|
+
: (state === "teamRunning" ? " dsb-ring-team"
|
|
2160
|
+
: (state === "deepRunning" ? " dsb-ring-deep"
|
|
2161
|
+
: (state === "error" ? " dsb-ring-error" : (state === "approval" ? " dsb-ring-approval" : ""))));
|
|
2002
2162
|
const offset = METER_CIRCUMFERENCE * (1 - Math.max(0, Math.min(100, percent)) / 100);
|
|
2003
2163
|
const dashes = notchDashes(compact);
|
|
2004
2164
|
return h("svg", {
|
|
@@ -2024,6 +2184,37 @@ window.__ModuleLoader__.load({
|
|
|
2024
2184
|
可见边缘比 border-box 再往外这么多,左右对齐时要补掉,否则看得见的边还差半像素。 */
|
|
2025
2185
|
const TIP_OUTLINE_PX = 0.5;
|
|
2026
2186
|
|
|
2187
|
+
/**
|
|
2188
|
+
* 圆环的弧长取哪一个百分比(纯函数,便于离线断言)。
|
|
2189
|
+
*
|
|
2190
|
+
* 用户原话:「那个条还是有上下文占用的百分比的,要增长的。所以如果有多个成员同时在
|
|
2191
|
+
* 运行,那么这个圆圈应该显示为同一级内上下文占用最多的那一个。成员下派的子智能体
|
|
2192
|
+
* 那一级别(也就是该显示紫色的那一部分),上下文占用要显示最少的那个。」
|
|
2193
|
+
*
|
|
2194
|
+
* 也就是**颜色与弧长是两件独立的事**:
|
|
2195
|
+
* · 颜色 = 谁在跑(Lead 绿 / 成员蓝 / 更深紫)
|
|
2196
|
+
* · 弧长 = 那一级里在跑的人们的占用,成员取**最大**、更深层取**最小**
|
|
2197
|
+
* ("最大 / 最小"由 host 在那一级内部先聚合好 —— 只有它看得到每个子会话的占用;
|
|
2198
|
+
* 客户端不重复聚合,避免两处口径打架)
|
|
2199
|
+
*
|
|
2200
|
+
* 其余四种状态(running / error / approval / idle)一律用 Lead 自己的 `contextMeter.percent`。
|
|
2201
|
+
* 分层百分比是 `null`(老 host / 那一刻算不出)时**退回** `contextMeter.percent`:
|
|
2202
|
+
* 显示 0 会让圆环突然空掉,用户只会以为插件坏了。非数字 / NaN 同样退回。
|
|
2203
|
+
*/
|
|
2204
|
+
function ringPercentOf(contextMeter, agentState, tiers) {
|
|
2205
|
+
const own = contextMeter === null || contextMeter === undefined || typeof contextMeter.percent !== "number"
|
|
2206
|
+
|| !Number.isFinite(contextMeter.percent)
|
|
2207
|
+
? 0
|
|
2208
|
+
: contextMeter.percent;
|
|
2209
|
+
const layered = agentState === "teamRunning"
|
|
2210
|
+
? (tiers === null || tiers === undefined ? null : tiers.teamPercent)
|
|
2211
|
+
: (agentState === "deepRunning"
|
|
2212
|
+
? (tiers === null || tiers === undefined ? null : tiers.deepPercent)
|
|
2213
|
+
: null);
|
|
2214
|
+
if (typeof layered !== "number" || !Number.isFinite(layered)) return own;
|
|
2215
|
+
return Math.max(0, Math.min(100, layered));
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2027
2218
|
/** 气泡的 fixed 内联位置:JS 已按视口与底栏范围夹住;pos 为空时不写。
|
|
2028
2219
|
宽度交给内容(CSS width:max-content),但比底栏还宽时按底栏跨度压住,多出的列自己折行。 */
|
|
2029
2220
|
function panelStyle(pos) {
|
|
@@ -2044,8 +2235,19 @@ window.__ModuleLoader__.load({
|
|
|
2044
2235
|
/**
|
|
2045
2236
|
* 气泡定位诊断回传:把这次算出的基准与结果发给 host 落盘(只含数字与短字符串),
|
|
2046
2237
|
* 便于在打不开控制台的环境里核对气泡为什么越界。任何失败都静默,不影响气泡本身。
|
|
2238
|
+
*
|
|
2239
|
+
* **开发模式一律不发**:那时气泡里的数字全是假的(假投影算出来的假坐标),
|
|
2240
|
+
* 诊断日志只会污染 host 的 tip-diag.log,而且用户对开发模式的要求是
|
|
2241
|
+
* 「所有数据都是伪造的,不许发任何真实请求」。
|
|
2242
|
+
*
|
|
2243
|
+
* 守的是**模块级 `devMode`** 而不是组件里的 `devActive`:两者同一份真相
|
|
2244
|
+
* (`publishDev` 读的就是 devMode),但这里是模块级函数、调用点在 StatusBar 的
|
|
2245
|
+
* layout effect 里,加参数会多一条「谁都得记得传」的隐式约定;而 devMode 与
|
|
2246
|
+
* reportTipDiag 本来就同处一个闭包,直接读它零参数、零同步风险,且比
|
|
2247
|
+
* useSyncExternalStore 的快照更早生效(进入开发模式的那一帧就不会漏发)。
|
|
2047
2248
|
*/
|
|
2048
2249
|
function reportTipDiag(payload) {
|
|
2250
|
+
if (devMode === true) return;
|
|
2049
2251
|
try {
|
|
2050
2252
|
window.fetch(TIP_DIAG_URL, {
|
|
2051
2253
|
method: "POST",
|
|
@@ -2170,9 +2372,24 @@ window.__ModuleLoader__.load({
|
|
|
2170
2372
|
/**
|
|
2171
2373
|
* 积分套餐面板(WorkBuddy):标题 + 总积分,下面每个套餐一个块:
|
|
2172
2374
|
* 第一行 = 包名/备注 + 「已用 X / 总数」;第二行 = 一条进度。
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2375
|
+
*
|
|
2376
|
+
* **进度条语义(与余额的「剩余配额」条完全一致,改一处必须同步另一处)**:
|
|
2377
|
+
* **灰 = 还剩多少,从左端起;透明 = 已用,在右。**
|
|
2378
|
+
* `segments` 恒为「灰(剩余%) + 透明(已用%)」,按顺序拼 —— flex 从左往右排,
|
|
2379
|
+
* 所以灰在前自然贴着最左端、透明落在右边。
|
|
2380
|
+
* 边界:used=0 → 只有灰 100%(额度一点没用);used>=100 → 只有透明 100%
|
|
2381
|
+
* (用完了,露出轨道底色,不画灰)。两段都用 `width: %`,宽度由浏览器按比例切。
|
|
2382
|
+
*
|
|
2383
|
+
* 为什么灰是「剩余」而不是「已用」:这套面板里灰(BALANCE_COLORS.topUp)一直是
|
|
2384
|
+
* 「账户里还剩多少钱」的固定色 —— 余额气泡的充值段、余额配额条的剩余段都用它。
|
|
2385
|
+
* 进度条如果反过来用灰表示"已用",同一个颜色在相邻两个气泡里含义相反,最容易读错。
|
|
2386
|
+
* 为什么从**左**端起(用户拍板,来回改过一次,这里记下来免得再翻烧饼):
|
|
2387
|
+
* 灰条长度 = 剩余额度,越长剩得越多;从左读起,「还剩多少」一眼就看到,
|
|
2388
|
+
* 不用先扫过一段空的已用区。用户原话:「灰条从左边起」。
|
|
2389
|
+
* 「已用 X / 总数」这行文字保持不变,所以条的方向不影响读数:字在上、条在下,
|
|
2390
|
+
* 条只承担"视觉比例"。
|
|
2391
|
+
*
|
|
2392
|
+
* `.dsb-bar` 始终渲染:used=0 时是整条灰(仍有填充),used=100 时只有空轨道。
|
|
2176
2393
|
* **聚合行(×N)可点**:点进去是子视图 —— 内容与主面板同一套套餐块(一行 + 一条进度),
|
|
2177
2394
|
* 只是每个块代表该组合并前的一个原始包(自己的名字/日期备注、真实已用/总数),竖排单列可滚动;
|
|
2178
2395
|
* 标题只显示聚合的显示名(不带 ×N),右侧不再重复总积分。点标题返回。
|
|
@@ -2186,21 +2403,21 @@ window.__ModuleLoader__.load({
|
|
|
2186
2403
|
主列表与下钻子视图共用这一份,别各写一遍。 */
|
|
2187
2404
|
const barOf = (one, key) => {
|
|
2188
2405
|
const used = Math.max(0, Math.min(100, one.percent));
|
|
2189
|
-
/*
|
|
2406
|
+
/* **灰 = 剩余、从左起;透明 = 已用、在右**(与余额配额条同一套语义)。
|
|
2407
|
+
用户拍板原话:「灰条从左边起」—— 灰条越长表示剩得越多,从左边读起,
|
|
2408
|
+
和「还剩多少」这个问题的直觉一致。
|
|
2409
|
+
只推「宽 > 0」的段,所以 used=0 只剩左边的灰 100%、used=100 只剩透明 100%。 */
|
|
2190
2410
|
const segments = [];
|
|
2191
|
-
if (used
|
|
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) {
|
|
2411
|
+
if (used < 100) {
|
|
2197
2412
|
segments.push(h("span", {
|
|
2198
2413
|
className: "dsb-bar-seg", key: key + "a",
|
|
2199
|
-
style: { width: used + "%", backgroundColor: BALANCE_COLORS.topUp }
|
|
2414
|
+
style: { width: (100 - used) + "%", backgroundColor: BALANCE_COLORS.topUp }
|
|
2200
2415
|
}));
|
|
2416
|
+
}
|
|
2417
|
+
if (used > 0) {
|
|
2201
2418
|
segments.push(h("span", {
|
|
2202
2419
|
className: "dsb-bar-seg", key: key + "b",
|
|
2203
|
-
style: { width:
|
|
2420
|
+
style: { width: used + "%", backgroundColor: "transparent" }
|
|
2204
2421
|
}));
|
|
2205
2422
|
}
|
|
2206
2423
|
return h("span", { className: "dsb-bar", key: key + "bar" }, segments);
|
|
@@ -2272,11 +2489,11 @@ window.__ModuleLoader__.load({
|
|
|
2272
2489
|
};
|
|
2273
2490
|
|
|
2274
2491
|
/* 余额构成的两色:充值取灰(同「系统提示词」图例)、赠金取紫(同「工具定义」图例)。
|
|
2275
|
-
百分比配额(GLM
|
|
2492
|
+
百分比配额(GLM 那种)只有「剩余」那段上色:取灰(充值那一档)、贴右端 ——
|
|
2493
|
+
"剩余"在语义上就是这个套餐里还没花掉的部分,和充值余额同属"可用"色系。 */
|
|
2276
2494
|
const BALANCE_COLORS = {
|
|
2277
2495
|
topUp: "var(--dsw-static-neutral-bluish-400)",
|
|
2278
|
-
granted: "#a78bfa"
|
|
2279
|
-
left: "var(--dsw-static-blue-450)"
|
|
2496
|
+
granted: "#a78bfa"
|
|
2280
2497
|
};
|
|
2281
2498
|
|
|
2282
2499
|
/* 运行用时面板的三色:等待用时取灰(同「系统提示词」图例)、模型用时(解码)取紫、工具调用取蓝 */
|
|
@@ -2415,11 +2632,73 @@ window.__ModuleLoader__.load({
|
|
|
2415
2632
|
return textWidth <= available;
|
|
2416
2633
|
}
|
|
2417
2634
|
|
|
2635
|
+
/**
|
|
2636
|
+
* 活跃总览里的消费金额文案:**按币种各算各的,不做汇率折算**。
|
|
2637
|
+
*
|
|
2638
|
+
* 用户原话:「各自算各自,不再换算成统一币种,就比如说我美元模型用了 7.77。
|
|
2639
|
+
* 人民币模型用了 145.66。那显示就应该显示 ¥145.66+$7.77」,
|
|
2640
|
+
* 以及「但是如果其中有一项为 0,也就是没产生费用,就不用显示这个 +」。
|
|
2641
|
+
*
|
|
2642
|
+
* 规则(顺序即优先级,全是用户拍板的):
|
|
2643
|
+
* · **人民币在前、美元在后**,用 `+` 连接 → `¥145.66+$7.77`
|
|
2644
|
+
* · 每项 `toFixed(2)`;负数与 NaN 一律当 0(金额是浮点累加出来的,会有 -0.0000001 这种噪声)
|
|
2645
|
+
* · **某项 ≤ 0 就不出现** —— 所以只有美元时是 `$7.77`,不会出现 `¥0.00+$7.77`
|
|
2646
|
+
* · **只有一项时不带 `+`**(`¥145.66` 而不是 `¥145.66+`)
|
|
2647
|
+
* · 两项都 ≤ 0 ⇒ `¥0.00`(这一行必须有内容,不能空)
|
|
2648
|
+
* · `byCurrency` 是空对象 / undefined / 不是对象 ⇒ **退回旧的 fallbackCost 逻辑**
|
|
2649
|
+
* (`¥` + 那个混合数):老 host 还没有这个字段,行为必须与改动前一模一样
|
|
2650
|
+
*
|
|
2651
|
+
* @param {object|undefined} byCurrency `{ CNY?: number, USD?: number }`(host 按币种分好桶)
|
|
2652
|
+
* @param {number} fallbackCost 老的混合金额(byCurrency 拿不到时用它)
|
|
2653
|
+
* @param {function} t 取文案
|
|
2654
|
+
*/
|
|
2655
|
+
function costByCurrencyText(byCurrency, fallbackCost, t) {
|
|
2656
|
+
/* **金额不足 0.01 就不显示**(用户原话:「只要金额不足 0.01,就直接不显示。」)。
|
|
2657
|
+
以前是「≤ 0 才不显示」,于是 0.002 会被 toFixed(2) 显示成 `$0.00` —— 一个
|
|
2658
|
+
"有这笔钱但看起来是零"的假零,用户明确不要。
|
|
2659
|
+
判据用 `Math.round(amount * 100) >= 1`,等价于「四舍五入到分后不是 0.00」:
|
|
2660
|
+
· 0.005 ⇒ round(0.5)=1 ⇒ **显示 `¥0.01`**(正好半分,进位)
|
|
2661
|
+
· 0.004 ⇒ round(0.4)=0 ⇒ 过滤掉
|
|
2662
|
+
负数与 NaN 仍按 0 处理(现状不变);只认 CNY / USD 两个键(现状不变)。 */
|
|
2663
|
+
const showable = (value) => {
|
|
2664
|
+
const amount = Number.isFinite(value) && value > 0 ? value : 0;
|
|
2665
|
+
return amount > 0 && Math.round(amount * 100) >= 1
|
|
2666
|
+
? amount
|
|
2667
|
+
: 0;
|
|
2668
|
+
};
|
|
2669
|
+
const parts = [];
|
|
2670
|
+
const hasMap = byCurrency !== null && byCurrency !== undefined && typeof byCurrency === "object";
|
|
2671
|
+
if (hasMap === true) {
|
|
2672
|
+
/* 固定顺序 CNY → USD:用户要的就是「人民币在前、美元在后」 */
|
|
2673
|
+
["CNY", "USD"].forEach((code) => {
|
|
2674
|
+
const amount = showable(Number(byCurrency[code]));
|
|
2675
|
+
if (amount > 0) parts.push({ code: code, amount: amount });
|
|
2676
|
+
});
|
|
2677
|
+
/* 有 map 但里面一项都不过滤门槛时,分两种情况:
|
|
2678
|
+
· **有 CNY/USD 键**(宿主算过、结果就是零,或都 < 0.005)⇒ `¥0.00`。
|
|
2679
|
+
这一行必须有内容,不能返回空串。
|
|
2680
|
+
· **完全空对象**(老 host 没有分币种数据)⇒ 走下面的 fallback,
|
|
2681
|
+
用混合 cost 兜底 —— 这是改动前就有的行为,不能因为新规则把它改掉。 */
|
|
2682
|
+
const hasAnyKey = byCurrency.CNY !== undefined || byCurrency.USD !== undefined;
|
|
2683
|
+
if (parts.length === 0 && hasAnyKey === true) return currencySymbol("CNY") + "0.00";
|
|
2684
|
+
}
|
|
2685
|
+
if (parts.length === 0) {
|
|
2686
|
+
/* 老数据兜底:宿主根本没给 byCurrency(不是"给了但是零")。
|
|
2687
|
+
同样按新规则过滤:不足 0.01 显示 ¥0.00。 */
|
|
2688
|
+
const fallback = showable(Number(fallbackCost));
|
|
2689
|
+
return currencySymbol("CNY") + fallback.toFixed(2);
|
|
2690
|
+
}
|
|
2691
|
+
return parts.map((one) => currencySymbol(one.code) + one.amount.toFixed(2)).join("+");
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2418
2694
|
/**
|
|
2419
2695
|
* 悬停浮层的内容:日期(或周区间)+ 三项总计 → 命中/未命中/输出三行 → API 请求次数 → 消费金额。
|
|
2420
2696
|
* 六行全部走「名称左、数值右」的双边对齐;数量精确到个位并带千分位,不做缩写。
|
|
2697
|
+
*
|
|
2698
|
+
* `byCurrency` 是 host 按币种分好的金额(`{CNY, USD}`);拿不到时退回 `cost` 那个混合数。
|
|
2699
|
+
* 参数顺序:`byCurrency` 紧跟 `cost` 之后、`t` 之前(`t` 始终是最后一个,与文件其它函数一致)。
|
|
2421
2700
|
*/
|
|
2422
|
-
function hoverPanelRows(label, bucket, calls, cost, t) {
|
|
2701
|
+
function hoverPanelRows(label, bucket, calls, cost, byCurrency, t) {
|
|
2423
2702
|
const hit = Number(bucket.cacheRead) || 0;
|
|
2424
2703
|
const miss = (Number(bucket.input) || 0) + (Number(bucket.cacheWrite) || 0);
|
|
2425
2704
|
const out = Number(bucket.output) || 0;
|
|
@@ -2429,7 +2708,7 @@ window.__ModuleLoader__.load({
|
|
|
2429
2708
|
hoverRow("__h2", t("f_tokensMiss"), formatTokensExact(miss)),
|
|
2430
2709
|
hoverRow("__h3", t("f_tokensOut"), formatTokensExact(out)),
|
|
2431
2710
|
hoverRow("__h4", t("hoverCalls"), formatTokensExact(calls)),
|
|
2432
|
-
hoverRow("__h5", t("hoverCost"),
|
|
2711
|
+
hoverRow("__h5", t("hoverCost"), costByCurrencyText(byCurrency, cost, t))
|
|
2433
2712
|
]);
|
|
2434
2713
|
}
|
|
2435
2714
|
|
|
@@ -2443,11 +2722,18 @@ window.__ModuleLoader__.load({
|
|
|
2443
2722
|
};
|
|
2444
2723
|
}
|
|
2445
2724
|
|
|
2446
|
-
/**
|
|
2725
|
+
/**
|
|
2726
|
+
* 把若干天并成一个桶(周条 / 全年汇总用)。
|
|
2727
|
+
*
|
|
2728
|
+
* 除了老的混合 `cost`(保持不动,老逻辑还读它),还按币种各累各的 `byCurrency`:
|
|
2729
|
+
* 用户要求活跃总览「各自算各自、不折算」,所以周汇总不能只给一个混合数。
|
|
2730
|
+
* **只写正数键**:某币种那天没有费用就不建键,让数据本身干净(显示层也会再过滤一遍)。
|
|
2731
|
+
*/
|
|
2447
2732
|
function sumDays(days) {
|
|
2448
2733
|
const total = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
2449
2734
|
let calls = 0;
|
|
2450
2735
|
let cost = 0;
|
|
2736
|
+
const byCurrency = {};
|
|
2451
2737
|
for (const day of days) {
|
|
2452
2738
|
if (day === null || day === undefined) continue;
|
|
2453
2739
|
const one = bucketOf(day);
|
|
@@ -2457,8 +2743,61 @@ window.__ModuleLoader__.load({
|
|
|
2457
2743
|
total.output += one.output;
|
|
2458
2744
|
calls += Number(day.calls) || 0;
|
|
2459
2745
|
cost += Number(day.cost) || 0;
|
|
2746
|
+
/* 按币种累加(老 host 没有这个字段 ⇒ 跳过,只靠 cost 走旧显示) */
|
|
2747
|
+
const map = day.costByCurrency;
|
|
2748
|
+
if (map !== null && map !== undefined && typeof map === "object") {
|
|
2749
|
+
Object.keys(map).forEach((code) => {
|
|
2750
|
+
const amount = Number(map[code]);
|
|
2751
|
+
if (Number.isFinite(amount) && amount > 0) {
|
|
2752
|
+
byCurrency[code] = (byCurrency[code] || 0) + amount;
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2460
2756
|
}
|
|
2461
|
-
return { bucket: total, calls: calls, cost: cost };
|
|
2757
|
+
return { bucket: total, calls: calls, cost: cost, byCurrency: byCurrency };
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
/**
|
|
2761
|
+
* 把一笔混合金额按「形态」拆成 `costByCurrency`(分币种各算各的假数据)。
|
|
2762
|
+
*
|
|
2763
|
+
* 用户这一轮要的两个新功能都要在开发模式里能被点到,所以活跃总览的每个日桶/月桶
|
|
2764
|
+
* 必须带上 `costByCurrency`,而且要**三种形态都有**,否则「某项为 0 就不显示 +」
|
|
2765
|
+
* 这条规则根本验证不到:
|
|
2766
|
+
* · `"mixed"` —— 人民币 + 美元都有(悬停显示 `¥x+$y`)
|
|
2767
|
+
* · `"cny"` —— 只有人民币(悬停显示 `¥x`,不带 +)
|
|
2768
|
+
* · `"usd"` —— 只有美元(悬停显示 `$y`,不带 +,也不出现 ¥0.00)
|
|
2769
|
+
*
|
|
2770
|
+
* **`costByCurrency` 各项之和必须严格等于 `cost`**(这条是硬约束):
|
|
2771
|
+
* 两个数对不上,用户一眼就看出是假数据。所以这里用"先按比例切、再把差额补给 CNY"
|
|
2772
|
+
* 的写法,保证分币种相加与混合值完全一致(浮点误差用四舍五入收到 4 位,
|
|
2773
|
+
* 差额直接补在人民币上 —— 它总是存在的那一项)。
|
|
2774
|
+
*
|
|
2775
|
+
* @param {number} cost 混合金额(已是四舍五入后的值)
|
|
2776
|
+
* @param {string} shape "mixed" / "cny" / "usd"
|
|
2777
|
+
* @param {number} ratio 混币时美元那一小份的占比(0~1),默认 0.18
|
|
2778
|
+
*/
|
|
2779
|
+
function devCostSplit(cost, shape, ratio) {
|
|
2780
|
+
const sum = typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : 0;
|
|
2781
|
+
if (sum <= 0) return {};
|
|
2782
|
+
if (shape === "usd") return { USD: sum };
|
|
2783
|
+
if (shape === "cny") return { CNY: sum };
|
|
2784
|
+
/* 混币:按比例切,**差额补给人民币**,保证两块相加 === sum */
|
|
2785
|
+
const share = typeof ratio === "number" && Number.isFinite(ratio) && ratio > 0 && ratio < 1 ? ratio : 0.18;
|
|
2786
|
+
const usd = Math.round(sum * share * 10000) / 10000;
|
|
2787
|
+
const cny = Math.round((sum - usd) * 10000) / 10000;
|
|
2788
|
+
return { CNY: cny, USD: usd };
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* 按天的序号挑一种形态:让同一个 fixture 里**三种形态都出现**,
|
|
2793
|
+
* 这样用户在活跃总览里逐天悬停就能看全三种写法。
|
|
2794
|
+
* 序号对 3 取模 —— 稳定(同一个 index 永远同一种形态,不随重渲染乱跳)。
|
|
2795
|
+
*/
|
|
2796
|
+
function devCostShapeAt(index) {
|
|
2797
|
+
const rest = ((index % 3) + 3) % 3;
|
|
2798
|
+
if (rest === 0) return "mixed";
|
|
2799
|
+
if (rest === 1) return "cny";
|
|
2800
|
+
return "usd";
|
|
2462
2801
|
}
|
|
2463
2802
|
|
|
2464
2803
|
/**
|
|
@@ -2486,7 +2825,7 @@ window.__ModuleLoader__.load({
|
|
|
2486
2825
|
覆盖底板左右两端"露出来"的边,以及跨月天留下的空位格;方块自己报当天,不走这里。 */
|
|
2487
2826
|
const edgeHandlers = {
|
|
2488
2827
|
onMouseEnter: (event) => onHover(event, hasData
|
|
2489
|
-
? { rows: hoverPanelRows(label, weekSum.bucket, weekSum.calls, weekSum.cost, t), week: wi }
|
|
2828
|
+
? { rows: hoverPanelRows(label, weekSum.bucket, weekSum.calls, weekSum.cost, weekSum.byCurrency, t), week: wi }
|
|
2490
2829
|
: { week: wi }),
|
|
2491
2830
|
onMouseLeave: () => onHover(null)
|
|
2492
2831
|
};
|
|
@@ -2511,7 +2850,7 @@ window.__ModuleLoader__.load({
|
|
|
2511
2850
|
key: "__hc" + day.date,
|
|
2512
2851
|
/* 没有用量的天不弹浮层 */
|
|
2513
2852
|
onMouseEnter: total > 0
|
|
2514
|
-
? (event) => onHover(event, { rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, t) })
|
|
2853
|
+
? (event) => onHover(event, { rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, day.costByCurrency, t) })
|
|
2515
2854
|
: undefined,
|
|
2516
2855
|
onMouseLeave: total > 0 ? () => onHover(null) : undefined
|
|
2517
2856
|
});
|
|
@@ -2554,7 +2893,7 @@ window.__ModuleLoader__.load({
|
|
|
2554
2893
|
className: "dsb-year-cell " + cls,
|
|
2555
2894
|
key: "__yc" + month,
|
|
2556
2895
|
onMouseEnter: total > 0
|
|
2557
|
-
? (event) => onHover(event, { rows: hoverPanelRows(label, bucketOf(one), Number(one.calls) || 0, Number(one.cost) || 0, t) })
|
|
2896
|
+
? (event) => onHover(event, { rows: hoverPanelRows(label, bucketOf(one), Number(one.calls) || 0, Number(one.cost) || 0, one.costByCurrency, t) })
|
|
2558
2897
|
: undefined,
|
|
2559
2898
|
onMouseLeave: total > 0 ? () => onHover(null) : undefined
|
|
2560
2899
|
});
|
|
@@ -2563,6 +2902,9 @@ window.__ModuleLoader__.load({
|
|
|
2563
2902
|
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
2564
2903
|
let calls = 0;
|
|
2565
2904
|
let cost = 0;
|
|
2905
|
+
/* 各月的分币种金额也一起累加(用户要求活跃总览「各自算各自」);
|
|
2906
|
+
老数据没有这个字段就跳过,只靠 cost 走旧显示。 */
|
|
2907
|
+
const yearByCurrency = {};
|
|
2566
2908
|
for (const one of year.months) {
|
|
2567
2909
|
totals.input += Number(one.input) || 0;
|
|
2568
2910
|
totals.cacheRead += Number(one.cacheRead) || 0;
|
|
@@ -2570,13 +2912,22 @@ window.__ModuleLoader__.load({
|
|
|
2570
2912
|
totals.output += Number(one.output) || 0;
|
|
2571
2913
|
calls += Number(one.calls) || 0;
|
|
2572
2914
|
cost += Number(one.cost) || 0;
|
|
2915
|
+
const map = one.costByCurrency;
|
|
2916
|
+
if (map !== null && map !== undefined && typeof map === "object") {
|
|
2917
|
+
Object.keys(map).forEach((code) => {
|
|
2918
|
+
const amount = Number(map[code]);
|
|
2919
|
+
if (Number.isFinite(amount) && amount > 0) {
|
|
2920
|
+
yearByCurrency[code] = (yearByCurrency[code] || 0) + amount;
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2573
2924
|
}
|
|
2574
2925
|
const hasYear = totals.input + totals.cacheRead + totals.cacheWrite + totals.output > 0;
|
|
2575
2926
|
const allCell = h("span", {
|
|
2576
2927
|
className: "dsb-year-cell dsb-year-all",
|
|
2577
2928
|
key: "__yall",
|
|
2578
2929
|
onMouseEnter: hasYear
|
|
2579
|
-
? (event) => onHover(event, { rows: hoverPanelRows(String(year.year) + " " + t("hoverYearAll"), totals, calls, cost, t) })
|
|
2930
|
+
? (event) => onHover(event, { rows: hoverPanelRows(String(year.year) + " " + t("hoverYearAll"), totals, calls, cost, yearByCurrency, t) })
|
|
2580
2931
|
: undefined,
|
|
2581
2932
|
onMouseLeave: hasYear ? () => onHover(null) : undefined
|
|
2582
2933
|
});
|
|
@@ -2625,7 +2976,7 @@ window.__ModuleLoader__.load({
|
|
|
2625
2976
|
/* 悬停整列都让判定区变灰 —— 没有用量的天也一样;浮层只在有用量时弹 */
|
|
2626
2977
|
onMouseEnter: (event) => onHover(event, total > 0
|
|
2627
2978
|
? {
|
|
2628
|
-
rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, t),
|
|
2979
|
+
rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, day.costByCurrency, t),
|
|
2629
2980
|
bar: index
|
|
2630
2981
|
}
|
|
2631
2982
|
: { bar: index }),
|
|
@@ -2706,6 +3057,354 @@ window.__ModuleLoader__.load({
|
|
|
2706
3057
|
return current < LIST_ROWS_PER_COLUMN_MAX ? current + 1 : current;
|
|
2707
3058
|
}
|
|
2708
3059
|
|
|
3060
|
+
/**
|
|
3061
|
+
* 滚轮 → 横向滚动:工具调用那个列网格**只能靠鼠标滚轮**横移。
|
|
3062
|
+
*
|
|
3063
|
+
* 用户拍板两条(实机反馈,原话「我用的是键盘上的方向键,而不是触控板。我要求的就是
|
|
3064
|
+
* 它只能由鼠标滚轮滑动」):
|
|
3065
|
+
* ① 纵向滚轮deltaY 转成 scrollLeft —— 否则鼠标在气泡上滚滚轮只会带动整页;
|
|
3066
|
+
* ② 键盘方向键**一律不滚**(见 dsbBlockKeyScroll),原生的方向键/PageUp/Home 滚动
|
|
3067
|
+
* 在 `overflow:auto` 的元素被点过之后就会生效,必须主动拦。
|
|
3068
|
+
*
|
|
3069
|
+
* `deltaMode` 三种(WheelEvent.DOM_DELTA_*):
|
|
3070
|
+
* 0 = 像素(触控板、大多数浏览器)→ 直接当像素用;
|
|
3071
|
+
* 1 = 行(Firefox 鼠标滚轮常见)→ 乘 `WHEEL_LINE_HEIGHT`(一行≈列表行高 22px);
|
|
3072
|
+
* 2 = 页 → 乘容器可视高度(一次滚一屏,符合"翻页"的直觉)。
|
|
3073
|
+
* 不这么换算的话:行模式下 deltaY 通常只有 3,直接当像素滚 3px,用户会觉得"滚了没反应"。
|
|
3074
|
+
*
|
|
3075
|
+
* 只在**真的能横移**时才 preventDefault:没有溢出(或已经滚到边界)时把事件放过去,
|
|
3076
|
+
* 否则用户滚轮停在气泡上会把整页钉住,反而是新的"滚不动"。
|
|
3077
|
+
*
|
|
3078
|
+
* **逐帧惯性(momentum)**:用户第二轮反馈原话「工具调用的滚动还是不够丝滑。我想要的是
|
|
3079
|
+
* 那种滑动的惯性的动效」。旧实现把 delta 直接累加到 scrollLeft(rAF 只是合并写入),
|
|
3080
|
+
* 松手即停、一步到位,所以手感是"跳"而不是"滑"。
|
|
3081
|
+
* 现在换成速度模型:每个容器存 `{ pos, velocity }`,滚轮只**给 velocity 加增量**,
|
|
3082
|
+
* 之后每帧 `velocity *= WHEEL_FRICTION`、`pos += velocity` —— 一次自然减速的滑行。
|
|
3083
|
+
*
|
|
3084
|
+
* 为什么用 rAF 而不是定时器:① rAF 与屏幕刷新同步,帧率变化时滑行距离不变;
|
|
3085
|
+
* ② rAF 不是定时器,不占 `setInterval` 计数(底栏只允许时钟 + 余额轮询两处);
|
|
3086
|
+
* ③ 速度衰减到阈值以下就**不再排下一帧**,空闲期一次回调都没有,不会空转。
|
|
3087
|
+
* preventDefault 仍然**当场**调(不能延后:延后的话浏览器可能已经开始滚页面了)。
|
|
3088
|
+
*
|
|
3089
|
+
* 注意每帧写完要**顺手刷一次渐隐**(`applyColsFade`):程序性改 scrollLeft 在部分浏览器
|
|
3090
|
+
* 里不触发 scroll 事件,不主动刷的话滚到尽头渐隐会一直留着。
|
|
3091
|
+
*/
|
|
3092
|
+
const WHEEL_LINE_HEIGHT = 22;
|
|
3093
|
+
/**
|
|
3094
|
+
* 每帧速度乘这个系数(阻尼)。0.8 是用户实机滚过之后定的:0.92 滑得太久(约 0.7 秒),
|
|
3095
|
+
* 停手以后内容还在飘,手感发黏。0.8 大约 0.25 秒收住,一推就走、松手即停。
|
|
3096
|
+
* 调小 → 停得更快更"实";调大(别过 0.97)→ 更飘更远。
|
|
3097
|
+
*/
|
|
3098
|
+
const WHEEL_FRICTION = 0.8;
|
|
3099
|
+
/**
|
|
3100
|
+
* 滚轮 delta(像素)→ 初速度(px/帧)的折算系数。
|
|
3101
|
+
* 取 (1 - friction) / friction:一次手势的总位移正好约等于原始 delta
|
|
3102
|
+
* (等比级数 Σ v0·f^k = v0/(1-f)),所以「滚一格走多远」与改造前一致,
|
|
3103
|
+
* 变的只是尾巴上的减速曲线 —— 用户不会觉得滚轮突然变快或变慢。
|
|
3104
|
+
*/
|
|
3105
|
+
const WHEEL_VELOCITY_GAIN = (1 - WHEEL_FRICTION) / WHEEL_FRICTION;
|
|
3106
|
+
/** 速度低于此值(px/帧)就归零收手:再走肉眼已看不出来,白烧一帧。 */
|
|
3107
|
+
const WHEEL_STOP_VELOCITY = 0.1;
|
|
3108
|
+
/** 速度上限(px/帧):连发事件或触控板可能给出很大的 delta,不封顶会一帧跳出半屏。 */
|
|
3109
|
+
const WHEEL_MAX_VELOCITY = 60;
|
|
3110
|
+
/** 每个容器当前的滑行状态 `{ pos, velocity }`(WeakMap:节点被回收就自动释放)。 */
|
|
3111
|
+
const wheelMotion = new WeakMap();
|
|
3112
|
+
/** 每个容器已排的 rAF id(同一节点同时只排一帧,绝不叠加)。 */
|
|
3113
|
+
const wheelFrame = new WeakMap();
|
|
3114
|
+
|
|
3115
|
+
/** 可横移的最大距离;拿不到几何(测试替身 / 面板未挂载)时按 0 处理。 */
|
|
3116
|
+
function wheelMaxScroll(node) {
|
|
3117
|
+
const scrollWidth = typeof node.scrollWidth === "number" ? node.scrollWidth : 0;
|
|
3118
|
+
const clientWidth = typeof node.clientWidth === "number" ? node.clientWidth : 0;
|
|
3119
|
+
const maxScroll = scrollWidth - clientWidth;
|
|
3120
|
+
return maxScroll > 0 ? maxScroll : 0;
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
/** 推进一帧。返回 true = 还要接着滑,false = 收手(调用方据此清状态、不再排帧)。 */
|
|
3124
|
+
function stepWheelMotion(node) {
|
|
3125
|
+
const motion = wheelMotion.get(node);
|
|
3126
|
+
if (motion === undefined) return false;
|
|
3127
|
+
/* 节点已被 React 卸载:继续算只是烧 CPU;顺带这也是"容器销毁不泄漏"的出口
|
|
3128
|
+
(WeakMap 本身不阻止回收,但排着的 rAF 会把节点多捂一帧) */
|
|
3129
|
+
if (typeof node.isConnected === "boolean" && node.isConnected === false) return false;
|
|
3130
|
+
const maxScroll = wheelMaxScroll(node);
|
|
3131
|
+
motion.velocity *= WHEEL_FRICTION;
|
|
3132
|
+
if (Math.abs(motion.velocity) < WHEEL_STOP_VELOCITY) {
|
|
3133
|
+
motion.velocity = 0;
|
|
3134
|
+
return false;
|
|
3135
|
+
}
|
|
3136
|
+
const raw = motion.pos + motion.velocity;
|
|
3137
|
+
const next = Math.max(0, Math.min(maxScroll, raw));
|
|
3138
|
+
/* 撞到两端就把速度归零:用户没要回弹,回弹会让"到底了"这个信号变模糊 */
|
|
3139
|
+
const hitEdge = next !== raw;
|
|
3140
|
+
if (hitEdge === true) motion.velocity = 0;
|
|
3141
|
+
motion.pos = next;
|
|
3142
|
+
if (typeof node.scrollLeft === "number") node.scrollLeft = next;
|
|
3143
|
+
applyColsFade(node);
|
|
3144
|
+
return hitEdge !== true;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/** 排下一帧;已有排队的就什么都不做(同一节点同时只有一帧在路上)。 */
|
|
3148
|
+
function scheduleWheelMotion(node) {
|
|
3149
|
+
if (wheelFrame.has(node) === true) return;
|
|
3150
|
+
const schedule = typeof window !== "undefined" && window !== null && typeof window.requestAnimationFrame === "function"
|
|
3151
|
+
? window.requestAnimationFrame
|
|
3152
|
+
: null;
|
|
3153
|
+
/* 没有 rAF(测试替身 / 极老环境):由调用方同步退化,这里不排队 */
|
|
3154
|
+
if (schedule === null) return;
|
|
3155
|
+
const frame = schedule(() => {
|
|
3156
|
+
wheelFrame.delete(node);
|
|
3157
|
+
if (stepWheelMotion(node) === true) {
|
|
3158
|
+
scheduleWheelMotion(node);
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
/* 收手时清掉状态:下次滚轮从真实 scrollLeft 重新取基准,不拿过期的 pos 起跳 */
|
|
3162
|
+
wheelMotion.delete(node);
|
|
3163
|
+
});
|
|
3164
|
+
/* rAF 替身可能是**同步**执行的(有的测试就是这么桩的),那样上面这段已经跑到收手、
|
|
3165
|
+
状态也清了;此时不能再把 id 记回去 —— 否则 wheelFrame 里留下"已排一帧"的假标记,
|
|
3166
|
+
之后的滚轮永远排不上帧。 */
|
|
3167
|
+
if (wheelMotion.has(node) === true && wheelFrame.has(node) !== true) wheelFrame.set(node, frame);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function wheelToHorizontalScroll(event) {
|
|
3171
|
+
const node = event === null || event === undefined ? null : event.currentTarget;
|
|
3172
|
+
if (node === null || node === undefined) return;
|
|
3173
|
+
const maxScroll = wheelMaxScroll(node);
|
|
3174
|
+
/* 没溢出(含 1px 取整误差):交回给页面,别把滚轮吃掉 */
|
|
3175
|
+
if (maxScroll <= 1) return;
|
|
3176
|
+
/* Shift + 滚轮很多浏览器已经把 deltaY 换成 deltaX 了,两个都算上 */
|
|
3177
|
+
const rawY = Number(event.deltaY) || 0;
|
|
3178
|
+
const rawX = Number(event.deltaX) || 0;
|
|
3179
|
+
const mode = Number(event.deltaMode) || 0;
|
|
3180
|
+
const perUnit = mode === 1
|
|
3181
|
+
? WHEEL_LINE_HEIGHT
|
|
3182
|
+
: (mode === 2 ? (typeof node.clientHeight === "number" && node.clientHeight > 0 ? node.clientHeight : WHEEL_LINE_HEIGHT) : 1);
|
|
3183
|
+
const delta = (Math.abs(rawY) >= Math.abs(rawX) ? rawY : rawX) * perUnit;
|
|
3184
|
+
if (delta === 0) return;
|
|
3185
|
+
/* 位置基准:滑行中就用我们自己推进的 pos(那才是屏幕上此刻的位置),
|
|
3186
|
+
空闲时以真实 scrollLeft 为准 —— 用户拖过滚动条、或内容重排过,
|
|
3187
|
+
都不会从过期位置起跳。 */
|
|
3188
|
+
const inFlight = wheelMotion.has(node) === true && wheelFrame.has(node) === true;
|
|
3189
|
+
const current = inFlight
|
|
3190
|
+
? wheelMotion.get(node)
|
|
3191
|
+
: { pos: Math.max(0, Math.min(maxScroll, Number(node.scrollLeft) || 0)), velocity: 0 };
|
|
3192
|
+
const atLeft = current.pos <= 0;
|
|
3193
|
+
const atRight = current.pos >= maxScroll - 1;
|
|
3194
|
+
/* 已经贴边且继续往那个方向滚:放行,让页面自己滚(别在气泡边缘"顶住") */
|
|
3195
|
+
if ((delta < 0 && atLeft) || (delta > 0 && atRight)) return;
|
|
3196
|
+
/* preventDefault 必须当场调:延到 rAF 里浏览器可能已经开始滚页面了 */
|
|
3197
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
3198
|
+
const schedule = typeof window !== "undefined" && window !== null && typeof window.requestAnimationFrame === "function"
|
|
3199
|
+
? window.requestAnimationFrame
|
|
3200
|
+
: null;
|
|
3201
|
+
if (schedule === null || typeof node.scrollLeft !== "number") {
|
|
3202
|
+
/* 没有 rAF / 没有 scrollLeft:没法逐帧缓动,退回一次性位移
|
|
3203
|
+
(测试替身 / 极老环境),行为仍然正确 —— 至少内容会动。 */
|
|
3204
|
+
const next = Math.max(0, Math.min(maxScroll, current.pos + delta));
|
|
3205
|
+
if (typeof node.scrollLeft === "number") node.scrollLeft = next;
|
|
3206
|
+
applyColsFade(node);
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
/* 滚轮只给速度加增量,位置交给帧循环推进 —— 惯性就来自这里。
|
|
3210
|
+
delta 先按 gain 折算成 px/帧再封顶;连发时速度是**叠加**的,所以越滚越快。 */
|
|
3211
|
+
const boost = delta * WHEEL_VELOCITY_GAIN;
|
|
3212
|
+
current.velocity = Math.max(-WHEEL_MAX_VELOCITY, Math.min(WHEEL_MAX_VELOCITY, current.velocity + boost));
|
|
3213
|
+
current.pos = Math.max(0, Math.min(maxScroll, current.pos));
|
|
3214
|
+
wheelMotion.set(node, current);
|
|
3215
|
+
scheduleWheelMotion(node);
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
/** 这些键在可滚容器上会触发原生滚动,必须拦掉(用户只要鼠标滚轮)。 */
|
|
3219
|
+
const SCROLL_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"];
|
|
3220
|
+
|
|
3221
|
+
/**
|
|
3222
|
+
* 拦方向键/翻页键:容器只有 `overflow:auto` 时,点过它一次就获得焦点,
|
|
3223
|
+
* 之后按方向键浏览器会自己滚它 —— 用户明确不要这条路,全部 preventDefault。
|
|
3224
|
+
* 只拦滚动键,别的键(Tab / Esc / 复制粘贴)照常放行。
|
|
3225
|
+
*/
|
|
3226
|
+
function blockKeyScroll(event) {
|
|
3227
|
+
const key = event === null || event === undefined ? null : event.key;
|
|
3228
|
+
if (typeof key !== "string" || SCROLL_KEYS.indexOf(key) === -1) return;
|
|
3229
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/**
|
|
3233
|
+
* 右缘淡出:给 `.dsb-meter-cols` 挂 / 摘 `dsb-cols-fade` 控制遮罩要不要出现。
|
|
3234
|
+
*
|
|
3235
|
+
* **为什么不直接在 CSS 里常开 mask**:常开的话"已经滚到最右"时最右那一列也会被
|
|
3236
|
+
* 渐变啃掉一条,看起来像内容被裁了。所以按几何切 class:
|
|
3237
|
+
* · 没溢出(scrollWidth ≈ clientWidth)→ 不挂(整排都看得见,没有"还有更多"的暗示)
|
|
3238
|
+
* · 滚到最右(scrollLeft + clientWidth ≈ scrollWidth)→ 不挂
|
|
3239
|
+
* · 其余(还有内容在右边)→ 挂上
|
|
3240
|
+
* 遮罩只做视觉,**不改任何布局尺寸**(mask-image 不参与排版),所以气泡宽度/列宽不变。
|
|
3241
|
+
*
|
|
3242
|
+
* **性能与闪烁**(用户实机反馈「一卡一顿、闪烁」的两个成因,都在这里治):
|
|
3243
|
+
* ① 原实现每个 scroll 事件都 `classList.toggle` —— 即使值没变也会触发样式重算,
|
|
3244
|
+
* 而 mask-image 一变就要整层重新栅格化,Windows Chromium 上表现为闪。
|
|
3245
|
+
* 现在用 **ref 记住上一次的完整状态(含两侧像素宽度)**,只在**真的变化**时才碰
|
|
3246
|
+
* class / 写 CSS 变量。
|
|
3247
|
+
* ② scroll 是高频事件(一次滚轮可能几十个),用 **requestAnimationFrame 合并**:
|
|
3248
|
+
* 一帧最多算一次,且同一帧内到达的多个 scroll 只保留最后一次。
|
|
3249
|
+
* rAF 不是定时器(不受 setInterval 计数约束),也不会有"自己造循环"的问题 ——
|
|
3250
|
+
* 每帧最多排一次,没有新 scroll 就不再排。
|
|
3251
|
+
*
|
|
3252
|
+
* 首次不显示的问题见 `primeColsFade`(挂载那一刻主动算一次);
|
|
3253
|
+
* **逐档加行之后不重算**的问题见 `refreshColsFade`(在 useTipLayout 里每次渲染后调一次)——
|
|
3254
|
+
* 那条是用户第二次实机报的:「能完整显示、不需要滑动,右侧却挂着一道淡出」。
|
|
3255
|
+
*/
|
|
3256
|
+
const COLS_FADE_CLASS = "dsb-cols-fade";
|
|
3257
|
+
/**
|
|
3258
|
+
* 渐隐带的宽度(px)。两侧共用同一个值,**是固定值,不随溢出量变化**。
|
|
3259
|
+
* 24 → 40:用户实机看过后觉得"淡出范围太窄、像被切了一刀",加宽到 40 才像自然淡出。
|
|
3260
|
+
* 曾经试过按实际溢出量收窄(`min(40, 溢出量)`),被用户否掉,原话:
|
|
3261
|
+
* 「遮罩的宽度是固定的呀,溢出两个像素,就抹两个像素,那样好看吗?」
|
|
3262
|
+
* 理由:宽度随内容跳变会让同一种淡出忽宽忽窄、溢出 2px 时更是不成形;
|
|
3263
|
+
* 固定 40px + 只在"挂不挂"上二值切换,观感才稳定。
|
|
3264
|
+
*/
|
|
3265
|
+
const COLS_FADE_PX = 40;
|
|
3266
|
+
/**
|
|
3267
|
+
* 每个滚动容器上一次的 `{left, right}`(WeakMap:节点被回收就自动清掉,不泄漏)。
|
|
3268
|
+
* 宽度现在是常量(只可能是 0 或 COLS_FADE_PX),所以只需记"挂不挂"这两个布尔 ——
|
|
3269
|
+
* 布尔一变就整体重写两个变量,不会出现"宽度该改却没改"的情况。
|
|
3270
|
+
*/
|
|
3271
|
+
const colsFadeLast = new WeakMap();
|
|
3272
|
+
/** 每个滚动容器已排的 rAF id(同一帧只排一次)。 */
|
|
3273
|
+
const colsFadeFrame = new WeakMap();
|
|
3274
|
+
|
|
3275
|
+
/**
|
|
3276
|
+
* 按几何算一次该不该挂渐隐,**只在状态真的变化时**才动 class / 写内联变量。
|
|
3277
|
+
* 返回 `{left, right, leftPx, rightPx}`(两个 Px 恒为 `0` 或 `COLS_FADE_PX`)。
|
|
3278
|
+
*
|
|
3279
|
+
* 两侧各自独立判断:
|
|
3280
|
+
* · `right` —— 右边还有没滚到的内容(`overflow - scrollLeft > 1px`)
|
|
3281
|
+
* · `left` —— 左边已经有内容滚出去了(`scrollLeft > 1px`)
|
|
3282
|
+
* 两个都为 false 时整条 mask 摘掉:不挂 class 就不参与合成,滚动时也没有额外重绘。
|
|
3283
|
+
*
|
|
3284
|
+
* **宽度固定 40px,不按溢出量收窄**(用户拍板,理由见 COLS_FADE_PX 上方注释)。
|
|
3285
|
+
* 也就是说这里只做"该不该挂"的二值判断,不关心溢出 2px 还是 200px ——
|
|
3286
|
+
* 溢出一丁点就说明要滚,那就给同样的 40px 淡出。
|
|
3287
|
+
*
|
|
3288
|
+
* **还能继续加行的容器一律不挂**:见下面 `data-cols-grow` 那段。
|
|
3289
|
+
*
|
|
3290
|
+
* 为什么用 CSS 变量而不是写死两个 class:mask-image 是单条属性,两条规则会互相覆盖,
|
|
3291
|
+
* 只有把「左边留多宽 / 右边留多宽」变成变量,才能在一条 gradient 里同时表达两侧。
|
|
3292
|
+
* 变量只在状态真的变化那一帧写一次,滚动过程中完全不碰。
|
|
3293
|
+
*/
|
|
3294
|
+
function applyColsFade(node) {
|
|
3295
|
+
const idle = { left: false, right: false, leftPx: 0, rightPx: 0 };
|
|
3296
|
+
if (node === null || node === undefined) return idle;
|
|
3297
|
+
if (node.classList === undefined || node.classList === null || typeof node.classList.add !== "function") return idle;
|
|
3298
|
+
if (typeof node.classList.remove !== "function") return idle;
|
|
3299
|
+
const scrollWidth = typeof node.scrollWidth === "number" ? node.scrollWidth : 0;
|
|
3300
|
+
const clientWidth = typeof node.clientWidth === "number" ? node.clientWidth : 0;
|
|
3301
|
+
const scrollLeft = Number(node.scrollLeft) || 0;
|
|
3302
|
+
const overflow = scrollWidth - clientWidth;
|
|
3303
|
+
/* **还能继续加行的容器一律不显示遮罩**(用户最终判据,原话:
|
|
3304
|
+
「只有超过每列 12 的情况才应该有这个淡出遮罩,因为其他情况都不涉及到滚动。」)。
|
|
3305
|
+
`data-cols-grow="1"` 由 rowsPanel 只挂在「工具调用子视图 + perColumn 未到 12 行」的
|
|
3306
|
+
多列容器上。此时量出来的溢出只是"还没加完行"的中间态 —— 下一帧 useTipLayout 会
|
|
3307
|
+
继续加行、气泡随之变宽,用户永远不需要横向滚,挂遮罩就是误报
|
|
3308
|
+
(用户实机报的「4 行 × 5 列、明明能完整显示却有淡出」正是这个中间态)。
|
|
3309
|
+
非 tight 的多列面板(技能列表 / 压缩列表)没有加行机制,不带这个属性 ⇒ 照旧按
|
|
3310
|
+
几何决定,溢出就是真要滚。
|
|
3311
|
+
用 DOM 属性而不是给函数加参数:applyColsFade 的调用点有 4 处(挂载 ref、onScroll、
|
|
3312
|
+
滚轮写入后、refreshColsFade),挨个传参会漏;属性跟着节点走,谁调都读得到。 */
|
|
3313
|
+
const growable = typeof node.getAttribute === "function" && node.getAttribute("data-cols-grow") === "1";
|
|
3314
|
+
/* 1px 容差与 toolColumnsOverflow 同口径:亚像素取整不该被当成"还有内容" */
|
|
3315
|
+
const hiddenRight = overflow - scrollLeft;
|
|
3316
|
+
const showRight = growable !== true && overflow > 1 && hiddenRight > 1;
|
|
3317
|
+
const showLeft = growable !== true && overflow > 1 && scrollLeft > 1;
|
|
3318
|
+
/* 宽度**固定** COLS_FADE_PX:只在"挂不挂"上二值切换,不按溢出量收窄
|
|
3319
|
+
(用户拍板理由见 COLS_FADE_PX 上方注释)。所以这里只有 0 / 40 两种值。 */
|
|
3320
|
+
const state = {
|
|
3321
|
+
right: showRight,
|
|
3322
|
+
left: showLeft,
|
|
3323
|
+
rightPx: showRight === true ? COLS_FADE_PX : 0,
|
|
3324
|
+
leftPx: showLeft === true ? COLS_FADE_PX : 0
|
|
3325
|
+
};
|
|
3326
|
+
const last = colsFadeLast.get(node);
|
|
3327
|
+
/* **只有"挂不挂"真的变化才动 DOM**(关键:没变时连 classList / style 都不碰,不会每帧重新栅格化)。
|
|
3328
|
+
宽度是常量,所以只比两个布尔就够 —— 布尔一变,两个变量整体重写(恒为 0 / 40)。 */
|
|
3329
|
+
if (last !== undefined && last.left === state.left && last.right === state.right) return state;
|
|
3330
|
+
colsFadeLast.set(node, state);
|
|
3331
|
+
if (state.left !== true && state.right !== true) {
|
|
3332
|
+
node.classList.remove(COLS_FADE_CLASS);
|
|
3333
|
+
return state;
|
|
3334
|
+
}
|
|
3335
|
+
node.classList.add(COLS_FADE_CLASS);
|
|
3336
|
+
/* 只在拿得到内联样式时才写(测试替身可能没有 style) */
|
|
3337
|
+
if (node.style !== undefined && node.style !== null && typeof node.style.setProperty === "function") {
|
|
3338
|
+
node.style.setProperty("--dsb-fade-l", state.leftPx + "px");
|
|
3339
|
+
node.style.setProperty("--dsb-fade-r", state.rightPx + "px");
|
|
3340
|
+
}
|
|
3341
|
+
return state;
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
/** 首次挂载 / 尺寸变化时主动算一次:光等 onScroll 的话,刚打开的面板永远等不到事件。 */
|
|
3345
|
+
function primeColsFade(node) {
|
|
3346
|
+
if (node === null || node === undefined) return;
|
|
3347
|
+
applyColsFade(node);
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* 对一个**面板**里所有列网格重算渐隐。
|
|
3352
|
+
*
|
|
3353
|
+
* 为什么需要这个入口:渐隐原来只在「挂载那一刻」和「滚动时」重算,而工具气泡会
|
|
3354
|
+
* **逐档加行**(3 → 4 → 5 …)—— 加完之后列数变少、气泡变宽,可能已经不需要滚动了,
|
|
3355
|
+
* 但节点被 React 复用(key 没变、ref 是模块级常量),ref 不会再触发 ⇒ 遮罩一直留着。
|
|
3356
|
+
* 用户实机看到的就是「4 行 × 5 列、明明不用滚,右侧却有一道淡出」。
|
|
3357
|
+
*
|
|
3358
|
+
* 取法与 toolColumnsOverflow 完全一致(同为 `panel.querySelectorAll(".dsb-meter-cols")`)。
|
|
3359
|
+
* 拿不到 DOM 几何(测试替身)时 applyColsFade 自己会兜住,不报错。
|
|
3360
|
+
*/
|
|
3361
|
+
function refreshColsFade(panel) {
|
|
3362
|
+
if (panel === null || panel === undefined) return;
|
|
3363
|
+
if (typeof panel.querySelectorAll !== "function") return;
|
|
3364
|
+
const grids = panel.querySelectorAll(".dsb-meter-cols");
|
|
3365
|
+
if (grids === null || grids === undefined) return;
|
|
3366
|
+
for (let g = 0; g < grids.length; g += 1) applyColsFade(grids[g]);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* 列网格的回调 ref:**必须是模块级常量**,不能放进渲染函数里。
|
|
3371
|
+
*
|
|
3372
|
+
* 为什么:React 在 ref 回调的**引用**变化时会先 `ref(null)` 再 `ref(node)`。
|
|
3373
|
+
* 渲染函数里每帧都写 `const colsFadeRef = (node) => ...`,等于每帧都换一个新函数,
|
|
3374
|
+
* 于是底栏每秒一次的重渲染都会重新触发一次 ref 回调 —— 而 `applyColsFade` 第一件事
|
|
3375
|
+
* 就是读 `scrollWidth` / `clientWidth`,那是**强制同步布局**。打开多列工具气泡
|
|
3376
|
+
* (尤其 420 个工具那档)期间就变成每秒一次强制布局,正是用户报的「一卡一顿」。
|
|
3377
|
+
* 换成模块级常量后引用永远不变,只在真正挂载/卸载时才跑。
|
|
3378
|
+
*
|
|
3379
|
+
* (这里也不能用 react.useRef:rowsPanel 是 StatusBar 渲染时直接调用的普通函数,
|
|
3380
|
+
* 在里面调 hook 会往 hook 链里插一个时有时无的 hook,破坏 hook 顺序。)
|
|
3381
|
+
*/
|
|
3382
|
+
const colsFadeRef = (node) => { primeColsFade(node); };
|
|
3383
|
+
|
|
3384
|
+
/**
|
|
3385
|
+
* onScroll 入口:用 rAF 合并,一帧最多算一次。
|
|
3386
|
+
* `event.currentTarget` 在异步回调里会变成 null(React 合成事件会回收),所以**先取出节点**。
|
|
3387
|
+
*/
|
|
3388
|
+
function syncColsFade(event) {
|
|
3389
|
+
const node = event === null || event === undefined ? null : event.currentTarget;
|
|
3390
|
+
if (node === null || node === undefined) return;
|
|
3391
|
+
/* 已经排过这一帧:什么都不做(被合并掉) */
|
|
3392
|
+
if (colsFadeFrame.has(node) === true) return;
|
|
3393
|
+
const schedule = typeof window !== "undefined" && window !== null && typeof window.requestAnimationFrame === "function"
|
|
3394
|
+
? window.requestAnimationFrame
|
|
3395
|
+
: null;
|
|
3396
|
+
if (schedule === null) {
|
|
3397
|
+
/* 没有 rAF(测试替身 / 极老环境):直接算,退化成同步版本,行为仍然正确 */
|
|
3398
|
+
applyColsFade(node);
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
const frame = schedule(() => {
|
|
3402
|
+
colsFadeFrame.delete(node);
|
|
3403
|
+
applyColsFade(node);
|
|
3404
|
+
});
|
|
3405
|
+
colsFadeFrame.set(node, frame);
|
|
3406
|
+
}
|
|
3407
|
+
|
|
2709
3408
|
/**
|
|
2710
3409
|
* 工具调用子视图的列网格**是否横向放不下**:放不下返回 true,交给 nextToolRows 逐档加行
|
|
2711
3410
|
* (3 → 4 → 5 …,行数加上去列数就变少,直到一排放得下)。
|
|
@@ -2718,13 +3417,20 @@ window.__ModuleLoader__.load({
|
|
|
2718
3417
|
* 判据用 `1px` 容差:scrollWidth / clientWidth 都是整数,亚像素取整可能差 1px,
|
|
2719
3418
|
* 差 1px 不该被当成溢出,否则会白加一档行数。真溢出时通常差几十上百像素。
|
|
2720
3419
|
*
|
|
2721
|
-
*
|
|
3420
|
+
* **所有支持逐档加行的子视图**都参与:工具调用、技能注入、上下文压缩这三个
|
|
3421
|
+
* (它们的列表对象上带 `growable: true`,渲染时多列容器会带 `data-cols-growable`)。
|
|
3422
|
+
* 以前这里只认 `dsb-meter-tight`,于是后两个列表固定 3 行、直接横向滚动 ——
|
|
3423
|
+
* 用户明确要求「技能注入也和工具调用同理……上下文压缩也同理」。
|
|
3424
|
+
* **不能用 tight 当判据**:tight 是视觉样式(收窄名字与数值的间隙),只适合短英文工具名;
|
|
3425
|
+
* 技能名和「第二次压缩*」都很长,套 tight 会挤在一起。所以判据换成能力标记。
|
|
2722
3426
|
* 拿不到 DOM 几何(测试替身 / 面板还没挂上)时一律当"放得下",避免在非浏览器环境里误判。
|
|
2723
3427
|
*/
|
|
2724
3428
|
function toolColumnsOverflow(panel) {
|
|
2725
3429
|
if (panel === null || panel === undefined) return false;
|
|
2726
3430
|
if (typeof panel.querySelector !== "function" || typeof panel.querySelectorAll !== "function") return false;
|
|
2727
|
-
|
|
3431
|
+
/* `data-cols-growable` 表示"这类列表支持加行"(与 `data-cols-grow` 不是一回事:
|
|
3432
|
+
后者表示"当前还没加到 12 行上限",只给遮罩判据用)。两者都要,别合并。 */
|
|
3433
|
+
if (panel.querySelector("[data-cols-growable]") === null) return false;
|
|
2728
3434
|
const grids = panel.querySelectorAll(".dsb-meter-cols");
|
|
2729
3435
|
if (grids === null || grids === undefined) return false;
|
|
2730
3436
|
for (let g = 0; g < grids.length; g += 1) {
|
|
@@ -2747,14 +3453,20 @@ window.__ModuleLoader__.load({
|
|
|
2747
3453
|
*/
|
|
2748
3454
|
function rowsPanel(tip, pos, ref, sub) {
|
|
2749
3455
|
const rows = tip.rows === undefined ? [] : tip.rows;
|
|
3456
|
+
/* 列网格的 ref:节点一挂上就量一次几何(右缘渐隐"首次就要显示"靠它)。
|
|
3457
|
+
**注意这里不能用 react.useRef** —— rowsPanel 是 StatusBar 渲染时**直接调用的普通函数**
|
|
3458
|
+
(不是组件),在里面调 hook 会往 StatusBar 的 hook 链里插一个条件下才出现的 hook,
|
|
3459
|
+
破坏 hook 顺序。所以用**回调 ref**:React 挂载节点时回调一次,值不进 hook 链。
|
|
3460
|
+
ref 用模块级常量(见 colsFadeRef 定义处):每帧新建函数会让 React 每帧重跑一次
|
|
3461
|
+
ref 回调,进而每帧强制同步布局。 */
|
|
2750
3462
|
const openedAt = sub === undefined || sub === null || typeof sub.id !== "number" ? null : sub.id;
|
|
2751
3463
|
const opened = openedAt === null || rows[openedAt] === undefined || rows[openedAt].sub === undefined
|
|
2752
3464
|
? null
|
|
2753
3465
|
: rows[openedAt].sub;
|
|
2754
3466
|
/* 一行里的三块:色块(可选,与上面那条色条一一对应)+ 名称 + 数值。
|
|
2755
|
-
名称也可能是节点数组(手动调用 /
|
|
2756
|
-
|
|
2757
|
-
|
|
3467
|
+
名称也可能是节点数组(手动调用 / 手动压缩的名字**后面**要挂上标星号,
|
|
3468
|
+
子能力的名字后面挂 `+` 角标 —— 拼成字符串就插不进去了),这里原样交给
|
|
3469
|
+
React 当子节点。可点行与普通行共用这一份,别各写一遍。 */
|
|
2758
3470
|
const parts = (row, key) => [
|
|
2759
3471
|
row.color === undefined || row.color === null
|
|
2760
3472
|
? null
|
|
@@ -2782,10 +3494,13 @@ window.__ModuleLoader__.load({
|
|
|
2782
3494
|
}, parts(row, "__xr" + index));
|
|
2783
3495
|
}));
|
|
2784
3496
|
} else {
|
|
2785
|
-
/*
|
|
2786
|
-
|
|
3497
|
+
/* 每列几行:**支持加行的三个子视图**(工具调用 / 技能注入 / 上下文压缩)
|
|
3498
|
+
由 StatusBar 量完 DOM 后逐档加(3 → 4 → 5 …,最多 12),其余子视图照旧 3。
|
|
3499
|
+
判据用 `growable`(能力)而不是 `tight`(视觉):tight 只管把名字与数值的
|
|
3500
|
+
间隙收到 1ch,只适合短英文工具名;技能名与「第二次压缩*」都很长,套了会挤。
|
|
3501
|
+
所有行统一走**一个**列网格 —— 工具名就是原生英文名,
|
|
2787
3502
|
不再有「中文/英文整行块」两套结构。 */
|
|
2788
|
-
const perColumn = opened.
|
|
3503
|
+
const perColumn = opened.growable === true && sub !== undefined && sub !== null
|
|
2789
3504
|
&& typeof sub.rowsPerColumn === "number"
|
|
2790
3505
|
? sub.rowsPerColumn
|
|
2791
3506
|
: LIST_ROWS_PER_COLUMN;
|
|
@@ -2796,11 +3511,44 @@ window.__ModuleLoader__.load({
|
|
|
2796
3511
|
const cls = "dsb-meter-rows dsb-meter-rows-fit"
|
|
2797
3512
|
+ (columns.length > 1 ? " dsb-meter-cols" : "")
|
|
2798
3513
|
+ (opened.tight === true ? " dsb-meter-tight" : "");
|
|
3514
|
+
/* 多列时的交互约定(用户拍板):
|
|
3515
|
+
· 横向滚动**只能用鼠标滚轮** —— onWheel 把 deltaY 转成 scrollLeft 并
|
|
3516
|
+
preventDefault(详见 wheelToHorizontalScroll 的 deltaMode 说明);
|
|
3517
|
+
· 方向键/翻页键一律拦掉(onKeyDown + blockKeyScroll);
|
|
3518
|
+
· **不可聚焦**:tabIndex -1 让它不参与 Tab 序列,也不给浏览器"点过就获得焦点、
|
|
3519
|
+
随后方向键能滚"的机会。overlay 上的焦点由 stopTipClick 挡住冒泡来保证。
|
|
3520
|
+
· 右缘淡出:`ref: colsFadeRef` 在**挂载那一刻**就量一次几何(这就是"首次打开
|
|
3521
|
+
也要有渐隐"的修法 —— 光靠 onScroll 永远等不到首次事件),之后滚动由
|
|
3522
|
+
syncColsFade 用 rAF 合并着维护。
|
|
3523
|
+
· `data-cols-grow="1"`:**这个容器还能继续加行**(支持加行的子视图且未到 12 行上限)。
|
|
3524
|
+
applyColsFade 见到它就不显示遮罩 —— 加行机制保证未到上限前程序会继续加行,
|
|
3525
|
+
此时"量出来溢出"只是还没加完行的中间态,用户并不会真的需要横向滚。
|
|
3526
|
+
用户最终判据原话:「只有超过每列 12 的情况才应该有这个淡出遮罩,
|
|
3527
|
+
因为其他情况都不涉及到滚动。」
|
|
3528
|
+
· `data-cols-growable="1"`:**这类列表支持加行**(能力标记,与上面那个不是一回事)。
|
|
3529
|
+
它跟着 `opened.growable` 走,加到 12 行上限后**依然带着** —— 因为
|
|
3530
|
+
`toolColumnsOverflow` 靠它判断"要不要量溢出",而那时仍然需要量
|
|
3531
|
+
(量出来才知道该不该显示遮罩)。两个属性都留着,别合并。 */
|
|
3532
|
+
const canGrow = opened.growable === true;
|
|
3533
|
+
const stillGrowable = canGrow === true && perColumn < LIST_ROWS_PER_COLUMN_MAX;
|
|
3534
|
+
const colProps = columns.length > 1
|
|
3535
|
+
? {
|
|
3536
|
+
className: cls,
|
|
3537
|
+
key: "__xrows",
|
|
3538
|
+
ref: colsFadeRef,
|
|
3539
|
+
tabIndex: -1,
|
|
3540
|
+
onWheel: wheelToHorizontalScroll,
|
|
3541
|
+
onKeyDown: blockKeyScroll,
|
|
3542
|
+
onScroll: syncColsFade,
|
|
3543
|
+
"data-cols-growable": canGrow === true ? "1" : undefined,
|
|
3544
|
+
"data-cols-grow": stillGrowable === true ? "1" : undefined
|
|
3545
|
+
}
|
|
3546
|
+
: { className: cls, key: "__xrows" };
|
|
2799
3547
|
body = columns.length > 1
|
|
2800
|
-
? h("span",
|
|
3548
|
+
? h("span", colProps,
|
|
2801
3549
|
columns.map((column, at) => h("span", { className: "dsb-meter-col", key: "__col" + at },
|
|
2802
3550
|
column.map((row, index) => cell(row, "__c" + at + "r" + index)))))
|
|
2803
|
-
: h("span",
|
|
3551
|
+
: h("span", colProps,
|
|
2804
3552
|
(columns[0] === undefined ? [] : columns[0]).map((row, index) => cell(row, "__r" + index)));
|
|
2805
3553
|
}
|
|
2806
3554
|
/* 标题下那条:根视图用 tip.segments(余额/用时那种),子视图用 opened.segments(工具调用彩条);
|
|
@@ -2923,7 +3671,8 @@ window.__ModuleLoader__.load({
|
|
|
2923
3671
|
|
|
2924
3672
|
/** 毫秒 → 面板里的时长文本(精确到秒);0 或缺数据给横杠。 */
|
|
2925
3673
|
function tipDuration(ms) {
|
|
2926
|
-
|
|
3674
|
+
/* 面板要精确到秒,底栏那条省秒(宽度有限)。 */
|
|
3675
|
+
const text = formatDuration(ms, true);
|
|
2927
3676
|
return text === null ? "-" : text;
|
|
2928
3677
|
}
|
|
2929
3678
|
|
|
@@ -2944,17 +3693,6 @@ window.__ModuleLoader__.load({
|
|
|
2944
3693
|
].filter((one) => one.width > 0);
|
|
2945
3694
|
}
|
|
2946
3695
|
|
|
2947
|
-
/** 时长精确到秒:1h23m45s / 23m45s / 45s。底栏那条用 formatDuration(省掉秒,宽度有限)。 */
|
|
2948
|
-
function formatDurationFine(ms) {
|
|
2949
|
-
const n = Number(ms) || 0;
|
|
2950
|
-
if (n <= 0) return null;
|
|
2951
|
-
const whole = Math.round(n / 1000);
|
|
2952
|
-
if (whole < 60) return whole + "s";
|
|
2953
|
-
const minutes = Math.floor(whole / 60);
|
|
2954
|
-
if (minutes < 60) return minutes + "m" + (whole % 60) + "s";
|
|
2955
|
-
return Math.floor(minutes / 60) + "h" + (minutes % 60) + "m" + (whole % 60) + "s";
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
3696
|
/** 本轮命中率:这一轮的输入 token 里命中缓存的比例;本轮还没有调用给横杠。
|
|
2959
3697
|
读插件自己的 desktopStatusbarUsage 投影 —— 官方 tokenUsage 只有会话累计值。
|
|
2960
3698
|
注意字段是短名(input / cacheRead / cacheWrite),与官方投影的 *Tokens 不同名。 */
|
|
@@ -3032,6 +3770,27 @@ window.__ModuleLoader__.load({
|
|
|
3032
3770
|
}, "*");
|
|
3033
3771
|
}
|
|
3034
3772
|
|
|
3773
|
+
/**
|
|
3774
|
+
* **子能力**(`sub: true`)名字后面的角标:一个上标的 `+`。
|
|
3775
|
+
*
|
|
3776
|
+
* 符号由用户指定(先前试过 `†`,用户看完实机后要求换成 `+`)。
|
|
3777
|
+
* `+` 比 `*` / `†` **笔画细、体量小**,同一个 `.dsb-sup` 字号(.7em)下会显得比星号轻一档,
|
|
3778
|
+
* 所以额外挂一个 `.dsb-sup-strong` 修饰类,把字重提到 600 —— 只改粗细,**不改字号与抬高量**:
|
|
3779
|
+
* 两种角标的行盒高度必须一样,否则同一列表里一行高一行低(`.dsb-sup` 的 line-height:0 就是为此),
|
|
3780
|
+
* 只加粗不会碰到这一点。
|
|
3781
|
+
*
|
|
3782
|
+
* 读屏另有文案(`tipSub`),不与手动的「手动触发」重名。
|
|
3783
|
+
*/
|
|
3784
|
+
function subStar(t) {
|
|
3785
|
+
/* 与 manualStar 同样不挂 title:亚 11px 的角标上没有可停留的鼠标热区 */
|
|
3786
|
+
return h("span", {
|
|
3787
|
+
className: "dsb-sup dsb-sup-strong",
|
|
3788
|
+
key: "__substar",
|
|
3789
|
+
role: "img",
|
|
3790
|
+
"aria-label": t("tipSub")
|
|
3791
|
+
}, "+");
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3035
3794
|
/** 子视图没有内容时给一行横杠:气泡任何情况下都要打得开,也不能是一片空白。 */
|
|
3036
3795
|
function emptyList(title) {
|
|
3037
3796
|
return { title: title, rows: [{ label: title, value: "-" }] };
|
|
@@ -3041,17 +3800,25 @@ window.__ModuleLoader__.load({
|
|
|
3041
3800
|
* 技能注入子视图:只列名字,**不显示统计次数**(用户明确要求:次数只用来排序)。
|
|
3042
3801
|
* 同名注入在内部聚合计数,排序 = **名字短的在前** → 注入次数多的在前 → 字典序;
|
|
3043
3802
|
* 次数只参与排序,不进界面(与工具调用「次」那套无关)。
|
|
3044
|
-
*
|
|
3803
|
+
*
|
|
3804
|
+
* 两种角标(用户拍板:**平铺、不加父技能前缀**,两个都挂在名字**后面**):
|
|
3805
|
+
* · 手动调用的技能(`manual: true`)→ `名字*`(manualStar 上标)
|
|
3806
|
+
* · 子能力(`sub: true`,模型读了某技能的子能力文档)→ `名字+`(subStar 上标)
|
|
3807
|
+
* 同名聚合时"只要其中有一次是手动就算手动"、"只要有一次是子能力就算子能力" ——
|
|
3808
|
+
* 聚合规则沿用现状,不做分组、不按来源拆行。
|
|
3045
3809
|
* 全局提示词(AGENTS.md 这类)**固定排第一**:它也是注入的一种,但不是技能调用,
|
|
3046
|
-
*
|
|
3810
|
+
* 不参与上面那套技能排序;其余技能照常排(子能力与普通技能混在同一个列表里)。
|
|
3047
3811
|
*/
|
|
3048
3812
|
function skillList(skills, globalInstruction, t) {
|
|
3049
3813
|
const title = t("tipCountsSkills");
|
|
3050
3814
|
const names = [];
|
|
3051
3815
|
const counts = new Map();
|
|
3052
3816
|
const manual = new Set();
|
|
3817
|
+
/* 子能力是**新增的可选字段**:老宿主 / 旧 state 里根本没有 `sub`,
|
|
3818
|
+
这里一律按 false 处理(不是"未知"),旧数据因此渲染得与改动前一模一样。 */
|
|
3819
|
+
const subs = new Set();
|
|
3053
3820
|
const pinned = typeof globalInstruction === "string" && globalInstruction.length > 0 ? globalInstruction : null;
|
|
3054
|
-
const add = (name, isManual) => {
|
|
3821
|
+
const add = (name, isManual, isSub) => {
|
|
3055
3822
|
const key = String(name);
|
|
3056
3823
|
if (key.length === 0) return;
|
|
3057
3824
|
if (counts.has(key) !== true) {
|
|
@@ -3060,17 +3827,18 @@ window.__ModuleLoader__.load({
|
|
|
3060
3827
|
}
|
|
3061
3828
|
counts.set(key, counts.get(key) + 1);
|
|
3062
3829
|
if (isManual === true) manual.add(key);
|
|
3830
|
+
if (isSub === true) subs.add(key);
|
|
3063
3831
|
};
|
|
3064
|
-
if (pinned !== null) add(pinned, false);
|
|
3832
|
+
if (pinned !== null) add(pinned, false, false);
|
|
3065
3833
|
if (Array.isArray(skills)) {
|
|
3066
3834
|
skills.forEach((entry) => {
|
|
3067
|
-
/* 契约是 `{ name, manual }`;旧宿主(还没升级的那一半)只给字符串,兼容一下 */
|
|
3835
|
+
/* 契约是 `{ name, manual, sub? }`;旧宿主(还没升级的那一半)只给字符串,兼容一下 */
|
|
3068
3836
|
if (typeof entry === "string") {
|
|
3069
|
-
add(entry, false);
|
|
3837
|
+
add(entry, false, false);
|
|
3070
3838
|
return;
|
|
3071
3839
|
}
|
|
3072
3840
|
if (entry === null || entry === undefined || typeof entry.name !== "string") return;
|
|
3073
|
-
add(entry.name, entry.manual === true);
|
|
3841
|
+
add(entry.name, entry.manual === true, entry.sub === true);
|
|
3074
3842
|
});
|
|
3075
3843
|
}
|
|
3076
3844
|
if (names.length === 0) return emptyList(title);
|
|
@@ -3079,12 +3847,28 @@ window.__ModuleLoader__.load({
|
|
|
3079
3847
|
|| counts.get(b) - counts.get(a)
|
|
3080
3848
|
|| (a < b ? -1 : (a > b ? 1 : 0)));
|
|
3081
3849
|
const ordered = pinned !== null && counts.has(pinned) ? [pinned].concat(rest) : rest;
|
|
3082
|
-
/*
|
|
3850
|
+
/* 行上只有名字 + 两种角标,不带 value —— 次数只用于上面的排序。
|
|
3851
|
+
角标**都挂在名字后面**(用户拍板,实机看完改回来的):手动 `*` → `名字*`,
|
|
3852
|
+
子能力 `+` → `名字+`。以前试过"手动星号前置、子能力后置"靠前后位置区分,
|
|
3853
|
+
但用户指出**子能力不可能由他手动调用**,两者不会落在同一个名字上,不需要位置区分;
|
|
3854
|
+
而"星号在后"读起来更像脚注标注,也与压缩列表保持同一种写法。
|
|
3855
|
+
(代码仍兼容两者同时出现的情况:那时渲染成 `名字*+`,谁在前谁在后都不影响识别。) */
|
|
3083
3856
|
return {
|
|
3084
3857
|
title: title,
|
|
3085
|
-
rows: ordered.map((name) =>
|
|
3086
|
-
|
|
3087
|
-
|
|
3858
|
+
rows: ordered.map((name) => {
|
|
3859
|
+
const isManual = manual.has(name) === true;
|
|
3860
|
+
const isSub = subs.has(name) === true;
|
|
3861
|
+
if (isManual !== true && isSub !== true) return { label: name };
|
|
3862
|
+
const label = [name];
|
|
3863
|
+
if (isManual === true) label.push(manualStar(t));
|
|
3864
|
+
if (isSub === true) label.push(subStar(t));
|
|
3865
|
+
return { label: label };
|
|
3866
|
+
}),
|
|
3867
|
+
/* **支持逐档加行**(用户原话:「技能注入也和工具调用同理呀,如果超过每列 3 个
|
|
3868
|
+
依然排不下,就是加每列的个数啊,而不是直接可滚动啊」)。
|
|
3869
|
+
注意这里是**能力**标记,与 `tight`(视觉:把名字与数值的间隙收到 1ch)无关 ——
|
|
3870
|
+
技能名很长,套 tight 会把长名字挤在一起,所以两者必须分开。 */
|
|
3871
|
+
growable: true
|
|
3088
3872
|
};
|
|
3089
3873
|
}
|
|
3090
3874
|
|
|
@@ -3109,14 +3893,16 @@ window.__ModuleLoader__.load({
|
|
|
3109
3893
|
*/
|
|
3110
3894
|
function toolList(counts, t) {
|
|
3111
3895
|
const title = t("tipCountsToolCalls");
|
|
3112
|
-
|
|
3896
|
+
/* `growable: true` 见 skillList 末尾的说明:能力标记,表示这个子视图支持逐档加行。
|
|
3897
|
+
工具调用是最早支持的那个(也是唯一需要 tight 视觉收紧的:原生工具名都是短英文)。 */
|
|
3898
|
+
if (counts === null || typeof counts !== "object") return Object.assign(emptyList(title), { tight: true, growable: true });
|
|
3113
3899
|
const entries = Object.keys(counts)
|
|
3114
3900
|
.map((name) => ({ label: name, count: Number(counts[name]) || 0 }))
|
|
3115
3901
|
.filter((one) => one.count > 0)
|
|
3116
3902
|
.sort((a, b) => a.label.length - b.label.length
|
|
3117
3903
|
|| b.count - a.count
|
|
3118
3904
|
|| (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)));
|
|
3119
|
-
if (entries.length === 0) return Object.assign(emptyList(title), { tight: true });
|
|
3905
|
+
if (entries.length === 0) return Object.assign(emptyList(title), { tight: true, growable: true });
|
|
3120
3906
|
const total = entries.reduce((sum, one) => sum + one.count, 0);
|
|
3121
3907
|
const rows = entries.map((one) => ({ label: one.label, value: formatTokensExact(one.count) + t("unitCountCalls") }));
|
|
3122
3908
|
const segments = entries.map((one, index) => {
|
|
@@ -3129,7 +3915,7 @@ window.__ModuleLoader__.load({
|
|
|
3129
3915
|
percentText: percent.toFixed(2) + "%"
|
|
3130
3916
|
};
|
|
3131
3917
|
});
|
|
3132
|
-
return { title: title, rows: rows, tight: true, segments: segments };
|
|
3918
|
+
return { title: title, rows: rows, tight: true, segments: segments, growable: true };
|
|
3133
3919
|
}
|
|
3134
3920
|
|
|
3135
3921
|
/**
|
|
@@ -3139,8 +3925,12 @@ window.__ModuleLoader__.load({
|
|
|
3139
3925
|
* turn 为 null(手动 /compact 的轮间压缩)→ 用 afterTurn 显示「第X轮后」,
|
|
3140
3926
|
* 否则手动压缩只剩一个横杠,用户看不出它发生在哪儿(2026-10 实测反馈);
|
|
3141
3927
|
* afterTurn 也没有 → 「-」。
|
|
3142
|
-
*
|
|
3143
|
-
*
|
|
3928
|
+
*
|
|
3929
|
+
* 手动压缩(`manual: true`)在**名字后面**跟上标星号(「第二次压缩*」),与技能列表
|
|
3930
|
+
* 里「手动调用的技能」保持同一种写法(用户拍板:两种列表的星号都挂在名字后)。
|
|
3931
|
+
* 星号挂在**整个名字**之后、不进序号内部 —— 序号本身仍是完整的「第二次」,
|
|
3932
|
+
* 不会被拆成「第二*次」。以前一度挪到名字前(「*第二次压缩」),实机看完后用户
|
|
3933
|
+
* 要求改回行尾:星号在后读起来更像脚注标注,与技能列表也一致。
|
|
3144
3934
|
*/
|
|
3145
3935
|
function compactList(compacts, t) {
|
|
3146
3936
|
const title = t("tipCountsCompactAt");
|
|
@@ -3156,13 +3946,17 @@ window.__ModuleLoader__.load({
|
|
|
3156
3946
|
const afterTurn = typeof mark.afterTurn === "number" ? mark.afterTurn : null;
|
|
3157
3947
|
const label = t("tipCompactItem", { index: text });
|
|
3158
3948
|
return {
|
|
3949
|
+
/* 星号在名字**后面**(与 skillList 一致):手动压缩是「第二次压缩*」 */
|
|
3159
3950
|
label: mark.manual === true ? [label, manualStar(t)] : label,
|
|
3160
3951
|
value: turn !== null
|
|
3161
3952
|
? (step === null ? t("tipCompactTurnOnly", { turn: turn }) : t("tipCompactWhere", { turn: turn, step: step }))
|
|
3162
3953
|
: (afterTurn === null ? "-" : t("tipCompactAfter", { turn: afterTurn }))
|
|
3163
3954
|
};
|
|
3164
3955
|
});
|
|
3165
|
-
|
|
3956
|
+
/* 与技能注入、工具调用同一套:**支持逐档加行**,不是直接横向滚动
|
|
3957
|
+
(用户原话:「上下文压缩也同理啊」)。
|
|
3958
|
+
同样只是能力标记 —— 压缩条目的名字(「第二次压缩*」)也不短,不能套 tight。 */
|
|
3959
|
+
return { title: title, rows: rows, growable: true };
|
|
3166
3960
|
}
|
|
3167
3961
|
|
|
3168
3962
|
/** 余额接口给的金额字符串;没有这个字段时按 0.00 显示。 */
|
|
@@ -3315,7 +4109,10 @@ window.__ModuleLoader__.load({
|
|
|
3315
4109
|
refreshAt: refreshAt,
|
|
3316
4110
|
monthly: monthly,
|
|
3317
4111
|
label: itemLabel,
|
|
3318
|
-
|
|
4112
|
+
/* 显示**剩余**而不是已用(用户要求:「不要显示已用,显示剩余」)。
|
|
4113
|
+
`used` 仍然留着 —— 进度条宽度与 percent 都要它,只是不出现在文字里。
|
|
4114
|
+
left 夹到 0:脏数据里 used > total 时绝不能显示负数。 */
|
|
4115
|
+
valueText: t("tipCreditsLeft", { left: formatCredits(Math.max(0, total - used)), total: formatCredits(total) }),
|
|
3319
4116
|
percent: total > 0 ? Math.max(0, Math.min(100, (used / total) * 100)) : 0
|
|
3320
4117
|
};
|
|
3321
4118
|
const last = merged.length > 0 ? merged[merged.length - 1] : null;
|
|
@@ -3350,7 +4147,10 @@ window.__ModuleLoader__.load({
|
|
|
3350
4147
|
refreshAt: one.refreshAt,
|
|
3351
4148
|
monthly: one.monthly,
|
|
3352
4149
|
label: creditsLabel(one, t),
|
|
3353
|
-
|
|
4150
|
+
/* 聚合行同样显示**剩余**(与原始包 items 一致);left 同样夹到 0,脏数据不出负数。
|
|
4151
|
+
这里复用上面算好的 `remaining`(`Math.max(0, total - used)`),
|
|
4152
|
+
保证"文字里的剩余"与"下钻标题右侧的剩余"是同一个数。 */
|
|
4153
|
+
valueText: t("tipCreditsLeft", { left: formatCredits(remaining), total: formatCredits(one.total) }),
|
|
3354
4154
|
percent: one.total > 0 ? Math.max(0, Math.min(100, (one.used / one.total) * 100)) : 0
|
|
3355
4155
|
};
|
|
3356
4156
|
}));
|
|
@@ -3392,16 +4192,17 @@ window.__ModuleLoader__.load({
|
|
|
3392
4192
|
if (n <= 0) return null;
|
|
3393
4193
|
return String(Math.round(n / 100) / 10);
|
|
3394
4194
|
}
|
|
3395
|
-
/** 时长按最小单位给:>=1h 用 h/m
|
|
3396
|
-
|
|
4195
|
+
/** 时长按最小单位给:>=1h 用 h/m(withSeconds 时再带秒),>=1m 用 m/s,不足一分钟只给整数秒;
|
|
4196
|
+
分钟与秒之间不加空格。面板要精确到秒传 true,底栏那条省秒(宽度有限)不传。 */
|
|
4197
|
+
function formatDuration(ms, withSeconds) {
|
|
3397
4198
|
const n = Number(ms) || 0;
|
|
3398
4199
|
if (n <= 0) return null;
|
|
3399
|
-
const
|
|
3400
|
-
const whole = Math.round(seconds);
|
|
4200
|
+
const whole = Math.round(n / 1000);
|
|
3401
4201
|
if (whole < 60) return whole + "s";
|
|
3402
4202
|
const minutes = Math.floor(whole / 60);
|
|
3403
4203
|
if (minutes < 60) return minutes + "m" + (whole % 60) + "s";
|
|
3404
|
-
|
|
4204
|
+
const head = Math.floor(minutes / 60) + "h" + (minutes % 60) + "m";
|
|
4205
|
+
return withSeconds === true ? head + (whole % 60) + "s" : head;
|
|
3405
4206
|
}
|
|
3406
4207
|
function formatThroughput(tokensPerSecond) {
|
|
3407
4208
|
return String(Math.round(Number(tokensPerSecond) || 0));
|
|
@@ -3865,24 +4666,62 @@ window.__ModuleLoader__.load({
|
|
|
3865
4666
|
};
|
|
3866
4667
|
}
|
|
3867
4668
|
/* 分隔线换成色条,两种情况各一套:
|
|
3868
|
-
金额余额(DeepSeek 等)= 充值 :
|
|
3869
|
-
百分比配额(GLM 等)=
|
|
4669
|
+
金额余额(DeepSeek 等)= 充值 : 赠金,按金额占比分两段(灰 / 紫);
|
|
4670
|
+
百分比配额(GLM 等)= 剩余(灰)在左、已用(透明)在右。
|
|
4671
|
+
这条色条的方向是「灰 = 剩余、在左」,与积分套餐条(creditsView)同一套语义,
|
|
4672
|
+
改一处必须同步另一处。
|
|
3870
4673
|
余额为负或拿不到数字时算不出占比,就不给 segments,面板退回普通分隔线。 */
|
|
3871
4674
|
const isQuota = balance.isPercent === true;
|
|
3872
|
-
|
|
3873
|
-
|
|
4675
|
+
/* 色条的「充值 : 赠金」两段。**分币种时先折汇率再相加**(用户原话:
|
|
4676
|
+
「色调的比例就两者混合,不要拆分出来 4 个色条」「金额两者相加」,且明确
|
|
4677
|
+
选了"折汇率后再加"而不是把 13.14 和 5.21 直接相加 —— 后者是拿两种不同的
|
|
4678
|
+
计价单位相加,数字没有意义)。
|
|
4679
|
+
折算目标币种用 `symbolCurrency`(= `normalizeCurrency(src.currency)`):
|
|
4680
|
+
与底栏「总计 / 本轮」是同一个数(`segmentView` 里所有金额都折到它);
|
|
4681
|
+
内部拿不到时 `convertCurrency` 自己会原样返回,不会抛。
|
|
4682
|
+
**仍然是两段**(灰 = 充值总额、紫 = 赠金总额),不按币种拆成四段。
|
|
4683
|
+
拿不到 `toppedUp` / `granted`(老 host 或 task-66 之前)⇒ 完全退回
|
|
4684
|
+
`balance.toppedUp` / `balance.granted` 那条老逻辑,行为零变化。 */
|
|
4685
|
+
const balanceTopUpRows = (() => {
|
|
4686
|
+
if (Array.isArray(balance.balances) !== true || balance.balances.length === 0) return null;
|
|
4687
|
+
let topSum = 0;
|
|
4688
|
+
let grantSum = 0;
|
|
4689
|
+
let sawAny = false;
|
|
4690
|
+
balance.balances.forEach((one) => {
|
|
4691
|
+
if (one === null || one === undefined || typeof one !== "object") return;
|
|
4692
|
+
if (one.toppedUp === undefined && one.granted === undefined) return;
|
|
4693
|
+
sawAny = true;
|
|
4694
|
+
const code = one.currency === "USD" ? "USD" : "CNY";
|
|
4695
|
+
topSum += convertCurrency(Number(one.toppedUp) || 0, code, symbolCurrency);
|
|
4696
|
+
grantSum += convertCurrency(Number(one.granted) || 0, code, symbolCurrency);
|
|
4697
|
+
});
|
|
4698
|
+
/* 一个币种都没带这两个字段 ⇒ 当作"拿不到",回退老口径 */
|
|
4699
|
+
if (sawAny !== true) return null;
|
|
4700
|
+
return { topUp: topSum, granted: grantSum };
|
|
4701
|
+
})();
|
|
4702
|
+
const topUp = balanceTopUpRows === null ? (Number(balance.toppedUp) || 0) : balanceTopUpRows.topUp;
|
|
4703
|
+
const granted = balanceTopUpRows === null ? (Number(balance.granted) || 0) : balanceTopUpRows.granted;
|
|
3874
4704
|
const balanceSum = Math.max(0, topUp) + Math.max(0, granted);
|
|
4705
|
+
/* ⚠ 百分比配额(智谱 Coding Plan 等)的 `total` 是**已用**百分比,不是余额。
|
|
4706
|
+
上游解析器(lib/balance-api.js 的 deepseek 适配器 parse 里的 isPercent 分支)把接口给的 `percentage` 原样当 `total` 返回 ——
|
|
4707
|
+
那个字段语义就是"已用",所以这里必须自己减一次得到"剩余"。
|
|
4708
|
+
**底栏那行文字与气泡这行必须同源**:两处都用 `quotaLeft`,否则就会出现
|
|
4709
|
+
「底栏 余额 62.50%、气泡 剩余配额 37.50%」这种同屏打架(用户 2026-10 实机报的就是它)。
|
|
4710
|
+
改这里之前先看一眼 segmentView 末尾那两行 f_balance / f_balanceSubCent 的 return 取值。 */
|
|
3875
4711
|
const quotaUsed = isQuota ? Math.max(0, Math.min(100, Number(String(balance.total).replace("%", "")) || 0)) : 0;
|
|
3876
4712
|
const quotaLeft = isQuota ? Math.max(0, 100 - quotaUsed) : 0;
|
|
3877
|
-
/* 百分比配额(GLM
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
4713
|
+
/* 百分比配额(GLM 等):**剩余(灰)占左段、已用(透明)在右**。
|
|
4714
|
+
用户拍板原话:「灰条从左边起」—— 灰条长度 = 剩余额度,越长剩得越多,
|
|
4715
|
+
从左读起一眼就知道"还剩多少",不用先扫过一段空的已用区。
|
|
4716
|
+
为什么用灰而不是蓝:灰是这套面板里"账户里的钱"的固定色(充值段、余额配额条都用它),
|
|
4717
|
+
蓝色留给"用量/命中"这类过程指标,语义不再混。
|
|
4718
|
+
为什么仍要两段而不是单段 + 右侧留空:气泡是 width:max-content,条又是 flex 容器,
|
|
4719
|
+
单个百分比段在"容器宽度由谁决定"上循环,实际渲染比例会偏;
|
|
4720
|
+
两段显式百分比之和为 100%,宽度由浏览器按比例切,不会漂。
|
|
3882
4721
|
行只列「剩余配额」一行并带同款灰图例;已用那边不列行。 */
|
|
3883
4722
|
const balanceSegments = isQuota
|
|
3884
4723
|
? [
|
|
3885
|
-
{ color: BALANCE_COLORS.
|
|
4724
|
+
{ color: BALANCE_COLORS.topUp, width: quotaLeft },
|
|
3886
4725
|
{ color: "transparent", width: quotaUsed }
|
|
3887
4726
|
].filter((one) => one.width > 0)
|
|
3888
4727
|
: balanceSum > 0
|
|
@@ -3891,26 +4730,112 @@ window.__ModuleLoader__.load({
|
|
|
3891
4730
|
{ color: BALANCE_COLORS.granted, width: (Math.max(0, granted) / balanceSum) * 100 }
|
|
3892
4731
|
].filter((one) => one.width > 0)
|
|
3893
4732
|
: [];
|
|
4733
|
+
/* 气泡里仍然是那两行:**「充值余额」/「赠金余额」**,label 一个字不改。
|
|
4734
|
+
用户这一轮要的是「值里体现两种币种的构成」,**不是**把行改成 CNY / USD
|
|
4735
|
+
(原话:「你改了我的项,原本气泡里面只显示充值余额和赠金余额,
|
|
4736
|
+
我没有让你把它改成 CNY 和 USD」—— 上一版按币种分行是错的,已回退)。
|
|
4737
|
+
|
|
4738
|
+
所以:
|
|
4739
|
+
· 充值余额那行的值 = 各币种 `toppedUp` 拼起来的串(`¥13.14+$5.21`)
|
|
4740
|
+
· 赠金余额那行的值 = 各币种 `granted` 拼起来的串(`¥2.50+$1.00`)
|
|
4741
|
+
拼串复用 `costByCurrencyText`:它已经实现了「人民币在前、美元在后、
|
|
4742
|
+
某项 ≤0 不出现(含不足 0.01)、只有一项不带 +、全 0 给 ¥0.00」这一整套规则,
|
|
4743
|
+
与底栏那行文字、活跃总览用的是同一套 —— 三处口径必须一致。
|
|
4744
|
+
|
|
4745
|
+
兜底:`balances` 空、或每笔都没有 `toppedUp`/`granted`(老宿主)⇒
|
|
4746
|
+
完全退回改动前的 `balSymbol + balanceAmount(...)`,行为零变化。 */
|
|
4747
|
+
const currencyMapsOf = (() => {
|
|
4748
|
+
if (Array.isArray(balance.balances) !== true || balance.balances.length === 0) return null;
|
|
4749
|
+
const topUpMap = {};
|
|
4750
|
+
const grantedMap = {};
|
|
4751
|
+
let sawAny = false;
|
|
4752
|
+
balance.balances.forEach((one) => {
|
|
4753
|
+
if (one === null || one === undefined || typeof one !== "object") return;
|
|
4754
|
+
if (one.toppedUp === undefined && one.granted === undefined) return;
|
|
4755
|
+
sawAny = true;
|
|
4756
|
+
const code = one.currency === "USD" ? "USD" : "CNY";
|
|
4757
|
+
/* 同币种多笔防御性累加(宿主一般已合并,这里再收一遍不会错) */
|
|
4758
|
+
const topAmount = Number(one.toppedUp);
|
|
4759
|
+
if (Number.isFinite(topAmount) && topAmount > 0) topUpMap[code] = (topUpMap[code] || 0) + topAmount;
|
|
4760
|
+
const grantAmount = Number(one.granted);
|
|
4761
|
+
if (Number.isFinite(grantAmount) && grantAmount > 0) grantedMap[code] = (grantedMap[code] || 0) + grantAmount;
|
|
4762
|
+
});
|
|
4763
|
+
/* 一笔都没带这两个字段 ⇒ 当作"拿不到",走下面的老逻辑 */
|
|
4764
|
+
if (sawAny !== true) return null;
|
|
4765
|
+
return { topUp: topUpMap, granted: grantedMap };
|
|
4766
|
+
})();
|
|
3894
4767
|
const balanceRows = isQuota
|
|
3895
|
-
? [{ label: t("tipQuotaLeft"), value: quotaLeft.toFixed(2) + "%", color: BALANCE_COLORS.
|
|
3896
|
-
:
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
4768
|
+
? [{ label: t("tipQuotaLeft"), value: quotaLeft.toFixed(2) + "%", color: BALANCE_COLORS.topUp }]
|
|
4769
|
+
: (currencyMapsOf !== null
|
|
4770
|
+
? [
|
|
4771
|
+
/* 两个 map 都为空 ⇒ costByCurrencyText 给 ¥0.00(那一行必须有内容,不能空串) */
|
|
4772
|
+
{ label: t("tipTopUp"), value: costByCurrencyText(currencyMapsOf.topUp, 0, t), color: BALANCE_COLORS.topUp },
|
|
4773
|
+
{ label: t("tipGranted"), value: costByCurrencyText(currencyMapsOf.granted, 0, t), color: BALANCE_COLORS.granted }
|
|
4774
|
+
]
|
|
4775
|
+
: [
|
|
4776
|
+
{ label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp), color: BALANCE_COLORS.topUp },
|
|
4777
|
+
{ label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted), color: BALANCE_COLORS.granted }
|
|
4778
|
+
]);
|
|
4779
|
+
/* 余额气泡:配额口径一行;金额口径**固定两行**(充值余额 / 赠金余额,
|
|
4780
|
+
值与是否分币种无关 —— 分币种只影响那两行的**值怎么写**,不影响行数)。
|
|
4781
|
+
**不再显示「累计消费」**:用户明确要求「所有的都不显示累计消费」。
|
|
4782
|
+
宿主侧 `spent` 字段照旧返回(智谱适配器会给),但客户端一律不画 ——
|
|
4783
|
+
这一行口径与「总计」不同源、又只在部分平台出现,
|
|
4784
|
+
摆在同一张气泡里容易被当成同一个数字。 */
|
|
3900
4785
|
const tip = {
|
|
3901
4786
|
kind: "rows",
|
|
3902
4787
|
title: t("segBalance"),
|
|
3903
4788
|
segments: balanceSegments,
|
|
3904
|
-
rows: balanceRows
|
|
3905
|
-
/* 累计消费只有部分平台能给(智谱有,DeepSeek 的接口没有),没有就不显示这一行 */
|
|
3906
|
-
balance.spent === undefined || balance.spent === null
|
|
3907
|
-
? null
|
|
3908
|
-
: { label: t("tipSpent"), value: balSymbol + balanceAmount(balance.spent) }
|
|
3909
|
-
].filter((one) => one !== null))
|
|
4789
|
+
rows: balanceRows
|
|
3910
4790
|
};
|
|
3911
4791
|
/* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
|
|
3912
4792
|
if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
|
|
3913
|
-
|
|
4793
|
+
/* 底栏那行文字:**百分比配额走 `quotaLeft`(剩余),不走 `balance.total`(已用)**。
|
|
4794
|
+
与上面气泡那行同一个数(都是 quotaLeft),所以「余额 37.50%」点进去就是
|
|
4795
|
+
「剩余配额 37.50%」,不会同屏打架。
|
|
4796
|
+
`balSymbol` 在 isPercent 时已经是空串(见 segmentView 里 const balSymbol 那行),所以不会出现 "¥37.50%"。
|
|
4797
|
+
其余口径(金额 / 积分)照旧用 `balance.total` —— 那两种的 total 本来就是余额本身。 */
|
|
4798
|
+
|
|
4799
|
+
/* **双币种余额**(用户要求:「有一些 API,它是人民币和美元两个余额共存的,
|
|
4800
|
+
应该都能查到,也就是显示成 ¥14.12+$3.17 这种格式」)。
|
|
4801
|
+
仅指底栏那行文字 —— 气泡里那两行充值/赠金**不动**(用户明确说「不是指点击气泡里」)。
|
|
4802
|
+
|
|
4803
|
+
数据来源:host 的 `balance.balances`(`[{currency, total}, …]`,只含 > 0 的币种)。
|
|
4804
|
+
格式**复用活跃总览那套** `costByCurrencyText`:人民币在前、美元在后,
|
|
4805
|
+
某项 ≤ 0 不出现、只有一项不带 `+`、两项都没有就给 ¥0.00 —— 两处口径必须一致,
|
|
4806
|
+
否则同一个 `¥14.12+$3.17` 的写法在两个地方会长得不一样。
|
|
4807
|
+
把数组转成它要的 `{CNY: n, USD: n}`:同币种多笔就累加(host 一般已合并,
|
|
4808
|
+
这里再加一遍是防重复项)。
|
|
4809
|
+
|
|
4810
|
+
**两个易错点**:
|
|
4811
|
+
① `fallbackCost` 必须传 **0**、不能传 `balance.total` —— 一旦进了双币种分支,
|
|
4812
|
+
就说明 host 给了分币种数据,不该再拿那个混合 total 兜底;
|
|
4813
|
+
② 拼好的串**自带 ¥/$ 符号**,所以下面 `balSymbol` 要传空串,
|
|
4814
|
+
否则会出现 `¥¥14.12+$3.17`。
|
|
4815
|
+
老 host / 老数据没有 `balances` ⇒ 完全走原来的单币种逻辑,行为零变化。 */
|
|
4816
|
+
const balances = balance.balances;
|
|
4817
|
+
const hasBalances = Array.isArray(balances) && balances.length > 0;
|
|
4818
|
+
let balAmountText = null;
|
|
4819
|
+
let balTextSymbol = balSymbol;
|
|
4820
|
+
if (isQuota !== true && hasBalances === true) {
|
|
4821
|
+
const map = {};
|
|
4822
|
+
for (const one of balances) {
|
|
4823
|
+
if (one === null || one === undefined || typeof one !== "object") continue;
|
|
4824
|
+
const amount = Number(one.total);
|
|
4825
|
+
if (!Number.isFinite(amount) || amount <= 0) continue;
|
|
4826
|
+
const code = one.currency === "USD" ? "USD" : "CNY";
|
|
4827
|
+
map[code] = (map[code] || 0) + amount;
|
|
4828
|
+
}
|
|
4829
|
+
if (Object.keys(map).length > 0) {
|
|
4830
|
+
balAmountText = costByCurrencyText(map, 0, t);
|
|
4831
|
+
/* 串里已经有符号了,外层不能再补一个 */
|
|
4832
|
+
balTextSymbol = "";
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
if (balAmountText === null) {
|
|
4836
|
+
balAmountText = isQuota === true ? quotaLeft.toFixed(2) + "%" : balance.total;
|
|
4837
|
+
}
|
|
4838
|
+
return { id: id, text: t("f_balance", { symbol: balTextSymbol, amount: balAmountText }), tip: tip };
|
|
3914
4839
|
}
|
|
3915
4840
|
|
|
3916
4841
|
return null;
|
|
@@ -3942,6 +4867,37 @@ window.__ModuleLoader__.load({
|
|
|
3942
4867
|
if (id === "balance") return t("f_balanceDash", { symbol: symbol });
|
|
3943
4868
|
return dash;
|
|
3944
4869
|
}
|
|
4870
|
+
|
|
4871
|
+
/**
|
|
4872
|
+
* 缺数据的段也要有一个气泡(**P0:不加就会把用户锁死**)。
|
|
4873
|
+
*
|
|
4874
|
+
* `segmentView` 返回 null 时,渲染处原本只拿到 `{ id, text }`、没有 tip,
|
|
4875
|
+
* 而那段 JSX 是「tip !== null 才挂 onClick」—— 于是这一格变成死块:
|
|
4876
|
+
* 点不开 → 推进场景的代码(在 toggleTip 里)永远不执行 → 用户停在这个空态出不来。
|
|
4877
|
+
* 开发模式下尤其致命:底栏整排横杠、假数据还在,用户既退不出去也翻不到下一格。
|
|
4878
|
+
*
|
|
4879
|
+
* 所以气泡一定要有,内容则**刻意极简**(一行解释 + 一行横杠):
|
|
4880
|
+
* · 这一段的真实内容是"没有数据",编不出有意义的明细;
|
|
4881
|
+
* · 但用户点它本来也不是为了看内容,而是为了推进到下一格(开发模式)
|
|
4882
|
+
* 或至少确认"这里确实没数据"(正式模式)。
|
|
4883
|
+
* 用 `kind: "rows"` 是为了复用现成的两列面板外壳,不再造一种新面板类型。
|
|
4884
|
+
*/
|
|
4885
|
+
function emptySegmentTip(id, view, t, currency) {
|
|
4886
|
+
const segment = SEGMENTS.filter((s) => s.id === id)[0];
|
|
4887
|
+
const locale = detectLocale(t);
|
|
4888
|
+
const label = segment === undefined || segment.short === undefined
|
|
4889
|
+
? t("segBalance")
|
|
4890
|
+
: (segment.short[locale] !== undefined ? segment.short[locale] : segment.short.zh);
|
|
4891
|
+
return {
|
|
4892
|
+
kind: "rows",
|
|
4893
|
+
title: t("tipEmptyTitle", { label: label }),
|
|
4894
|
+
rows: [
|
|
4895
|
+
/* 值就用该段自己的横杠占位文案,保证气泡与底栏读的是同一句话 */
|
|
4896
|
+
{ label: t("tipEmptyTitle", { label: label }), value: segmentText(id, null, t, currency) },
|
|
4897
|
+
{ label: t("tipEmptyBody"), value: "" }
|
|
4898
|
+
]
|
|
4899
|
+
};
|
|
4900
|
+
}
|
|
3945
4901
|
/* ---------------------------------------------------------------- css */
|
|
3946
4902
|
const CSS = [
|
|
3947
4903
|
/* 接管输入框下方的统计行:dock 里凡“不是本插件那一项”的都藏掉(也就是官方 StatsPills)。
|
|
@@ -3974,7 +4930,18 @@ window.__ModuleLoader__.load({
|
|
|
3974
4930
|
transform-box:view-box + origin:center 让旋转中心稳稳落在 viewBox 中心 (7,7)。 */
|
|
3975
4931
|
"@keyframes dsb-ring-spin{from{transform:rotate(-90deg)}to{transform:rotate(270deg)}}",
|
|
3976
4932
|
".dsb-ring-running .dsb-ring-fill{stroke:#22c55e;transform-box:view-box;transform-origin:center;animation:dsb-ring-spin 1.4s linear infinite}",
|
|
3977
|
-
|
|
4933
|
+
/* 蓝色 / 紫色两档(Agent Teams 场景,用户新需求):
|
|
4934
|
+
绿 = Lead 在跑、蓝 = 团队成员在跑、紫 = 只有成员下派的孙会话或团队外独立子智能体在跑。
|
|
4935
|
+
两个颜色**复用总计令牌条已有的两个色**,不新造:
|
|
4936
|
+
蓝 = var(--dsw-static-blue-450) —— 总计「输出」那段用的就是它
|
|
4937
|
+
紫 = #a78bfa —— 总计「未命中输入」那段用的就是它
|
|
4938
|
+
旋转与绿色那一档完全同参数(1.4s linear infinite,同一个 keyframes),
|
|
4939
|
+
三种颜色在观感上是同一件事,只有颜色不同 —— 用户要的就是这个。
|
|
4940
|
+
**旋转是 CSS animation,不占任何 JS 定时器**(`setInterval` 计数仍为 2)。 */
|
|
4941
|
+
".dsb-ring-team .dsb-ring-fill{stroke:var(--dsw-static-blue-450);transform-box:view-box;transform-origin:center;animation:dsb-ring-spin 1.4s linear infinite}",
|
|
4942
|
+
".dsb-ring-deep .dsb-ring-fill{stroke:#a78bfa;transform-box:view-box;transform-origin:center;animation:dsb-ring-spin 1.4s linear infinite}",
|
|
4943
|
+
/* 减弱动效偏好下三档一起停:蓝紫与绿色同待遇,不能只停一个 */
|
|
4944
|
+
"@media (prefers-reduced-motion: reduce){.dsb-ring-running .dsb-ring-fill,.dsb-ring-team .dsb-ring-fill,.dsb-ring-deep .dsb-ring-fill{animation:none}}",
|
|
3978
4945
|
".dsb-ring-error .dsb-ring-fill{stroke:#ef4444}",
|
|
3979
4946
|
".dsb-ring-approval .dsb-ring-fill{stroke:#f59e0b}",
|
|
3980
4947
|
/* 条形图恢复原设计:**段与段之间 1px 间隔(gap:1px)+ 分段 1px 圆角**。
|
|
@@ -4005,7 +4972,11 @@ window.__ModuleLoader__.load({
|
|
|
4005
4972
|
".dsb-bar-hoverable .dsb-bar-seg:hover{transform:scaleY(1.45);z-index:2}",
|
|
4006
4973
|
/* 工具彩条的悬停提示:**不要气泡、也不要阴影**,只显示一行文字(比 10px 的悬停浮层大、
|
|
4007
4974
|
但小于 12px 的标题)。位置由 JS 给 left/top,贴在鼠标上方很近处。 */
|
|
4008
|
-
|
|
4975
|
+
/* 气泡上的工具名悬停提示。**z-index = 106,必须始终比气泡(105)高一层**:
|
|
4976
|
+
它是气泡内部的提示,被自己的容器盖住就没意义了。
|
|
4977
|
+
106 同样夹在官方档位的 101(子菜单)与 1000(对话框/遮罩)之间 ——
|
|
4978
|
+
盖得住对话区的浮动控件,又盖不住系统级弹层。详见 `.dsb-tip` 那段的说明。 */
|
|
4979
|
+
".dsb-hover-plain{position:fixed;z-index:106;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}",
|
|
4009
4980
|
/* 宽度按内容自适应:width:max-content 让气泡收到"最长那一行"的宽度,
|
|
4010
4981
|
* 行内标签与数值之间至少留 5ch(.dsb-meter-value 的 padding-left),所以不会出现大片留白;
|
|
4011
4982
|
* 内容超过 max-width 时退回换行(white-space:normal)。 */
|
|
@@ -4034,8 +5005,14 @@ window.__ModuleLoader__.load({
|
|
|
4034
5005
|
".dsb-meter-value{margin-left:auto;padding-left:5ch;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
|
|
4035
5006
|
/* 手动调用 / 手动压缩的星号:做成上标(像 X² 的 ²),不是普通的星号字符。
|
|
4036
5007
|
.7em 收小、vertical-align:super 抬到基线以上;line-height:0 防止把行盒撑高
|
|
4037
|
-
(行高 18px 是按字体度量算好的,多 1px 同排的行就会错位)。
|
|
5008
|
+
(行高 18px 是按字体度量算好的,多 1px 同排的行就会错位)。
|
|
5009
|
+
星号挂在名字**后面**("ponytail*"):用户拍板 —— 子能力不可能由他手动调用,
|
|
5010
|
+
所以两种角标不会落在同一个名字上,不需要靠前后位置去区分。 */
|
|
4038
5011
|
".dsb-sup{font-size:.7em;line-height:0;vertical-align:super;margin-left:1px}",
|
|
5012
|
+
/* 子能力的 `+` 角标单独加粗:`+` 在这个字号下笔画比 `*` 细一档,不加粗会显得"没画完"。
|
|
5013
|
+
**只改字重**,字号与行盒都不动(前者会改变两种角标的体量差,后者会撑高行盒)——
|
|
5014
|
+
与 .dsb-sup 同一套抬升量,同一列表里两行才一样高。 */
|
|
5015
|
+
".dsb-sup-strong{font-weight:600}",
|
|
4039
5016
|
/* 积分套餐列表:同一屏最多显示 5 个套餐块,超出就往下滑。高度按单块 37px 推算:
|
|
4040
5017
|
.dsb-credit 上下 padding 2+1=3px + .dsb-meter-row(line-height 18 + padding 1+3=4 → 22px)
|
|
4041
5018
|
+ .dsb-bar(height 4 + margin 6+2=8 → 12px)= 37px;5 块 = 185px。
|
|
@@ -4072,6 +5049,29 @@ window.__ModuleLoader__.load({
|
|
|
4072
5049
|
滚动条隐藏但保留滚动:沿用 .dsb-credits-list 那套写法(见下方 ::-webkit-scrollbar)。 */
|
|
4073
5050
|
".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}",
|
|
4074
5051
|
".dsb-meter-cols::-webkit-scrollbar{display:none}",
|
|
5052
|
+
/* 右缘淡出(用户批准):还有内容在右边时,最右 COLS_FADE_PX(40px)从实到透明,
|
|
5053
|
+
暗示"往右还有"。**只是视觉遮罩**:mask-image 不参与排版,宽度/列宽/滚动范围
|
|
5054
|
+
一个像素都不变。
|
|
5055
|
+
**宽度固定 40px**(`--dsb-fade-r` 由 applyColsFade 写成 `40px` 或 `0px`),
|
|
5056
|
+
不随溢出量收窄 —— 用户拍板原话:「遮罩的宽度是固定的呀,溢出两个像素,就抹两个
|
|
5057
|
+
像素,那样好看吗?」宽度随内容跳变会忽宽忽窄、2px 那档更不成形;
|
|
5058
|
+
固定 40px + 只在"挂不挂"上二值切换,观感才稳定。常量与 JS 里的 COLS_FADE_PX
|
|
5059
|
+
是同一个数,改一处必须同步。
|
|
5060
|
+
这个类由 applyColsFade 按几何切换(没溢出、或已经滚到最右时摘掉),且**在逐档加行
|
|
5061
|
+
之后会由 refreshColsFade 重算一次** —— 所以"加完行已经不用滚了"时遮罩会立刻消失。
|
|
5062
|
+
更根本的一条判据:**还能继续加行的容器(`data-cols-grow="1"`)一律不挂遮罩** ——
|
|
5063
|
+
加行机制保证未到 12 行前程序会继续加行,用户不会真的需要滚;只有加到 12 行仍溢出
|
|
5064
|
+
才显示(用户原话:「只有超过每列 12 的情况才应该有这个淡出遮罩」)。
|
|
5065
|
+
**为什么保留 mask 而不用绝对定位覆盖层**:用户反馈的闪烁根因是"每个 scroll 事件
|
|
5066
|
+
都 toggle class ⇒ mask 每帧重新栅格化",这一点已在 applyColsFade 里治掉
|
|
5067
|
+
(只在"挂不挂"真的变化时才碰 class ⇒ 一次滚动过程中 mask 完全不变,只在一个
|
|
5068
|
+
边界处变一次)。mask 的另一个好处是**零布局改动**:覆盖层方案得给滚动容器加一层
|
|
5069
|
+
`position:relative` 的包裹元素,而气泡宽度是 `width:max-content` ——
|
|
5070
|
+
多一层包裹会改变"宽度由谁决定"的链路,容易让超宽气泡的宽度算错(那正是这套
|
|
5071
|
+
自适应宽度反复踩过的坑)。既然闪烁已从根上解决,就不引入布局风险。
|
|
5072
|
+
`-webkit-mask-image` 与标准写法并列:DSH 桌面端是 Chromium,新版本支持标准属性,
|
|
5073
|
+
但旧内核只认带前缀那个,两个都写才不是"在这台机器上不生效"。 */
|
|
5074
|
+
".dsb-cols-fade{-webkit-mask-image:linear-gradient(90deg,transparent 0,#000 var(--dsb-fade-l,0px),#000 calc(100% - var(--dsb-fade-r,0px)),transparent 100%);mask-image:linear-gradient(90deg,transparent 0,#000 var(--dsb-fade-l,0px),#000 calc(100% - var(--dsb-fade-r,0px)),transparent 100%)}",
|
|
4075
5075
|
/* 每列**不收缩**(flex:none):容器放不下时 flex 默认会先把列压扁(flex-shrink:1),
|
|
4076
5076
|
那等于用"挤扁内容"替代折行 —— 同样是用户禁止的坏形态,而且会让"是否溢出"的
|
|
4077
5077
|
判定失真。列保持自身内容宽度,放不下就交给上面的横向滚动。 */
|
|
@@ -4161,7 +5161,16 @@ window.__ModuleLoader__.load({
|
|
|
4161
5161
|
* (rgb(from …) 是 CSS 相对颜色语法,Chromium 119+ 支持;不支持时上一行的官方原值仍然生效)。 */
|
|
4162
5162
|
/* 柔光阴影去掉:只留 .5px 描边,浮层看起来更利落 */
|
|
4163
5163
|
/* box-sizing:border-box 必须留着:内联宽度要含 padding,否则面板右边缘会比对齐点偏出 22px */
|
|
4164
|
-
|
|
5164
|
+
/* 底栏气泡。**z-index = 105,不是随手取的数**:
|
|
5165
|
+
官方前端(dsh-web-frontend)的层级档位是 1 / 2 / 6 / 10 / 70 / 100 / 101 / 1000 / 1100,
|
|
5166
|
+
其中 100 = 气泡/卡片/hovercard/下拉列表、101 = 子菜单、1000 = 对话框与遮罩、
|
|
5167
|
+
1100 = portal / toast。105 刚好夹在 101 和 1000 之间,两头都照顾到:
|
|
5168
|
+
· 高于 100/101 ⇒ 对话区那些浮动控件(比如右下角「拉到最后」那个圆形按钮)
|
|
5169
|
+
会被压在气泡下面,不再挡住气泡最后一列的内容(用户实机报的就是它);
|
|
5170
|
+
· 低于 1000/1100 ⇒ 对话框、遮罩、toast 仍然盖在气泡之上 ——
|
|
5171
|
+
那些是用户必须能点到的系统级弹层,被数据气泡挡住就是严重问题。
|
|
5172
|
+
早先这里是 40,比官方浮动控件低,所以按钮压在了气泡上。 */
|
|
5173
|
+
".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:105;pointer-events:auto}",
|
|
4165
5174
|
".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
|
|
4166
5175
|
/* 整块禁选(用户拍板):底栏、底栏气泡、设置页按住鼠标拖动会刷出蓝色选中底,
|
|
4167
5176
|
看着像出了 bug。这三处都是插件自己画的纯展示/操作界面,用户没有复制文字的需求;
|
|
@@ -4358,6 +5367,1312 @@ window.__ModuleLoader__.load({
|
|
|
4358
5367
|
const DEV_CLICKS = 5;
|
|
4359
5368
|
/** 连点计数:两次点击间隔超过这个毫秒数就重新计数(否则慢慢点 5 次也会进去)。 */
|
|
4360
5369
|
const DEV_CLICK_WINDOW_MS = 1500;
|
|
5370
|
+
|
|
5371
|
+
/* ---------------------------------------------- 开发模式:解锁记忆 */
|
|
5372
|
+
/**
|
|
5373
|
+
* 「解锁过开发模式」记在 localStorage 里,**存的是解锁时的 VERSION**。
|
|
5374
|
+
* 为什么存版本而不是一个 true:版本号升级后 VERSION 常量变了,旧记录自动失效,
|
|
5375
|
+
* 于是要求重新连点 DEV_CLICKS 次 —— 既不用额外写迁移代码,也不会让作者在升级后
|
|
5376
|
+
* 忘了手势还能一键进出。
|
|
5377
|
+
*
|
|
5378
|
+
* 这个键与配置无关(不参与 CONFIG_VERSION 迁移),因为它只影响一个隐藏手势。
|
|
5379
|
+
*/
|
|
5380
|
+
const DEV_UNLOCK_KEY = "dsh.desktopStatusBar.devUnlock";
|
|
5381
|
+
|
|
5382
|
+
/** 读「解锁时的版本」;任何异常都当没解锁(无痕模式下 localStorage 读写会抛错)。 */
|
|
5383
|
+
function devUnlockVersion() {
|
|
5384
|
+
try {
|
|
5385
|
+
const raw = window.localStorage.getItem(DEV_UNLOCK_KEY);
|
|
5386
|
+
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
5387
|
+
} catch (error) {
|
|
5388
|
+
return null;
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
/** 记住「这一版已经解锁过」。写不进去(无痕模式)就返回 false,行为退化成每次都要连点。 */
|
|
5393
|
+
function rememberDevUnlock() {
|
|
5394
|
+
try {
|
|
5395
|
+
window.localStorage.setItem(DEV_UNLOCK_KEY, VERSION);
|
|
5396
|
+
return true;
|
|
5397
|
+
} catch (error) {
|
|
5398
|
+
return false;
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
|
|
5402
|
+
/* ------------------------- 开发模式:每字段独立计数(点哪项只变哪项) */
|
|
5403
|
+
/**
|
|
5404
|
+
* 用户明确要求(第三次强调,前两次实现都没对上):
|
|
5405
|
+
* 「点其中一个底栏,为什么其他所有的底栏都变了?点一个,应该只变那一个啊」
|
|
5406
|
+
* 「我要求的是,点底栏其中一项,它自己那一项就切换情况」
|
|
5407
|
+
*
|
|
5408
|
+
* 所以**每个能开气泡的字段各有一个独立计数器**,点开 X 只让 X +1,其余一动不动。
|
|
5409
|
+
* 早先只有一个全局 `devBubbleTick`,任何气泡打开都 +1,于是点一个全变 —— 那是错的。
|
|
5410
|
+
*
|
|
5411
|
+
* 键就是底栏字段的 id(与 SEGMENTS 的 id 一致,圆环用 "context"):
|
|
5412
|
+
* status / counts / cacheHit / tps / ttft / sessionTime / lastCost / cost / balance / context
|
|
5413
|
+
*/
|
|
5414
|
+
const DEV_FIELD_KEYS = [
|
|
5415
|
+
"status", "counts", "cacheHit", "tps", "ttft",
|
|
5416
|
+
"sessionTime", "lastCost", "cost", "balance", "context"
|
|
5417
|
+
];
|
|
5418
|
+
|
|
5419
|
+
/** 一份全 0 的字段计数表(每次进入开发模式都从这里重来)。 */
|
|
5420
|
+
function devEmptyFieldScenes() {
|
|
5421
|
+
const out = {};
|
|
5422
|
+
DEV_FIELD_KEYS.forEach((key) => { out[key] = 0; });
|
|
5423
|
+
return out;
|
|
5424
|
+
}
|
|
5425
|
+
|
|
5426
|
+
/** 第几次进入开发模式(退出不清零;只做"这是第几次"的记录,不再驱动余额)。 */
|
|
5427
|
+
let devRound = 0;
|
|
5428
|
+
|
|
5429
|
+
/** 每字段独立计数器:{ status: 0, counts: 0, ..., balance: 0, context: 0 }。 */
|
|
5430
|
+
let devFieldScenes = devEmptyFieldScenes();
|
|
5431
|
+
|
|
5432
|
+
/** 给 StatusBar / 设置页订阅用的快照:必须**换对象才换引用**,否则 useSyncExternalStore 会死循环。 */
|
|
5433
|
+
let devSnapshot = { on: false, round: 0, fields: devEmptyFieldScenes() };
|
|
5434
|
+
const devListeners = new Set();
|
|
5435
|
+
function subscribeDev(fn) {
|
|
5436
|
+
devListeners.add(fn);
|
|
5437
|
+
return () => { devListeners.delete(fn); };
|
|
5438
|
+
}
|
|
5439
|
+
function snapshotDev() { return devSnapshot; }
|
|
5440
|
+
function useDevState() {
|
|
5441
|
+
return react.useSyncExternalStore(subscribeDev, snapshotDev, snapshotDev);
|
|
5442
|
+
}
|
|
5443
|
+
/**
|
|
5444
|
+
* 模块级标记一变就通知:开发模式的假数据必须立刻出现在底栏上。
|
|
5445
|
+
* 快照里的 `fields` 每次**复制一份** —— 组件按 `fields.xxx` 读到的是稳定的数字,
|
|
5446
|
+
* 而对象本身换引用才能让 useSyncExternalStore 认为"变了"(不换引用会死循环或者不刷新)。
|
|
5447
|
+
*/
|
|
5448
|
+
function publishDev() {
|
|
5449
|
+
devSnapshot = { on: devMode === true, round: devRound, fields: Object.assign({}, devFieldScenes) };
|
|
5450
|
+
devListeners.forEach((fn) => { try { fn(); } catch (error) { /* 单个订阅者失败不影响其余 */ } });
|
|
5451
|
+
}
|
|
5452
|
+
|
|
5453
|
+
/* ------------------- 开发模式:工具形态场景(5 种,只由 counts 这一个计数驱动) */
|
|
5454
|
+
/**
|
|
5455
|
+
* 工具调用气泡的**形态**(几列、名字多长):
|
|
5456
|
+
* ① 短气泡(3 个短名) ② 18 个中长名 ③ 420 个极端 ④ 24 个(2 列 × 12 行) ⑤ 长短名极端混排
|
|
5457
|
+
* 它只听 `counts` 那一个计数 —— 用户要的是"点 counts 才换 counts",点余额不该把
|
|
5458
|
+
* 工具形态也换掉(那正是前两版被否掉的行为)。
|
|
5459
|
+
*/
|
|
5460
|
+
const DEV_BUBBLE_SCENES = 5;
|
|
5461
|
+
|
|
5462
|
+
/** 某个字段当前的计数(未初始化 / 非法值都当 0)。 */
|
|
5463
|
+
function devFieldCount(key) {
|
|
5464
|
+
if (devFieldScenes === null || devFieldScenes === undefined) return 0;
|
|
5465
|
+
const value = devFieldScenes[key];
|
|
5466
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
5467
|
+
}
|
|
5468
|
+
|
|
5469
|
+
/**
|
|
5470
|
+
* 冒泡:**点开哪个字段就只推进哪个字段**(关闭不推进;开发模式外调用一律无效)。
|
|
5471
|
+
* @param {string} fieldId 字段 id(见 DEV_FIELD_KEYS)
|
|
5472
|
+
* @returns {number} 推进后的该字段计数
|
|
5473
|
+
*/
|
|
5474
|
+
function advanceDevBubbleScene(fieldId) {
|
|
5475
|
+
if (devMode !== true) return 0;
|
|
5476
|
+
if (typeof fieldId !== "string" || DEV_FIELD_KEYS.indexOf(fieldId) === -1) return 0;
|
|
5477
|
+
devFieldScenes[fieldId] = devFieldCount(fieldId) + 1;
|
|
5478
|
+
publishDev();
|
|
5479
|
+
return devFieldScenes[fieldId];
|
|
5480
|
+
}
|
|
5481
|
+
|
|
5482
|
+
/** 重置所有字段计数(进入开发模式时调用;退出时也清一遍,保证下次从第 1 组开始)。 */
|
|
5483
|
+
function resetDevFieldScenes() {
|
|
5484
|
+
devFieldScenes = devEmptyFieldScenes();
|
|
5485
|
+
}
|
|
5486
|
+
/** 余额轮数 / 工具场景轮数,决定 ((轮-1) % N) + 1 的取模。 */
|
|
5487
|
+
const DEV_BALANCE_ROUNDS = 6;
|
|
5488
|
+
const DEV_TOOL_ROUNDS = 4;
|
|
5489
|
+
|
|
5490
|
+
/* ------------------- 开发模式:上下文圆环的形态(只由 context 计数驱动) */
|
|
5491
|
+
/**
|
|
5492
|
+
* 圆环要展示 **4 种状态色 × 占用极端** 的组合,每一种都必须在图上**看得见**。
|
|
5493
|
+
*
|
|
5494
|
+
* 一条实测教训(用户原话「红色在哪里?」):最早 error 那格配的是 percent=0,
|
|
5495
|
+
* 状态类确实挂上了、`stroke:#ef4444` 也确实生效,但**描边长度是 0 像素** ——
|
|
5496
|
+
* 红色一点都画不出来。所以现在每一格都给足可见的占用;error 给两格
|
|
5497
|
+
* (65% 中等、92% 接近满),让"红 + 长描边 + 缺口"能同时出现。
|
|
5498
|
+
*
|
|
5499
|
+
* **不再演示 dashMeter(空环 + 横杠)那一格**:用户明确否决了"没数据也占一格"
|
|
5500
|
+
* —— 横杠什么信息都看不出来。兜底代码路径(meterView 返回 null ⇒ dashMeter)
|
|
5501
|
+
* 当然还在,真实环境照样会走(比如会话刚发出时),只是开发模式不主动演它。
|
|
5502
|
+
*
|
|
5503
|
+
* 格序:
|
|
5504
|
+
* 0 running 55% —— 常规态(进开发模式立刻看到的就是它)
|
|
5505
|
+
* 1 error 65% —— 红色,中等长度
|
|
5506
|
+
* 2 error 92% —— 红色,接近满环 + 缺口
|
|
5507
|
+
* 3 approval 97% —— 黄色,将满
|
|
5508
|
+
* 4 idle 55% + 缺口算不出 —— 灰色,且轨道保持整圈(capacity 缺 maxOutputTokens)
|
|
5509
|
+
* 5 running 100% —— 满环,描边整圈闭合
|
|
5510
|
+
* 6 idle 0% —— 空环(占用 0 这个极端本身,不是"没数据")
|
|
5511
|
+
* 7 teamRunning 55% —— **蓝色旋转**:只有团队成员在跑,弧长 = 成员级最大占用
|
|
5512
|
+
* 8 deepRunning 10% —— **紫色旋转**:只有更深层在跑,弧长 = 深层最小占用
|
|
5513
|
+
*
|
|
5514
|
+
* 最后两格是 Agent Teams 那两档颜色。它们的 `percent` 是"Lead 自己的占用",
|
|
5515
|
+
* 与真实环境一样:**颜色由谁在跑决定,弧长切到分层百分比**(见 ringPercentOf)。
|
|
5516
|
+
* 这里故意给两格差异明显的数(55 / 10),开发模式一眼就能看出弧长真的跟着层级变。
|
|
5517
|
+
*/
|
|
5518
|
+
const DEV_CONTEXT_SCENES = 9;
|
|
5519
|
+
/** 假上下文窗口(1M,与官方默认同量级)。压力与容量两处必须用同一个数,否则占用率会自相矛盾。 */
|
|
5520
|
+
const DEV_CONTEXT_WINDOW = 1000000;
|
|
5521
|
+
/** 本次请求的输出上限(384K,官方默认)——压缩点算得出与否就取决于它在不在。 */
|
|
5522
|
+
const DEV_CONTEXT_MAX_OUTPUT = 384000;
|
|
5523
|
+
const DEV_CONTEXT_SCENARIO = [
|
|
5524
|
+
{ state: "running", percent: 55, notchKnown: true },
|
|
5525
|
+
{ state: "error", percent: 65, notchKnown: true },
|
|
5526
|
+
{ state: "error", percent: 92, notchKnown: true },
|
|
5527
|
+
{ state: "approval", percent: 97, notchKnown: true },
|
|
5528
|
+
{ state: "idle", percent: 55, notchKnown: false },
|
|
5529
|
+
{ state: "running", percent: 100, notchKnown: true },
|
|
5530
|
+
{ state: "idle", percent: 0, notchKnown: true },
|
|
5531
|
+
/* 蓝档:成员级占用取**最大**(55),比 Lead 自己的 20 大一截,弧长变化看得出来 */
|
|
5532
|
+
{ state: "teamRunning", percent: 20, notchKnown: true, teamRunning: 2, deepRunning: 0, teamContextPercent: 55, deepContextPercent: null },
|
|
5533
|
+
/* 紫档:深层占用取**最小**(10),比 Lead 自己的 20 小,方向相反也好认 */
|
|
5534
|
+
{ state: "deepRunning", percent: 20, notchKnown: true, teamRunning: 0, deepRunning: 3, teamContextPercent: null, deepContextPercent: 10 }
|
|
5535
|
+
];
|
|
5536
|
+
|
|
5537
|
+
/** 假数据的「现在」:模块加载那一刻固定下来。
|
|
5538
|
+
为什么必须固定:总计账本按「调用时刻 + token 数」做指纹去重,如果假数据的 at 每帧都变,
|
|
5539
|
+
每一帧都会被当成一批新调用 —— 底栏上的金额会自己往上涨。 */
|
|
5540
|
+
const DEV_FIXTURE_ANCHOR = Date.now();
|
|
5541
|
+
|
|
5542
|
+
/* ------------------- 开发模式:其余 9 个字段的形态矩阵(每字段独立计数) */
|
|
5543
|
+
/**
|
|
5544
|
+
* 用户要「开发模式下展示各种情况、各种状态以及极端状况」。
|
|
5545
|
+
*
|
|
5546
|
+
* **但「没数据 → 横杠」整类已经被用户否掉**(原话:「就不要做横杠形态啊,
|
|
5547
|
+
* 既然都没数据,我看得出来什么?」)。理由站得住:横杠什么都看不出来,
|
|
5548
|
+
* 占一格纯浪费;而且它曾经把用户锁死过一次(点不动就翻不到下一格)。
|
|
5549
|
+
* 所以现在每张表**只保留两类**:
|
|
5550
|
+
* ① 正常有数据 ② 极端值(极大/极小/边界)
|
|
5551
|
+
* 兜底代码路径(segmentText 的横杠、dashMeter、dashBalance、emptySegmentTip)
|
|
5552
|
+
* **一律保留** —— 真实环境照样会走(会话刚发出、某平台查不到余额等),
|
|
5553
|
+
* 只是开发模式不再主动演示它们。
|
|
5554
|
+
*/
|
|
5555
|
+
const DEV_STATUS_SCENES = 2;
|
|
5556
|
+
const DEV_COUNTS_SCENES = 5;
|
|
5557
|
+
const DEV_TTFT_SCENES = 2;
|
|
5558
|
+
const DEV_CACHEHIT_SCENES = 3;
|
|
5559
|
+
const DEV_TPS_SCENES = 2;
|
|
5560
|
+
const DEV_SESSIONTIME_SCENES = 3;
|
|
5561
|
+
const DEV_LASTCOST_SCENES = 3;
|
|
5562
|
+
const DEV_COST_SCENES = 3;
|
|
5563
|
+
/**
|
|
5564
|
+
* 余额的两个格数**故意不同**,别混:
|
|
5565
|
+
* · `DEV_BALANCE_ROUNDS = 6` —— 老契约:`devFixture(round)` 不传字段计数时按它取模,
|
|
5566
|
+
* tests 的 6 轮矩阵断言钉着它,**不要动**。
|
|
5567
|
+
* · `DEV_BALANCE_CLICK_SCENES = 11` —— 点余额气泡那一路(每点一次换一格)。
|
|
5568
|
+
* 1~6 是最初那 6 种形态;7~11 是分币种相关的五种
|
|
5569
|
+
* (双币种 / 只有美元 / 某项为 0 / 两币种都有赠金 / 只有美元带赠金)。
|
|
5570
|
+
*/
|
|
5571
|
+
const DEV_BALANCE_CLICK_SCENES = 11;
|
|
5572
|
+
|
|
5573
|
+
/**
|
|
5574
|
+
* 假「现在」:**只喂 status 与 sessionTime 两段**,底栏时钟仍走真实 Date.now()。
|
|
5575
|
+
*
|
|
5576
|
+
* 为什么必须假化:status 段就是 `isPeakTime(src.now)` 判出来的,而时钟 state 永远给
|
|
5577
|
+
* 真实时刻 —— 于是"高峰"那一支在开发模式里几乎永远点不到(除非正好在 9-12 / 14-18)。
|
|
5578
|
+
* 用户要看到峰谷两种形态,就必须能人为把"现在"放进指定的时段。
|
|
5579
|
+
*
|
|
5580
|
+
* 为什么只改 src.now 而不改组件 state:改 state 会连带影响每秒时钟的节奏、
|
|
5581
|
+
* sessionTime 的 elapsed 累计、以及"当前是否在跑"的判定 —— 牵一发动全身。
|
|
5582
|
+
* 只在 src 组装处覆盖一次,退出开发模式立刻恢复真实值,风险最小。
|
|
5583
|
+
*
|
|
5584
|
+
* 日期从 2026-11-02(周一)起往后找第一个"按 isPeakTime 判定真的符合"的日子:
|
|
5585
|
+
* 不能硬编码某一天 —— 那天可能是周末或法定节假日(HOLIDAY_SETS 会把它判成低谷),
|
|
5586
|
+
* 写死就会出现「号称高峰、实际低谷」的自相矛盾。运行时用同一个 isPeakTime 自检。
|
|
5587
|
+
*/
|
|
5588
|
+
const DEV_NOW_CACHE = new Map();
|
|
5589
|
+
function devNowAt(wantPeak) {
|
|
5590
|
+
const key = wantPeak === true ? "peak" : "valley";
|
|
5591
|
+
if (DEV_NOW_CACHE.has(key) === true) return DEV_NOW_CACHE.get(key);
|
|
5592
|
+
/* 北京时间的 10:00 落在高峰、20:00 落在低谷;Date.UTC 的小时数要减 8 才是北京小时 */
|
|
5593
|
+
const hourUtc = wantPeak === true ? 2 : 12;
|
|
5594
|
+
const base = Date.UTC(2026, 10, 2);
|
|
5595
|
+
for (let day = 0; day < 60; day += 1) {
|
|
5596
|
+
const at = base + day * 86400000 + hourUtc * 3600000;
|
|
5597
|
+
if (isPeakTime(at) === (wantPeak === true)) {
|
|
5598
|
+
DEV_NOW_CACHE.set(key, at);
|
|
5599
|
+
return at;
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
/* 六十天都找不到(理论上不可能):退回固定锚点,至少不崩 */
|
|
5603
|
+
DEV_NOW_CACHE.set(key, DEV_FIXTURE_ANCHOR);
|
|
5604
|
+
return DEV_FIXTURE_ANCHOR;
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
/** counts 技能那一路的 30 条名字池(要能看出多列 + 逐档加行,名字长短也要有差)。 */
|
|
5608
|
+
const DEV_SKILL_NAMES = [
|
|
5609
|
+
"i-have-adhd", "ponytail", "cangjie-skill", "ui-ux-pro-max", "chanlun",
|
|
5610
|
+
"office", "office-docx", "office-xlsx", "office-pptx", "office-render",
|
|
5611
|
+
"web-search", "web-fetch", "todo-write", "subagent", "workflow",
|
|
5612
|
+
"ralph", "goal-tools", "skill-install", "agent-team", "cordis-plugin",
|
|
5613
|
+
"diagnose-sandbox", "dsh-badge", "trade-data", "futu-skill", "hithink",
|
|
5614
|
+
"zhouyi", "sunzi", "mao-works", "gann-trend", "elliott-wave"
|
|
5615
|
+
];
|
|
5616
|
+
|
|
5617
|
+
/* 本地日期键用现成的 dateKeyOf(同文件的 dateKeyOf 函数)—— 这里原先另写了一份 devDateKey,
|
|
5618
|
+
与它逐字节相同(只有"ms 缺省时怎么兜底"不同,而假数据的两个调用点都必传实参),
|
|
5619
|
+
属于重复实现,已删(审查报告 2-5 条)。 */
|
|
5620
|
+
|
|
5621
|
+
/* ------------------------------ 确定性伪随机(以"场景"为种子) */
|
|
5622
|
+
/**
|
|
5623
|
+
* 以 `seed` 为基础的确定性伪随机数发生器(xorshift32)。
|
|
5624
|
+
*
|
|
5625
|
+
* 为什么**必须**用这个而不是 `Math.random()`:
|
|
5626
|
+
* 假数据在底栏每次重渲染时都会重新生成。用真随机的话,同一屏里两次渲染的数字不同 ⇒
|
|
5627
|
+
* ① 用户看到数字在抖;② 「总计」账本按"调用时刻 + token 数"做指纹去重,每次都是新指纹,
|
|
5628
|
+
* 金额会自己往上涨。
|
|
5629
|
+
* 所以:**同一个 seed ⇒ 永远同一串数**(同场景完全稳定),换 seed 才换一组(看起来没规律)。
|
|
5630
|
+
* 这正是用户要的「随机呀」+ 我们要的「可复现」。
|
|
5631
|
+
*
|
|
5632
|
+
* xorshift32 选它是因为:实现只有三行、状态就是一个 32 位整数、分布够用;
|
|
5633
|
+
* 不需要密码学强度(这只是演示数据)。
|
|
5634
|
+
*/
|
|
5635
|
+
function devRandom(seed) {
|
|
5636
|
+
/* seed 0 是 xorshift 的退化点(永远输出 0),先抬到非零 */
|
|
5637
|
+
let state = (Number(seed) | 0) || 0x9e3779b9;
|
|
5638
|
+
return function next() {
|
|
5639
|
+
state ^= state << 13; state |= 0;
|
|
5640
|
+
state ^= state >>> 17;
|
|
5641
|
+
state ^= state << 5; state |= 0;
|
|
5642
|
+
/* 转成 [0,1):>>> 0 把它当无符号整数 */
|
|
5643
|
+
return ((state >>> 0) % 100000) / 100000;
|
|
5644
|
+
};
|
|
5645
|
+
}
|
|
5646
|
+
|
|
5647
|
+
/** 把若干场景计数混成一个种子(不同字段组合 ⇒ 不同 seed)。 */
|
|
5648
|
+
function devSeedOf() {
|
|
5649
|
+
let seed = 0x12345678;
|
|
5650
|
+
for (let i = 0; i < arguments.length; i += 1) {
|
|
5651
|
+
const value = typeof arguments[i] === "number" && Number.isFinite(arguments[i]) ? arguments[i] : 0;
|
|
5652
|
+
seed = ((seed * 31) + (value | 0)) | 0;
|
|
5653
|
+
}
|
|
5654
|
+
return seed;
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
/** 用伪随机取一个区间内的整数(含两端)。 */
|
|
5658
|
+
function devRandInt(next, min, max) {
|
|
5659
|
+
const lo = Math.ceil(min);
|
|
5660
|
+
const hi = Math.floor(max);
|
|
5661
|
+
if (hi <= lo) return lo;
|
|
5662
|
+
return lo + Math.floor(next() * (hi - lo + 1));
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5665
|
+
/**
|
|
5666
|
+
* 工具场景 ③ 的名字池(420 条那档专用)
|
|
5667
|
+
*
|
|
5668
|
+
* 用户实机反馈:「工具调用极端情况可滚动的那个情况,工具名太整齐了,看不出效果」。
|
|
5669
|
+
* 旧实现生成 `tool_001` ~ `tool_420`:全部 8 字符、同前缀、同长度,而列表是
|
|
5670
|
+
* **按名字长度升序**排的 ⇒ 排出来是一块方正网格,横向滚动滚的全是同样的格子。
|
|
5671
|
+
*
|
|
5672
|
+
* 现在改用**真实工具名**,长度从 2 到 37 连续分布,分成四段:
|
|
5673
|
+
* · 短名区(2~3 字符) 5 个 —— `ls` / `cat` / `pwd`,排在最前
|
|
5674
|
+
* · 原生区(4~18 字符) 59 个 —— `read` / `git_log` / `stream_read`……
|
|
5675
|
+
* · MCP 中段(11~21) 336 个 —— `mcp__fs__get` / `mcp__k8s__deploy`……
|
|
5676
|
+
* · 极长名(25~37) 20 个 —— `mcp__brave_search__wait_for_selector`……
|
|
5677
|
+
* 排序按长度分组,所以滚轮滚过去会依次看到「几格窄的 → 越来越宽 → 最后一大片很宽」,
|
|
5678
|
+
* 一眼能看出确实在滚;最后那批极长名也正好触发 hoverSuppressed。
|
|
5679
|
+
*
|
|
5680
|
+
* **坚决不加数字后缀**:给名字补序号会把长度重新抹平,等于把旧问题换个地方重演。
|
|
5681
|
+
* 420 条互不相同的名字全部由"真实前缀 × 真实动词"组合出来 —— 这些形状在真实的
|
|
5682
|
+
* 工具/MCP 客户端里就是这么写的,不是占位符。
|
|
5683
|
+
*/
|
|
5684
|
+
const DEV_TOOL_SHORT_NAMES = ["ls", "cat", "pwd", "top", "awk"];
|
|
5685
|
+
const DEV_TOOL_BUILTIN_NAMES = [
|
|
5686
|
+
"read", "edit", "bash", "glob", "grep", "pwsh", "write", "skill", "task", "wait",
|
|
5687
|
+
"kill", "list", "open", "save", "copy", "move", "undo", "redo", "test", "lint",
|
|
5688
|
+
"sync", "push", "pull", "dump", "load", "tidy",
|
|
5689
|
+
"fs_read", "fs_write", "git_log", "git_diff", "git_show", "sql_query", "http_get",
|
|
5690
|
+
"web_fetch", "web_search", "todo_write", "multi_edit", "subagent", "notebook",
|
|
5691
|
+
"image_gen", "db_query", "db_migrate", "fs_tree", "git_blame", "git_stash",
|
|
5692
|
+
"task_wait", "task_kill", "task_list", "file_open", "file_save", "memory_read",
|
|
5693
|
+
"cache_get", "queue_push", "stream_read", "log_query", "health_check", "cron_list",
|
|
5694
|
+
"uuid_generate", "semver_compare"
|
|
5695
|
+
];
|
|
5696
|
+
/* MCP 中段:短服务名 × 短动词,组合出来 11~21 字符(落在「其余在 4~20」那条要求附近)。
|
|
5697
|
+
服务名长短也拉开(`fs` 2 字符 → `postgres` 8 字符),这样中段自身也有梯度,
|
|
5698
|
+
而不是从短名区到长名区中间夹一大块等长格子。 */
|
|
5699
|
+
const DEV_MCP_SERVERS = ["fs", "db", "s3", "git", "k8s", "aws", "ssh", "dns", "http", "redis", "kafka", "postgres"];
|
|
5700
|
+
const DEV_MCP_VERBS = [
|
|
5701
|
+
"get", "set", "put", "del", "run", "list", "find", "read", "push", "pull",
|
|
5702
|
+
"sync", "copy", "move", "open", "save", "test", "lint", "scan", "watch", "build",
|
|
5703
|
+
"deploy", "restart", "status", "logs", "tail", "merge", "query", "reload"
|
|
5704
|
+
];
|
|
5705
|
+
/* 极长名:长服务名 × 长动词,组合出来 25~37 字符(触发悬停抑制那一档) */
|
|
5706
|
+
const DEV_MCP_LONG_SERVERS = ["playwright", "filesystem", "memory_bank", "brave_search"];
|
|
5707
|
+
const DEV_MCP_LONG_VERBS = [
|
|
5708
|
+
"merge_pull_request", "wait_for_selector", "evaluate_script", "download_file", "add_comment"
|
|
5709
|
+
];
|
|
5710
|
+
|
|
5711
|
+
/**
|
|
5712
|
+
* 工具场景(`desktopStatusbarProgress.toolCounts` 的假值)。编号与含义:
|
|
5713
|
+
* ① 3 个短名工具(4~7 字符,最窄的一档) ② 18 个名字 2~27 字符(含 >=20 字符的)
|
|
5714
|
+
* ③ 420 个**真实长短名混排**(见上面那四组名字池;2~37 字符连续分布)
|
|
5715
|
+
* ④ 24 个 4~5 字符短名(2 列 × 12 行)
|
|
5716
|
+
* ⑤ 长短名**极端**混排:2 字符与 50+ 字符同屏(只供"点 counts 换形态"那一路)
|
|
5717
|
+
*
|
|
5718
|
+
* ①~④ 同时供 `devFixture(round)` 按轮取模用(每轮一种,旧的演示节奏靠它);
|
|
5719
|
+
* ⑤ 只在 counts 计数推进时出现,不进轮次取模 ——
|
|
5720
|
+
* 否则 devFixture 的 4 轮循环会被打破(tests/verify-dev-fixtures.js 有断言盯着)。
|
|
5721
|
+
*/
|
|
5722
|
+
function devToolCounts(scene) {
|
|
5723
|
+
const out = {};
|
|
5724
|
+
if (scene === 1) {
|
|
5725
|
+
out.read = 7;
|
|
5726
|
+
out.edit = 3;
|
|
5727
|
+
out.browser = 2;
|
|
5728
|
+
return out;
|
|
5729
|
+
}
|
|
5730
|
+
if (scene === 2) {
|
|
5731
|
+
/* 名字长度 3~27 混排:底栏的工具列表按名字长度升序排,能看出长短混排的效果 */
|
|
5732
|
+
const names = [
|
|
5733
|
+
"cat", "ls", "grep", "read", "edit", "bash", "pwsh", "glob", "write", "skill",
|
|
5734
|
+
"subagent", "web_fetch", "web_search", "todo_write", "multi_edit",
|
|
5735
|
+
"mcp__demo__search", "mcp__playwright__screenshot", "mcp__agents__spawn_subagent"
|
|
5736
|
+
];
|
|
5737
|
+
names.forEach((name, index) => { out[name] = 1 + (index % 5); });
|
|
5738
|
+
return out;
|
|
5739
|
+
}
|
|
5740
|
+
if (scene === 3) {
|
|
5741
|
+
/* 420 条 = 5 短 + 59 原生 + 336 中段 MCP + 20 极长 MCP。
|
|
5742
|
+
次数 1~12 各不相同,悬停占比条因此不会全一样。
|
|
5743
|
+
写入顺序按"短→中→长",JSON 里读起来也能看出梯度(列表自己排序,与写入顺序无关)。 */
|
|
5744
|
+
let index = 0;
|
|
5745
|
+
const put = (name) => {
|
|
5746
|
+
out[name] = 1 + ((index * 5) % 12);
|
|
5747
|
+
index += 1;
|
|
5748
|
+
};
|
|
5749
|
+
DEV_TOOL_SHORT_NAMES.forEach(put);
|
|
5750
|
+
DEV_TOOL_BUILTIN_NAMES.forEach(put);
|
|
5751
|
+
DEV_MCP_SERVERS.forEach((server) => {
|
|
5752
|
+
DEV_MCP_VERBS.forEach((verb) => put("mcp__" + server + "__" + verb));
|
|
5753
|
+
});
|
|
5754
|
+
DEV_MCP_LONG_SERVERS.forEach((server) => {
|
|
5755
|
+
DEV_MCP_LONG_VERBS.forEach((verb) => put("mcp__" + server + "__" + verb));
|
|
5756
|
+
});
|
|
5757
|
+
return out;
|
|
5758
|
+
}
|
|
5759
|
+
if (scene === 4) {
|
|
5760
|
+
/* 24 个 4~5 字符短名:24 ÷ 12 行 = 2 列,正好停在上限那一档 */
|
|
5761
|
+
const short = [
|
|
5762
|
+
"read", "edit", "bash", "glob", "grep", "pwsh", "task", "wait",
|
|
5763
|
+
"kill", "list", "open", "save", "copy", "move", "undo", "redo",
|
|
5764
|
+
"test", "lint", "sync", "push", "pull", "dump", "load", "tidy"
|
|
5765
|
+
];
|
|
5766
|
+
short.forEach((name, index) => { out[name] = 1 + (index % 4); });
|
|
5767
|
+
return out;
|
|
5768
|
+
}
|
|
5769
|
+
/* ⑤ 长短名极端混排:2 字符的挤在最前、30+ 字符的排在最后(列表按长度升序)。
|
|
5770
|
+
用来暴露「短名时名字与次数的间隙」与「长名时悬停提示放不下就整组关掉」这两条边界。 */
|
|
5771
|
+
const mixed = [
|
|
5772
|
+
"lm", "ok", "go", "up", "id", "ip",
|
|
5773
|
+
"read", "edit", "bash", "glob", "grep", "skill",
|
|
5774
|
+
"web_search", "todo_write", "mcp__demo__search",
|
|
5775
|
+
"mcp__playwright__screenshot",
|
|
5776
|
+
"mcp__agents__spawn_subagent__with_extra_long_suffix",
|
|
5777
|
+
"mcp__external__fetch_and_transform_document_pipeline"
|
|
5778
|
+
];
|
|
5779
|
+
mixed.forEach((name, index) => { out[name] = 1 + (index % 6); });
|
|
5780
|
+
return out;
|
|
5781
|
+
}
|
|
5782
|
+
|
|
5783
|
+
/**
|
|
5784
|
+
* 11 种余额形态(点余额那一项的轮换顺序见 DEV_BALANCE_CLICK_SCENES):
|
|
5785
|
+
* ① DeepSeek 金额(充值 70 + 赠金 30,色条按 7:3 分两段)
|
|
5786
|
+
* ② 智谱 Coding Plan 百分比配额(isPercent,已用 62.50%)
|
|
5787
|
+
* ③ WorkBuddy 积分(1280,rewards:1 个月度包 + 4 个同名裂变包 + 2 个同名普通包)
|
|
5788
|
+
* ④ 中转站按量计费(USD 86.40 + 累计消费 13.60)
|
|
5789
|
+
* ⑤ 中转站积分制(320 积分,3 个套餐 25% / 50% / 75%)
|
|
5790
|
+
* ⑥ DeepSeek 不足一分钱(subCent:true + 已截断的 "0.00" ⇒ 界面显示「余额 <¥0.01」)
|
|
5791
|
+
* ⑦ 人民币 + 美元双币种(¥14.12 + $3.17,底栏 `余额 ¥14.12+$3.17`)
|
|
5792
|
+
* ⑧ 只有美元一项(`余额 $3.17`,验证单项不带 +)
|
|
5793
|
+
* ⑨ 美元项为 0(`余额 ¥14.12`,验证 0 那项不出现)
|
|
5794
|
+
* ⑩ **两币种都有赠金**(CNY 10.00+2.50 / USD 4.00+1.00 ⇒ 色条灰约 80% / 紫约 20%)
|
|
5795
|
+
* ⑪ 只有美元且带赠金(验证 USD→CNY 折算方向是乘)
|
|
5796
|
+
*
|
|
5797
|
+
* 字段名照宿主真实响应(lib/index.js 的 queryBalance / balance-api.js 的 parse):
|
|
5798
|
+
* ok / at / currency / total / toppedUp / granted / spent / subCent / isPercent / isCredits / rewards。
|
|
5799
|
+
* **3.0.0 起 `balances` 每笔也带 `toppedUp` / `granted`**(`{currency, total, toppedUp, granted}`):
|
|
5800
|
+
* 气泡里那条「按币种折算的色条」靠它算比例;少了这两个字段会静默退回顶层混合值,
|
|
5801
|
+
* 于是折算逻辑在开发模式里永远演示不出来(这正是 ⑩ 存在的意义)。
|
|
5802
|
+
*/
|
|
5803
|
+
function devBalanceFixture(which) {
|
|
5804
|
+
const at = DEV_FIXTURE_ANCHOR;
|
|
5805
|
+
if (which === 1) {
|
|
5806
|
+
return {
|
|
5807
|
+
ok: true, at: at, source: "dev", currency: "CNY",
|
|
5808
|
+
total: "100.00", toppedUp: "70.00", granted: "30.00", available: true,
|
|
5809
|
+
parts: [{ label: "toppedUp", amount: "70.00" }, { label: "granted", amount: "30.00" }]
|
|
5810
|
+
};
|
|
5811
|
+
}
|
|
5812
|
+
if (which === 2) {
|
|
5813
|
+
/* 百分比口径:total 带 %、currency 留空(客户端据此不拼货币符号),
|
|
5814
|
+
气泡里只列「剩余配额」一行 = 100 - 62.50 = 37.50% */
|
|
5815
|
+
return {
|
|
5816
|
+
ok: true, at: at, source: "dev", provider: "zhipu", currency: "",
|
|
5817
|
+
total: "62.50%", isPercent: true, parts: [], toppedUp: "0.00", granted: "0.00"
|
|
5818
|
+
};
|
|
5819
|
+
}
|
|
5820
|
+
if (which === 3) {
|
|
5821
|
+
/* WorkBuddy 积分:rewards 的顺序**必须**是「月度包 → 4 个同名裂变包 → 2 个同名普通包」。
|
|
5822
|
+
客户端只聚合**相邻同名**包(先聚合、后排序),中间插了别的名字就并不成 ×4 / ×2。
|
|
5823
|
+
聚合后的 percent:月度 400/800=50%、裂变 396/1200=33%、普通 402/600=67%。 */
|
|
5824
|
+
const split = { name: "个人版国内运营裂变包", used: 99, total: 300, monthly: false, expireAt: "2026-12-31", refreshAt: null };
|
|
5825
|
+
const normal = { name: "活动赠送包", used: 201, total: 300, monthly: false, expireAt: "2026-11-20", refreshAt: null };
|
|
5826
|
+
return {
|
|
5827
|
+
ok: true, at: at, source: "dev", provider: "workbuddy", currency: "",
|
|
5828
|
+
total: "1280", isCredits: true, parts: [],
|
|
5829
|
+
rewards: [
|
|
5830
|
+
{ name: "专业版月度包", used: 400, total: 800, monthly: true, expireAt: null, refreshAt: "2026-11-01" },
|
|
5831
|
+
split, split, split, split,
|
|
5832
|
+
normal, normal
|
|
5833
|
+
]
|
|
5834
|
+
};
|
|
5835
|
+
}
|
|
5836
|
+
if (which === 4) {
|
|
5837
|
+
return {
|
|
5838
|
+
ok: true, at: at, source: "dev", currency: "USD",
|
|
5839
|
+
total: "86.40", toppedUp: "86.40", granted: "0.00", spent: "13.60",
|
|
5840
|
+
parts: [{ label: "toppedUp", amount: "86.40" }, { label: "spent", amount: "13.60" }]
|
|
5841
|
+
};
|
|
5842
|
+
}
|
|
5843
|
+
if (which === 5) {
|
|
5844
|
+
/* 积分制中转站:3 个**不同名**套餐(不聚合),percent 25 / 50 / 75,没有裂变包 */
|
|
5845
|
+
return {
|
|
5846
|
+
ok: true, at: at, source: "dev", currency: "",
|
|
5847
|
+
total: "320", isCredits: true, parts: [],
|
|
5848
|
+
rewards: [
|
|
5849
|
+
{ name: "标准套餐", used: 25, total: 100, monthly: false, expireAt: "2026-12-15", refreshAt: null },
|
|
5850
|
+
{ name: "专业套餐", used: 100, total: 200, monthly: false, expireAt: "2026-11-30", refreshAt: null },
|
|
5851
|
+
{ name: "团队套餐", used: 300, total: 400, monthly: false, expireAt: "2026-11-10", refreshAt: null }
|
|
5852
|
+
]
|
|
5853
|
+
};
|
|
5854
|
+
}
|
|
5855
|
+
if (which === 7) {
|
|
5856
|
+
/* ⑦ **人民币 + 美元双币种**(用户原话那个例子:「我美元模型用了 7.77。
|
|
5857
|
+
人民币模型用了 145.66。那显示就应该显示 ¥145.66+$7.77」)。
|
|
5858
|
+
这里用 14.12 / 3.17 两个数(任务指定),底栏应显示 `余额 ¥14.12+$3.17`。
|
|
5859
|
+
注意 `total` 仍给一个混合值(17.29 = 14.12 + 3.17):老 host 没有 balances 时
|
|
5860
|
+
靠它兜底,两者语义一致才不矛盾。
|
|
5861
|
+
|
|
5862
|
+
**每笔 balances 都带 toppedUp / granted**(3.0.0 起宿主也给):
|
|
5863
|
+
没有这两个字段的话,气泡里「按币种折算的色条」永远走回退分支
|
|
5864
|
+
(只能拿顶层混合值),折算逻辑在开发模式里就一次都演示不出来。
|
|
5865
|
+
本形态赠金全是 0 ⇒ 色条会是一整段灰(这是正确表现,见形态 10 的双向赠金)。 */
|
|
5866
|
+
return {
|
|
5867
|
+
ok: true, at: at, source: "dev", currency: "CNY",
|
|
5868
|
+
total: "17.29", toppedUp: "17.29", granted: "0.00",
|
|
5869
|
+
parts: [{ label: "toppedUp", amount: "17.29" }, { label: "granted", amount: "0.00" }],
|
|
5870
|
+
balances: [
|
|
5871
|
+
{ currency: "CNY", total: "14.12", toppedUp: "14.12", granted: "0.00" },
|
|
5872
|
+
{ currency: "USD", total: "3.17", toppedUp: "3.17", granted: "0.00" }
|
|
5873
|
+
]
|
|
5874
|
+
};
|
|
5875
|
+
}
|
|
5876
|
+
if (which === 8) {
|
|
5877
|
+
/* ⑧ **只有美元一项**:验证「单项不带 +」——底栏应是 `余额 $3.17`,
|
|
5878
|
+
不能出现 `¥0.00+$3.17` 那种写法。 */
|
|
5879
|
+
return {
|
|
5880
|
+
ok: true, at: at, source: "dev", currency: "USD",
|
|
5881
|
+
total: "3.17", toppedUp: "3.17", granted: "0.00",
|
|
5882
|
+
parts: [{ label: "toppedUp", amount: "3.17" }, { label: "granted", amount: "0.00" }],
|
|
5883
|
+
balances: [{ currency: "USD", total: "3.17", toppedUp: "3.17", granted: "0.00" }]
|
|
5884
|
+
};
|
|
5885
|
+
}
|
|
5886
|
+
if (which === 9) {
|
|
5887
|
+
/* ⑨ **美元项为 0**:验证用户那条规则「其中有一项为 0…就不用显示这个 +」。
|
|
5888
|
+
底栏应仍是 `余额 ¥14.12`(0 那项被过滤掉),不会出现 `¥14.12+$0.00`。 */
|
|
5889
|
+
return {
|
|
5890
|
+
ok: true, at: at, source: "dev", currency: "CNY",
|
|
5891
|
+
total: "14.12", toppedUp: "14.12", granted: "0.00",
|
|
5892
|
+
parts: [{ label: "toppedUp", amount: "14.12" }, { label: "granted", amount: "0.00" }],
|
|
5893
|
+
balances: [
|
|
5894
|
+
{ currency: "CNY", total: "14.12", toppedUp: "14.12", granted: "0.00" },
|
|
5895
|
+
{ currency: "USD", total: "0", toppedUp: "0", granted: "0" }
|
|
5896
|
+
]
|
|
5897
|
+
};
|
|
5898
|
+
}
|
|
5899
|
+
if (which === 10) {
|
|
5900
|
+
/* ⑩ **两个币种都有赠金** —— 这是唯一能让「按币种折算的色条」真正出效果的形态。
|
|
5901
|
+
前几种形态赠金全是 0,色条只会是一整段灰,折算逻辑等于没演示;
|
|
5902
|
+
这里两个币种各带一部分赠金,折算后应当出现灰 / 紫两段。
|
|
5903
|
+
|
|
5904
|
+
数值自洽(每笔 toppedUp + granted === total,顶层也等于各笔之和):
|
|
5905
|
+
· CNY:10.00 充值 + 2.50 赠金 = 12.50
|
|
5906
|
+
· USD: 4.00 充值 + 1.00 赠金 = 5.00
|
|
5907
|
+
· 顶层:14.00 充值 + 3.50 赠金 = 17.50
|
|
5908
|
+
色条预期(REFERENCE_RATE ≈ 6.7424,折到 CNY):
|
|
5909
|
+
充值 = 10.00 + 4.00×6.7424 = 36.97
|
|
5910
|
+
赠金 = 2.50 + 1.00×6.7424 = 9.24
|
|
5911
|
+
⇒ **灰约 80% / 紫约 20%**(仍是两段,不按币种拆四段)。
|
|
5912
|
+
若看到的是「灰 100%」或「灰 87.9%(直接相加没折算)」,说明折算分支没生效。 */
|
|
5913
|
+
return {
|
|
5914
|
+
ok: true, at: at, source: "dev", currency: "CNY",
|
|
5915
|
+
total: "17.50", toppedUp: "14.00", granted: "3.50",
|
|
5916
|
+
parts: [{ label: "toppedUp", amount: "14.00" }, { label: "granted", amount: "3.50" }],
|
|
5917
|
+
balances: [
|
|
5918
|
+
{ currency: "CNY", total: "12.50", toppedUp: "10.00", granted: "2.50" },
|
|
5919
|
+
{ currency: "USD", total: "5.00", toppedUp: "4.00", granted: "1.00" }
|
|
5920
|
+
]
|
|
5921
|
+
};
|
|
5922
|
+
}
|
|
5923
|
+
if (which === 11) {
|
|
5924
|
+
/* ⑪ **只有美元且带赠金** —— 验证折算方向(USD → CNY 是**乘**汇率,不是除)。
|
|
5925
|
+
如果方向搞反了,充值/赠金都会被缩小 45 倍,但**比例仍可能是 80:20**,
|
|
5926
|
+
所以单看百分比看不出错 —— 这条形态主要配合下面 `devBalanceFixture`
|
|
5927
|
+
的数值断言一起看(折算绝对值对不对)。
|
|
5928
|
+
数值:USD 4.00 充值 + 1.00 赠金 = 5.00,比例仍是 灰 80% / 紫 20%。 */
|
|
5929
|
+
return {
|
|
5930
|
+
ok: true, at: at, source: "dev", currency: "USD",
|
|
5931
|
+
total: "5.00", toppedUp: "4.00", granted: "1.00",
|
|
5932
|
+
parts: [{ label: "toppedUp", amount: "4.00" }, { label: "granted", amount: "1.00" }],
|
|
5933
|
+
balances: [{ currency: "USD", total: "5.00", toppedUp: "4.00", granted: "1.00" }]
|
|
5934
|
+
};
|
|
5935
|
+
}
|
|
5936
|
+
/* ⑥ 不足一分钱:宿主判定的是**未截断**的 total(0 < total < 0.01 ⇒ subCent:true),
|
|
5937
|
+
而显示用的 total 已经被 formatBalanceAmount 截到分 ⇒ "0.00"。
|
|
5938
|
+
客户端只要看到 subCent:true 就显示「余额 <¥0.01」,两个字段必须成对出现。
|
|
5939
|
+
**保持在第 6 格**:`DEV_BALANCE_ROUNDS`(6 轮矩阵)那条旧契约是按它断言的,
|
|
5940
|
+
新加的分币种形态排在它后面(7~11),不能把 6 挤走。 */
|
|
5941
|
+
return {
|
|
5942
|
+
ok: true, at: at, source: "dev", currency: "CNY",
|
|
5943
|
+
total: "0.00", subCent: true, toppedUp: "0.00", granted: "0.00",
|
|
5944
|
+
parts: [{ label: "toppedUp", amount: "0.00" }, { label: "granted", amount: "0.00" }]
|
|
5945
|
+
};
|
|
5946
|
+
}
|
|
5947
|
+
|
|
5948
|
+
/** 单次调用的用量记录(desktopStatusbarUsage.calls 的元素,字段照 usageCall schema)。 */
|
|
5949
|
+
function devUsageCall(atMs, input, cacheRead, cacheWrite, output) {
|
|
5950
|
+
return {
|
|
5951
|
+
at: atMs,
|
|
5952
|
+
model: "deepseek-flash",
|
|
5953
|
+
input: input, cacheRead: cacheRead, cacheWrite: cacheWrite, output: output
|
|
5954
|
+
};
|
|
5955
|
+
}
|
|
5956
|
+
|
|
5957
|
+
/**
|
|
5958
|
+
* desktopStatusbarUsage 的假值。
|
|
5959
|
+
*
|
|
5960
|
+
* 两个独立驱动:
|
|
5961
|
+
* · `costCount` —— 「总计」那一堆历史调用(calls / models / overflow)
|
|
5962
|
+
* · `lastCostCount` —— 「本轮」那一条(current)与轮号(turn)
|
|
5963
|
+
* 分成两份的原因:用户要求"点总计只变总计、点本轮只变本轮"。早先一个计数同时驱动两者,
|
|
5964
|
+
* 点任一个都会让另一个的数字跳。
|
|
5965
|
+
*
|
|
5966
|
+
* 金额会**随计数明显变大**:costCount 越大,历史调用的 token 越多(每 +1 多一批),
|
|
5967
|
+
* 所以「总计」的金额单调上涨、一眼看得出变化。
|
|
5968
|
+
*
|
|
5969
|
+
* 时间戳全部以 DEV_FIXTURE_ANCHOR 为基准且与计数无关 ⇒ 同一场景反复调用得到完全相同的
|
|
5970
|
+
* 指纹(总计账本按"时刻 + token"去重,乱变会让钱自己涨)。
|
|
5971
|
+
*/
|
|
5972
|
+
function devSessionUsageFixture(costCount, lastCostCount, baseTurn, turnEmpty) {
|
|
5973
|
+
const anchor = DEV_FIXTURE_ANCHOR;
|
|
5974
|
+
/* 历史调用:条数与 token 量都随 costCount 增长(阶梯明显,不是微调) */
|
|
5975
|
+
const history = devRandom(devSeedOf(7, costCount));
|
|
5976
|
+
const callTotal = 3 + costCount * 2;
|
|
5977
|
+
const calls = [];
|
|
5978
|
+
for (let i = 0; i < callTotal; i += 1) {
|
|
5979
|
+
const scale = (costCount + 1) * (0.6 + history() * 0.9);
|
|
5980
|
+
calls.push(devUsageCall(
|
|
5981
|
+
anchor - (callTotal - i) * 60000,
|
|
5982
|
+
Math.round(devRandInt(history, 4000, 40000) * scale),
|
|
5983
|
+
Math.round(devRandInt(history, 60000, 700000) * scale),
|
|
5984
|
+
Math.round(devRandInt(history, 0, 12000) * scale),
|
|
5985
|
+
Math.round(devRandInt(history, 6000, 60000) * scale)
|
|
5986
|
+
));
|
|
5987
|
+
}
|
|
5988
|
+
const total = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
5989
|
+
calls.forEach((one) => {
|
|
5990
|
+
total.input += one.input;
|
|
5991
|
+
total.cacheRead += one.cacheRead;
|
|
5992
|
+
total.cacheWrite += one.cacheWrite;
|
|
5993
|
+
total.output += one.output;
|
|
5994
|
+
});
|
|
5995
|
+
/* 本轮:只由 lastCostCount 驱动;轮号跟着它走,这样「本轮」的粘性下限会作废重算 */
|
|
5996
|
+
const turnNext = devRandom(devSeedOf(8, lastCostCount));
|
|
5997
|
+
const turnNo = typeof baseTurn === "number" && Number.isFinite(baseTurn) ? baseTurn : 1;
|
|
5998
|
+
const turnScale = (lastCostCount + 1) * (0.7 + turnNext() * 1.1);
|
|
5999
|
+
const currentCall = devUsageCall(
|
|
6000
|
+
anchor - 240000,
|
|
6001
|
+
Math.round(devRandInt(turnNext, 3000, 30000) * turnScale),
|
|
6002
|
+
Math.round(devRandInt(turnNext, 40000, 500000) * turnScale),
|
|
6003
|
+
Math.round(devRandInt(turnNext, 0, 8000) * turnScale),
|
|
6004
|
+
Math.round(devRandInt(turnNext, 4000, 50000) * turnScale)
|
|
6005
|
+
);
|
|
6006
|
+
const current = {
|
|
6007
|
+
provider: "deepseek-official", model: "deepseek-flash", time: currentCall.at,
|
|
6008
|
+
input: currentCall.input, cacheRead: currentCall.cacheRead,
|
|
6009
|
+
cacheWrite: currentCall.cacheWrite, output: currentCall.output,
|
|
6010
|
+
/* turnEmpty:本轮还没有任何调用。
|
|
6011
|
+
segmentView 的 lastCost 就是按 current.calls 逐条算价的 —— 留一条就一定会
|
|
6012
|
+
算出金额,"正在跑但本轮没调用(¥0.00)"与"空闲无数据(¥-)"两格就都到不了。
|
|
6013
|
+
轮号(turn)仍保留:它只用来判断"这份用量属不属于本轮",与本轮有没有调用无关。 */
|
|
6014
|
+
calls: turnEmpty === true ? [] : [currentCall]
|
|
6015
|
+
};
|
|
6016
|
+
return {
|
|
6017
|
+
models: { "deepseek-flash": total },
|
|
6018
|
+
calls: calls,
|
|
6019
|
+
overflow: {},
|
|
6020
|
+
/* 轮号由本轮计数推出:一变「本轮」的轮标识就变,粘性下限自动作废 */
|
|
6021
|
+
turn: turnNo + lastCostCount * 7,
|
|
6022
|
+
current: current,
|
|
6023
|
+
last: calls.length > 2 ? {
|
|
6024
|
+
provider: "deepseek-official", model: "deepseek-flash", time: calls[calls.length - 2].at,
|
|
6025
|
+
input: calls[calls.length - 2].input, cacheRead: calls[calls.length - 2].cacheRead,
|
|
6026
|
+
cacheWrite: calls[calls.length - 2].cacheWrite, output: calls[calls.length - 2].output,
|
|
6027
|
+
calls: [calls[calls.length - 2]]
|
|
6028
|
+
} : current,
|
|
6029
|
+
/* 压缩摘要那一份额外累计(「总计用量」要把它加到官方投影上) */
|
|
6030
|
+
summaryParts: {
|
|
6031
|
+
input: Math.round(total.input * 0.03),
|
|
6032
|
+
cacheRead: Math.round(total.cacheRead * 0.01),
|
|
6033
|
+
cacheWrite: 0,
|
|
6034
|
+
output: Math.round(total.output * 0.015)
|
|
6035
|
+
}
|
|
6036
|
+
};
|
|
6037
|
+
}
|
|
6038
|
+
|
|
6039
|
+
/**
|
|
6040
|
+
* 当一个月的日历假值(字段照 lib/index.js 的 monthUsagePayload)。
|
|
6041
|
+
*
|
|
6042
|
+
* **月份由 status 计数轮换**:用户实机反馈「一直在用 10 月份的呢」—— 早先这里写死
|
|
6043
|
+
* `new Date(now)` 取当前月,所以月热力图永远停在本月,换月这个 UI 形态根本看不到。
|
|
6044
|
+
* 现在从**当前月往前推** `shift` 个月(0 = 本月、1 = 上月…),点一次 status 就翻一页。
|
|
6045
|
+
* 往前推而不是随便取,是为了"连续看几个月"这件事本身说得通。
|
|
6046
|
+
*
|
|
6047
|
+
* 每天的用量用确定性伪随机生成(同一个月 ⇒ 同一组数),所以柱高有高有低、不是斜线。
|
|
6048
|
+
*/
|
|
6049
|
+
function devMonthFixture(now, shift) {
|
|
6050
|
+
const back = typeof shift === "number" && Number.isFinite(shift) && shift > 0 ? Math.floor(shift) : 0;
|
|
6051
|
+
const today = new Date(now);
|
|
6052
|
+
/* 用 Date 的月份归一化能力往前推(它会自动跨年) */
|
|
6053
|
+
const first = new Date(today.getFullYear(), today.getMonth() - back, 1);
|
|
6054
|
+
const year = first.getFullYear();
|
|
6055
|
+
const month = first.getMonth() + 1;
|
|
6056
|
+
const firstWeekday = (new Date(year, month - 1, 1).getDay() + 6) % 7;
|
|
6057
|
+
const daysInMonth = new Date(year, month, 0).getDate();
|
|
6058
|
+
const lastWeekday = (new Date(year, month - 1, daysInMonth).getDay() + 6) % 7;
|
|
6059
|
+
const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday);
|
|
6060
|
+
const gridStart = new Date(year, month - 1, 1 - firstWeekday);
|
|
6061
|
+
/* 这个月的伪随机种子 = 年月(同一个月永远同一组数) */
|
|
6062
|
+
const next = devRandom(devSeedOf(11, year * 100 + month));
|
|
6063
|
+
const days = [];
|
|
6064
|
+
let max = 0;
|
|
6065
|
+
let maxDate = null;
|
|
6066
|
+
for (let i = 0; i < gridDays; i += 1) {
|
|
6067
|
+
const at = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i).getTime();
|
|
6068
|
+
const key = dateKeyOf(at);
|
|
6069
|
+
const inMonth = i >= firstWeekday && i < firstWeekday + daysInMonth;
|
|
6070
|
+
/* 每天的量:伪随机 + 周末更低(真实用量确实有周末效应),不是等差 */
|
|
6071
|
+
const weekday = (new Date(at).getDay() + 6) % 7;
|
|
6072
|
+
const weekend = weekday >= 5 ? 0.35 : 1;
|
|
6073
|
+
const total = inMonth === true
|
|
6074
|
+
? Math.round((600 + next() * 9000) * weekend)
|
|
6075
|
+
: 0;
|
|
6076
|
+
const input = Math.round(total * (0.08 + next() * 0.12));
|
|
6077
|
+
const cacheRead = Math.round(total * (0.6 + next() * 0.18));
|
|
6078
|
+
const cacheWrite = Math.round(total * (0.01 + next() * 0.03));
|
|
6079
|
+
const output = Math.max(0, total - input - cacheRead - cacheWrite);
|
|
6080
|
+
/* 量级与 devDailyFixture 对齐(1 元级):原来是 `total/1e6`,当月单日只有 0.01 级,
|
|
6081
|
+
而「金额不足 0.01 不显示」那条规则一上,日历上会全是 ¥0.00 —— 开发模式就验证不到
|
|
6082
|
+
分币种格式了。把 1e6 改成 1e4 等于放大 100 倍,落到 1~几元这一档。
|
|
6083
|
+
(只动量级,不动拆币逻辑:costByCurrency 各项之和仍严格等于 cost。) */
|
|
6084
|
+
const dayCost = total > 0 ? Math.round(total / 1e4 * 2.2 * 10000) / 10000 : 0;
|
|
6085
|
+
days.push({
|
|
6086
|
+
date: key,
|
|
6087
|
+
inMonth: inMonth,
|
|
6088
|
+
total: total,
|
|
6089
|
+
calls: total > 0 ? Math.round(1 + next() * 9) : 0,
|
|
6090
|
+
cost: dayCost,
|
|
6091
|
+
/* 分币种:按格序号轮换三种形态(混币 / 只有人民币 / 只有美元),
|
|
6092
|
+
各币种之和 === cost;合计为 0 的天给空对象(没费用就没有币种) */
|
|
6093
|
+
costByCurrency: devCostSplit(dayCost, devCostShapeAt(i)),
|
|
6094
|
+
input: input, cacheRead: cacheRead, cacheWrite: cacheWrite, output: output
|
|
6095
|
+
});
|
|
6096
|
+
if (inMonth === true && total > max) {
|
|
6097
|
+
max = total;
|
|
6098
|
+
maxDate = key;
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
return { year: year, month: month, max: max, maxDate: maxDate, days: days };
|
|
6102
|
+
}
|
|
6103
|
+
|
|
6104
|
+
/**
|
|
6105
|
+
* 当年 12 个月聚合假值(字段照 lib/index.js 的 yearUsagePayload)。
|
|
6106
|
+
* 每月一个伪随机量(同一年的值稳定),不是"逐月 +9000"那种斜线。
|
|
6107
|
+
*/
|
|
6108
|
+
function devYearFixture(now, shift) {
|
|
6109
|
+
const back = typeof shift === "number" && Number.isFinite(shift) && shift > 0 ? Math.floor(shift) : 0;
|
|
6110
|
+
const first = new Date(new Date(now).getFullYear(), new Date(now).getMonth() - back, 1);
|
|
6111
|
+
const year = first.getFullYear();
|
|
6112
|
+
const next = devRandom(devSeedOf(12, year));
|
|
6113
|
+
const months = [];
|
|
6114
|
+
let max = 0;
|
|
6115
|
+
let maxMonth = null;
|
|
6116
|
+
for (let m = 1; m <= 12; m += 1) {
|
|
6117
|
+
const input = Math.round(devRandInt(next, 8000, 260000));
|
|
6118
|
+
const cacheRead = Math.round(devRandInt(next, 60000, 3200000));
|
|
6119
|
+
const cacheWrite = Math.round(devRandInt(next, 0, 40000));
|
|
6120
|
+
const output = Math.round(devRandInt(next, 20000, 420000));
|
|
6121
|
+
const total = input + cacheRead + cacheWrite + output;
|
|
6122
|
+
/* **保持 1e6 不动**:年度桶的 `total` 是「四个 token 数相加」(百万级),
|
|
6123
|
+
本身比月度的单日 `total`(几百)大 ~400 倍,所以 1e6 算出来就是 1~5 元,
|
|
6124
|
+
已经是与 devDailyFixture 同量级。真正太小的是月度那条(见 devMonthFixture)。 */
|
|
6125
|
+
const monthCost = Math.round(total / 1e6 * 2.2 * 10000) / 10000;
|
|
6126
|
+
months.push({
|
|
6127
|
+
month: m, total: total, calls: devRandInt(next, 4, 120),
|
|
6128
|
+
cost: monthCost,
|
|
6129
|
+
/* 分币种:12 个月也轮换三种形态,各币种之和 === cost */
|
|
6130
|
+
costByCurrency: devCostSplit(monthCost, devCostShapeAt(m - 1)),
|
|
6131
|
+
input: input, cacheRead: cacheRead, cacheWrite: cacheWrite, output: output
|
|
6132
|
+
});
|
|
6133
|
+
if (total > max) {
|
|
6134
|
+
max = total;
|
|
6135
|
+
maxMonth = m;
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
return { year: year, max: max, maxMonth: maxMonth, months: months };
|
|
6139
|
+
}
|
|
6140
|
+
|
|
6141
|
+
/**
|
|
6142
|
+
* 活跃总览(峰谷气泡)假值:近 7 天 + 当月日历 + 12 个月方块,字段照 /api/daily-usage。
|
|
6143
|
+
*
|
|
6144
|
+
* **全部由 status 计数驱动**(用户要"点峰谷那一项只变峰谷"):
|
|
6145
|
+
* · 月份往前翻 `status` 个月(月热力图不再永远停在本月)
|
|
6146
|
+
* · 近 7 天的柱高用确定性伪随机生成,有高有低 —— 用户原话「随机呀,懂不懂什么叫随机?」,
|
|
6147
|
+
* 明确不要"每天比前一天多 1200"那种等差斜线
|
|
6148
|
+
* 同一 status 值 ⇒ 同一组数(同场景稳定,总计账本不会被带乱)。
|
|
6149
|
+
*/
|
|
6150
|
+
function devDailyFixture(now, statusCount) {
|
|
6151
|
+
const shift = typeof statusCount === "number" && Number.isFinite(statusCount) && statusCount > 0
|
|
6152
|
+
? Math.floor(statusCount)
|
|
6153
|
+
: 0;
|
|
6154
|
+
const today = new Date(now);
|
|
6155
|
+
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6).getTime();
|
|
6156
|
+
/* 种子里带 shift:每翻一次月/点一次 status,整组数都换一遍 */
|
|
6157
|
+
const next = devRandom(devSeedOf(13, shift));
|
|
6158
|
+
const days = [];
|
|
6159
|
+
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 };
|
|
6160
|
+
for (let i = 0; i < 7; i += 1) {
|
|
6161
|
+
const at = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6 + i).getTime();
|
|
6162
|
+
const weekday = (new Date(at).getDay() + 6) % 7;
|
|
6163
|
+
/* 周末低谷 + 每根柱子独立随机:相邻两天可能相差好几倍,也可能相近 —— 看不出规律 */
|
|
6164
|
+
const weekend = weekday >= 5 ? 0.3 : 1;
|
|
6165
|
+
const input = Math.round(devRandInt(next, 2000, 30000) * weekend);
|
|
6166
|
+
const cacheRead = Math.round(devRandInt(next, 30000, 900000) * weekend);
|
|
6167
|
+
const cacheWrite = Math.round(devRandInt(next, 0, 9000) * weekend);
|
|
6168
|
+
const output = Math.round(devRandInt(next, 4000, 120000) * weekend);
|
|
6169
|
+
const total = input + cacheRead + cacheWrite + output;
|
|
6170
|
+
const cost = Math.round(total / 1e6 * 2.2 * 10000) / 10000;
|
|
6171
|
+
const peakRatio = 0.35 + next() * 0.4;
|
|
6172
|
+
const peak = {
|
|
6173
|
+
input: Math.round(input * peakRatio), cacheRead: Math.round(cacheRead * peakRatio),
|
|
6174
|
+
cacheWrite: Math.round(cacheWrite * peakRatio), output: Math.round(output * peakRatio)
|
|
6175
|
+
};
|
|
6176
|
+
peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output;
|
|
6177
|
+
/* 分币种金额:三天形态轮换(混币 / 只有人民币 / 只有美元),
|
|
6178
|
+
与 `cost` 严格相等 —— 悬停逐天看就能看到 `¥x+$y`、`¥x`、`$y` 三种写法。 */
|
|
6179
|
+
const costByCurrency = devCostSplit(cost, devCostShapeAt(i));
|
|
6180
|
+
days.push({
|
|
6181
|
+
date: dateKeyOf(at), input: input, cacheRead: cacheRead, cacheWrite: cacheWrite,
|
|
6182
|
+
output: output, total: total, calls: Math.round(2 + next() * 14),
|
|
6183
|
+
peak: peak, cost: cost, costByCurrency: costByCurrency
|
|
6184
|
+
});
|
|
6185
|
+
totals.input += input;
|
|
6186
|
+
totals.cacheRead += cacheRead;
|
|
6187
|
+
totals.cacheWrite += cacheWrite;
|
|
6188
|
+
totals.output += output;
|
|
6189
|
+
totals.total += total;
|
|
6190
|
+
totals.calls += days[days.length - 1].calls;
|
|
6191
|
+
totals.cost += cost;
|
|
6192
|
+
}
|
|
6193
|
+
totals.cost = Math.round(totals.cost * 10000) / 10000;
|
|
6194
|
+
/* 周汇总也要带分币种(周条悬停读的是 totals):把 7 天各自那份相加,
|
|
6195
|
+
各币种之和因此**严格等于** totals.cost(不是另外造一个数)。 */
|
|
6196
|
+
const totalsByCurrency = {};
|
|
6197
|
+
days.forEach((one) => {
|
|
6198
|
+
const map = one.costByCurrency;
|
|
6199
|
+
if (map === null || map === undefined || typeof map !== "object") return;
|
|
6200
|
+
Object.keys(map).forEach((code) => {
|
|
6201
|
+
totalsByCurrency[code] = Math.round(((totalsByCurrency[code] || 0) + map[code]) * 10000) / 10000;
|
|
6202
|
+
});
|
|
6203
|
+
});
|
|
6204
|
+
totals.costByCurrency = totalsByCurrency;
|
|
6205
|
+
return {
|
|
6206
|
+
ok: true, at: now, rangeStart: start,
|
|
6207
|
+
days: days, totals: totals,
|
|
6208
|
+
month: devMonthFixture(now, shift), year: devYearFixture(now, shift)
|
|
6209
|
+
};
|
|
6210
|
+
}
|
|
6211
|
+
|
|
6212
|
+
/**
|
|
6213
|
+
* 队友(子智能体)用量假值:`session`(整会话,供「总计」)与 `turn`(本轮窗口,供「本轮」)
|
|
6214
|
+
* 两段都给 ok:true 且 cost > 0,界面上两侧都能看出「已经把队友并进来」。
|
|
6215
|
+
*
|
|
6216
|
+
* **刻意不随任何字段计数变化**(`seed` 只由 devRound 决定):队友那两段同时进「总计」
|
|
6217
|
+
* 与「本轮」,一旦跟着某字段变,点别的气泡时金额也会动 —— 那正是用户反对的串扰。
|
|
6218
|
+
* 字段照 /api/subagent-usage 的真实响应(total.cost / tokens / calls / stats / costByCurrency)。
|
|
6219
|
+
*/
|
|
6220
|
+
function devSubagentFixture(sessionId, seed) {
|
|
6221
|
+
const next = devRandom(devSeedOf(14, seed));
|
|
6222
|
+
const pick = (min, max) => devRandInt(next, min, max);
|
|
6223
|
+
const tokensOf = (input, cacheRead, cacheWrite, output) => ({
|
|
6224
|
+
input: input, cacheRead: cacheRead, cacheWrite: cacheWrite, output: output
|
|
6225
|
+
});
|
|
6226
|
+
const sessionCost = Math.round((0.4 + next() * 6) * 100) / 100;
|
|
6227
|
+
const turnCost = Math.round((0.1 + next() * 1.6) * 100) / 100;
|
|
6228
|
+
const sessionTokens = tokensOf(pick(8000, 40000), pick(40000, 220000), pick(0, 3000), pick(12000, 70000));
|
|
6229
|
+
const turnTokens = tokensOf(pick(1000, 8000), pick(6000, 40000), 0, pick(2000, 16000));
|
|
6230
|
+
return {
|
|
6231
|
+
session: {
|
|
6232
|
+
ok: true, sessionId: sessionId, sinceSource: "client", running: 0,
|
|
6233
|
+
children: [{ id: "dev-subagent-1", cost: sessionCost, tokens: sessionTokens, calls: pick(3, 20) }],
|
|
6234
|
+
total: {
|
|
6235
|
+
cost: sessionCost, costByCurrency: { CNY: sessionCost }, tokens: sessionTokens,
|
|
6236
|
+
calls: pick(3, 20),
|
|
6237
|
+
stats: {
|
|
6238
|
+
ttftMs: pick(2000, 12000), ttftSteps: pick(1, 4),
|
|
6239
|
+
decodeMs: pick(8000, 60000), decodeTokens: pick(2000, 12000)
|
|
6240
|
+
}
|
|
6241
|
+
}
|
|
6242
|
+
},
|
|
6243
|
+
turn: {
|
|
6244
|
+
ok: true, sessionId: sessionId, sinceSource: "client", running: 0,
|
|
6245
|
+
children: [{ id: "dev-subagent-1", cost: turnCost, tokens: turnTokens, calls: pick(1, 5) }],
|
|
6246
|
+
total: {
|
|
6247
|
+
cost: turnCost, costByCurrency: { CNY: turnCost }, tokens: turnTokens, calls: pick(1, 5),
|
|
6248
|
+
stats: { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
6249
|
+
}
|
|
6250
|
+
}
|
|
6251
|
+
};
|
|
6252
|
+
}
|
|
6253
|
+
|
|
6254
|
+
|
|
6255
|
+
/* ----------------------- 每个数据槽听哪个字段计数(点哪项只变哪项的核心)
|
|
6256
|
+
*
|
|
6257
|
+
* 归属取舍(写在 devFixture 里,没单列成映射表 —— 那张表自从写好后一次都没被读过,
|
|
6258
|
+
* 各槽位全是硬编码的,留着只会让人以为改表就能改行为):
|
|
6259
|
+
* · `stats.decodeMs` / `decodeTokens` —— 归 **tps**。它们同时被「综合速度」
|
|
6260
|
+
* (decodeTokens/decodeMs)与「总用时」气泡的"模型用时"读,二选一;
|
|
6261
|
+
* 归 tps 是因为那两项是速度的直接分子分母,归 sessionTime 会让"点总用时"时
|
|
6262
|
+
* 速度也跟着变(更违反"点哪项只变哪项")。
|
|
6263
|
+
* 代价:点 tps 时总用时气泡里的"模型用时"一行也会变 —— 但底栏那个总时长不变,
|
|
6264
|
+
* 属于可接受的最小串扰。
|
|
6265
|
+
* · `sessionUsage.current`(本轮)—— 归 **lastCost**。它同时被「本轮费用」与
|
|
6266
|
+
* 「本轮命中率」读;本轮费用是那个字段的主数字,命中率另有 usage 那条路。
|
|
6267
|
+
* · `sessionUsage.turn` —— 归 **lastCost**。它一变「本轮」的轮标识就变,
|
|
6268
|
+
* 同轮粘性下限自动作废、本轮金额因此能真的跟着换,而不是被旧下限锁住。
|
|
6269
|
+
* · `usage.*`(官方 tokenUsage)—— 归 **cacheHit**。综合命中率的分母就是它;
|
|
6270
|
+
* 它同时也进「总计」气泡的 token 明细(底栏金额不受影响),属于可接受串扰。
|
|
6271
|
+
* · 表里没提到的槽位(例如 subagent、sessionModel)**永远不变** ——
|
|
6272
|
+
* 这样点任何一个气泡都不会顺手改到别人的数据。
|
|
6273
|
+
*/
|
|
6274
|
+
|
|
6275
|
+
/**
|
|
6276
|
+
* 生成整套假投影。
|
|
6277
|
+
*
|
|
6278
|
+
* 入参 `fieldScenes` 是**每字段独立计数**(见 DEV_FIELD_KEYS):
|
|
6279
|
+
* { status:0, counts:2, cacheHit:0, tps:0, ttft:0, sessionTime:0,
|
|
6280
|
+
* lastCost:0, cost:1, balance:3, context:0 }
|
|
6281
|
+
* 每个槽位只读自己那个字段的计数,于是:
|
|
6282
|
+
* **点开哪个气泡,就只有那个气泡的数据变**。
|
|
6283
|
+
*
|
|
6284
|
+
* 完全不传时所有计数为 0,且 balance / toolCounts 仍按 round 取模 ——
|
|
6285
|
+
* 这样 `devFixture(round, t)` 这个纯函数契约与 tests/verify-dev-fixtures.js 的
|
|
6286
|
+
* 6 轮余额 / 4 轮工具断言原样成立。
|
|
6287
|
+
*
|
|
6288
|
+
* 一级字段(与 StatusBar 读投影的名字一一对应,注入时按这些键覆盖):
|
|
6289
|
+
* balance / stats / usage / sessionUsage / timing / pressure / breakdown /
|
|
6290
|
+
* capacity / progress / timeRange / daily / subagent / sessionModel
|
|
6291
|
+
*/
|
|
6292
|
+
function devFixture(round, t, fieldScenes) {
|
|
6293
|
+
const value = typeof round === "number" && Number.isFinite(round) ? Math.max(1, Math.floor(round)) : 1;
|
|
6294
|
+
/* 归一化第三个参数:只接受对象(调用方一律传对象或 undefined) */
|
|
6295
|
+
const scenes = fieldScenes !== null && fieldScenes !== undefined && typeof fieldScenes === "object"
|
|
6296
|
+
? fieldScenes
|
|
6297
|
+
: null;
|
|
6298
|
+
const countOf = (key) => {
|
|
6299
|
+
if (scenes === null) return 0;
|
|
6300
|
+
const raw = scenes[key];
|
|
6301
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
6302
|
+
};
|
|
6303
|
+
|
|
6304
|
+
const balanceCount = countOf("balance");
|
|
6305
|
+
const statusCount = countOf("status");
|
|
6306
|
+
const countsCount = countOf("counts");
|
|
6307
|
+
const ttftCount = countOf("ttft");
|
|
6308
|
+
const tpsCount = countOf("tps");
|
|
6309
|
+
const cacheHitCount = countOf("cacheHit");
|
|
6310
|
+
const sessionTimeCount = countOf("sessionTime");
|
|
6311
|
+
const costCount = countOf("cost");
|
|
6312
|
+
const lastCostCount = countOf("lastCost");
|
|
6313
|
+
const contextCount = countOf("context");
|
|
6314
|
+
|
|
6315
|
+
const at = DEV_FIXTURE_ANCHOR;
|
|
6316
|
+
|
|
6317
|
+
/* ---------- 余额:只听 balance ---------- */
|
|
6318
|
+
/* 没传场景时按 round 取模(旧契约,测试的 6 轮矩阵契约不变);
|
|
6319
|
+
传了就从第 1 种起、每点一次前进一种。
|
|
6320
|
+
**点击时是 11 格**(见 DEV_BALANCE_CLICK_SCENES):1~6 是最初的 6 种真实形态
|
|
6321
|
+
(金额 / 百分比配额 / 积分 / USD / 积分制 / 不足一分钱),7~11 是五种分币种形态
|
|
6322
|
+
(双币种 / 只有美元 / 某项为 0 / 两币种都有赠金 / 只有美元带赠金)。**不再有「完全查不到」那一格**
|
|
6323
|
+
(ok:false ⇒ 两行横杠)—— 用户已明确不要演示横杠。
|
|
6324
|
+
dashBalance 兜底路径保留,真实环境照样会走。
|
|
6325
|
+
(原先这里还有一个 balanceClickScene 变量,取值后从未被读,已删。) */
|
|
6326
|
+
/* 两条路用**不同的格数**,别混:
|
|
6327
|
+
· `scenes === null`(不传字段计数,测试用的旧契约)⇒ 按老契约 `DEV_BALANCE_ROUNDS`(6 轮)
|
|
6328
|
+
取模,tests 的 6 轮矩阵断言钉着它;
|
|
6329
|
+
· 点余额气泡那一路 ⇒ 走 `DEV_BALANCE_CLICK_SCENES`(11 格),每点一次换一格。
|
|
6330
|
+
**两者故意不同**:老契约(6 轮)只服务于 `scenes === null` 那条路径,
|
|
6331
|
+
点击路径按 11 格走,1~6 的原形态和 7~11 的分币种形态才都能点到。 */
|
|
6332
|
+
const balanceRound = scenes === null
|
|
6333
|
+
? ((value - 1) % DEV_BALANCE_ROUNDS) + 1
|
|
6334
|
+
: (balanceCount % DEV_BALANCE_CLICK_SCENES) + 1;
|
|
6335
|
+
/* ---------- 轮次/步数/工具形态:只听 counts ---------- */
|
|
6336
|
+
/* counts 5 格,**全部有数据**(横杠那格已按用户要求删掉):
|
|
6337
|
+
0 正常 / 1 **技能 30 条**(多列 + 逐档加行)/ 2 压缩 20 条(列表滚动)/
|
|
6338
|
+
3 工具 0 条(子视图里一行占位,但**底栏本身仍有轮次步数**,不是横杠)/
|
|
6339
|
+
4 技能 6 条且同时带 * 与 + 两种角标。
|
|
6340
|
+
工具形态本身有 5 档(DEV_BUBBLE_SCENES),与上面不是一一对应,仍按 counts 取模轮换。 */
|
|
6341
|
+
const countsScene = countsCount % DEV_COUNTS_SCENES;
|
|
6342
|
+
const toolRound = scenes === null
|
|
6343
|
+
? ((value - 1) % DEV_TOOL_ROUNDS) + 1
|
|
6344
|
+
: (countsCount % DEV_BUBBLE_SCENES) + 1;
|
|
6345
|
+
const toolCounts = countsScene === 3 ? {} : devToolCounts(toolRound);
|
|
6346
|
+
let toolCalls = 0;
|
|
6347
|
+
Object.keys(toolCounts).forEach((name) => { toolCalls += toolCounts[name]; });
|
|
6348
|
+
|
|
6349
|
+
/* counts 这一路的伪随机:轮次、步数、技能条数都从这里出。
|
|
6350
|
+
**压缩不再单独随机条数** —— 见下面 compacts 生成处的说明。 */
|
|
6351
|
+
const countsNext = devRandom(devSeedOf(2, countsCount));
|
|
6352
|
+
/* 轮次与步数**永远有值**:横杠那一格删掉之后,`segmentView` 的 `!(stats.steps > 0)`
|
|
6353
|
+
分支在开发模式里不再被触发(它作为兜底仍然留着)。 */
|
|
6354
|
+
const turns = devRandInt(countsNext, 3, 48);
|
|
6355
|
+
const steps = devRandInt(countsNext, turns + 2, turns * 9 + 12);
|
|
6356
|
+
const turnNo = devRandInt(countsNext, 1, turns);
|
|
6357
|
+
const turnSteps = devRandInt(countsNext, 1, 9);
|
|
6358
|
+
/* 第 1 格 30 条技能(多列 + 逐档加行)、第 4 格 6 条但两种角标都在 */
|
|
6359
|
+
const skillCount = countsScene === 1 ? DEV_SKILL_NAMES.length
|
|
6360
|
+
: (countsScene === 4 ? 6 : devRandInt(countsNext, 2, 5));
|
|
6361
|
+
/* 第 0 格:压缩 20 条 —— 超过列表的半屏高度上限,点进去要能滚 */
|
|
6362
|
+
const compactTotal = countsScene === 0 ? 20 : devRandInt(countsNext, 2, 4);
|
|
6363
|
+
|
|
6364
|
+
/* ---------- 首字:只听 ttft ---------- */
|
|
6365
|
+
/* 2 格,都有数据:0 正常 / 1 极端慢(2 次共 68000ms ⇒ 平均 34s,单次 30 秒以上) */
|
|
6366
|
+
const ttftScene = ttftCount % DEV_TTFT_SCENES;
|
|
6367
|
+
const ttftNext = devRandom(devSeedOf(3, ttftCount));
|
|
6368
|
+
const ttftSteps = ttftScene === 1 ? 2 : devRandInt(ttftNext, 2, 30);
|
|
6369
|
+
const ttftMs = ttftScene === 1 ? 68000 : devRandInt(ttftNext, ttftSteps * 300, ttftSteps * 9000);
|
|
6370
|
+
const turnTtftMs = devRandInt(ttftNext, 400, 14000);
|
|
6371
|
+
const fastestTtft = devRandInt(ttftNext, 300, 1600);
|
|
6372
|
+
const slowestTtft = ttftScene === 1 ? 34000 : devRandInt(ttftNext, 2500, 42000);
|
|
6373
|
+
|
|
6374
|
+
/* ---------- 速度:只听 tps ---------- */
|
|
6375
|
+
/* 2 格,都有数据:0 正常 / 1 个位数速度(1200 tokens / 200s = 6 t/s) */
|
|
6376
|
+
const tpsScene = tpsCount % DEV_TPS_SCENES;
|
|
6377
|
+
const tpsNext = devRandom(devSeedOf(4, tpsCount));
|
|
6378
|
+
const decodeMs = tpsScene === 1 ? 200000 : devRandInt(tpsNext, 20000, 2100000);
|
|
6379
|
+
const decodeTokens = tpsScene === 1 ? 1200 : devRandInt(tpsNext, 5000, 520000);
|
|
6380
|
+
const fastestTps = tpsScene === 1 ? 9 : devRandInt(tpsNext, 60, 180);
|
|
6381
|
+
const slowestTps = tpsScene === 1 ? 6 : devRandInt(tpsNext, 3, 40);
|
|
6382
|
+
const turnDecodeMs = devRandInt(tpsNext, 300, 30000);
|
|
6383
|
+
const turnDecodeTokens = devRandInt(tpsNext, 20, 3600);
|
|
6384
|
+
|
|
6385
|
+
/* ---------- 命中率:只听 cacheHit ---------- */
|
|
6386
|
+
/* 3 格,都有数据:0 正常 / 1 顶到 99.99% 封顶 / 2 一次都没命中(0%) */
|
|
6387
|
+
const hitScene = cacheHitCount % DEV_CACHEHIT_SCENES;
|
|
6388
|
+
const hitNext = devRandom(devSeedOf(5, cacheHitCount));
|
|
6389
|
+
let usage;
|
|
6390
|
+
if (hitScene === 1) {
|
|
6391
|
+
/* 未命中只留 1 个 token:比例 99.99999…%,被 Math.min(99.99, …) 压到封顶值 */
|
|
6392
|
+
usage = { uncachedInputTokens: 1, cacheReadTokens: 8000000, cacheWriteTokens: 0, outputTokens: 120000 };
|
|
6393
|
+
} else if (hitScene === 2) {
|
|
6394
|
+
/* 命中为 0:分母全落在未命中那一侧,比例正好 0% */
|
|
6395
|
+
usage = { uncachedInputTokens: 900000, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 120000 };
|
|
6396
|
+
} else {
|
|
6397
|
+
usage = {
|
|
6398
|
+
uncachedInputTokens: devRandInt(hitNext, 20000, 1800000),
|
|
6399
|
+
cacheReadTokens: devRandInt(hitNext, 200000, 26000000),
|
|
6400
|
+
cacheWriteTokens: devRandInt(hitNext, 0, 460000),
|
|
6401
|
+
outputTokens: devRandInt(hitNext, 30000, 2400000)
|
|
6402
|
+
};
|
|
6403
|
+
}
|
|
6404
|
+
|
|
6405
|
+
/* ---------- 总用时:只听 sessionTime ---------- */
|
|
6406
|
+
/* 3 格,都有数据:0 分钟级 / 1 超过 1 小时(4500000ms = 1h15m)/ 2 不足一分钟(42000ms = 42s) */
|
|
6407
|
+
const timeScene = sessionTimeCount % DEV_SESSIONTIME_SCENES;
|
|
6408
|
+
const timeNext = devRandom(devSeedOf(6, sessionTimeCount));
|
|
6409
|
+
const llmMs = timeScene === 1 ? 3000000 : devRandInt(timeNext, 60000, 2400000);
|
|
6410
|
+
const toolMs = timeScene === 1 ? 900000 : devRandInt(timeNext, 12000, 900000);
|
|
6411
|
+
const durationMs = timeScene === 1 ? 4500000
|
|
6412
|
+
: (timeScene === 2 ? 42000 : llmMs + toolMs + devRandInt(timeNext, 30000, 2600000));
|
|
6413
|
+
|
|
6414
|
+
/* ---------- 上下文占用:只听 context ---------- */
|
|
6415
|
+
/* 点一次 context 前进一格:第 0 格是进开发模式立刻看到的常规态,1~5 格是各极端。
|
|
6416
|
+
占用百分比、缺口算不算得出、有没有窗口,**三样都从同一张表出** ——
|
|
6417
|
+
各自随机的话很容易撞出「0% 却算得出缺口」「缺窗口却画着占用」这种自相矛盾的组合,
|
|
6418
|
+
而开发模式的意义恰恰是每种形态都能被如实地看一眼。 */
|
|
6419
|
+
const ctxScene = DEV_CONTEXT_SCENARIO[contextCount % DEV_CONTEXT_SCENES];
|
|
6420
|
+
const ctxNext = devRandom(devSeedOf(9, contextCount));
|
|
6421
|
+
/* 占用率直接从 percent 反算成 token 数(0% 就是真的一个 token 都没占)。
|
|
6422
|
+
这里**不再判「有没有窗口」**:场景表 7 格全都带 percent,而「缺 contextWindow ⇒
|
|
6423
|
+
dashMeter 空环 + 横杠」那一格已按用户要求删掉 —— 留着那个判断只会恒为 true,
|
|
6424
|
+
属于死分支(审查报告 2-3 条)。dashMeter 本身作为兜底仍留在 meterView 里。 */
|
|
6425
|
+
const projectedTokens = Math.round(DEV_CONTEXT_WINDOW * ctxScene.percent / 100);
|
|
6426
|
+
/* 分项三段之和必须等于总占用:小占用时先给每段封顶(总占用的三分之一),
|
|
6427
|
+
否则 0% 那格会算出负数消息段,圆环与分项气泡立刻自相矛盾。 */
|
|
6428
|
+
const segmentCap = Math.floor(projectedTokens / 3);
|
|
6429
|
+
const systemTokens = projectedTokens === 0 ? 0 : Math.min(devRandInt(ctxNext, 1200, 4200), segmentCap);
|
|
6430
|
+
const toolsTokens = projectedTokens === 0 ? 0 : Math.min(devRandInt(ctxNext, 4500, 22000), segmentCap);
|
|
6431
|
+
const messageTokens = Math.max(0, projectedTokens - systemTokens - toolsTokens);
|
|
6432
|
+
|
|
6433
|
+
/* ---------- 总计:只听 cost;本轮:只听 lastCost ---------- */
|
|
6434
|
+
/* lastCost 3 格,**三格都有金额**(横杠那格已删):
|
|
6435
|
+
0 有调用(正常) / 1 正在跑但本轮还没有调用(active 分支,显示 ¥0.00)/
|
|
6436
|
+
2 空闲、显示最近一轮。
|
|
6437
|
+
`turnKey` 跟着 lastCost 计数走:否则「本轮只增不减」的下限会把上一格的大数字
|
|
6438
|
+
顶住,¥0.00 那条分支永远出不来(下限的作废条件就是换轮)。
|
|
6439
|
+
第 1 格要连 sessionUsage.current.calls 一起清空 —— 留着一条就一定能算出金额,
|
|
6440
|
+
¥0.00 那条分支永远到不了(见 devSessionUsageFixture 的 turnEmpty)。 */
|
|
6441
|
+
const lastCostScene = lastCostCount % DEV_LASTCOST_SCENES;
|
|
6442
|
+
const devRunning = lastCostScene === 1;
|
|
6443
|
+
const devTurnKey = "dev-turn-" + String(lastCostScene);
|
|
6444
|
+
/* 第 1 格「在跑但还没调用」要求本轮用量为空 */
|
|
6445
|
+
const devTurnEmpty = lastCostScene === 1;
|
|
6446
|
+
|
|
6447
|
+
/* cost 3 格,**三格都有金额显示**(横杠那格已删):
|
|
6448
|
+
0 正常(队友也并入)/ 1 队友不并入(看总计只算本会话)/ 2 金额暴涨(长会话极端)。
|
|
6449
|
+
「算不出价」那条兜底(held.cost === null ⇒ ¥-)仍然留着,只是不再演示。 */
|
|
6450
|
+
const costScene = costCount % DEV_COST_SCENES;
|
|
6451
|
+
const subSessionZeroed = costScene === 1;
|
|
6452
|
+
const sessionUsage = devSessionUsageFixture(costCount, lastCostCount, turnNo, devTurnEmpty);
|
|
6453
|
+
|
|
6454
|
+
/* ---------- 活跃总览:只听 status ---------- */
|
|
6455
|
+
/* status 2 格,**都有数据**("整块查不到"那两格已删):
|
|
6456
|
+
0 低谷 + 数据齐全 / 1 高峰 + 数据齐全。 */
|
|
6457
|
+
const statusScene = statusCount % DEV_STATUS_SCENES;
|
|
6458
|
+
const peakNow = statusScene === 1;
|
|
6459
|
+
/* 假「现在」:月历、年条、峰谷文字、总用时 elapsed **必须同源**。
|
|
6460
|
+
用真实 `Date.now()`(at)去生成月历、却用假 now 去算「今天」的话,两边会跨月:
|
|
6461
|
+
月历落在真实当月、todayKey 落在假 now 那个月,于是 `day.date > todayKey`
|
|
6462
|
+
永远不成立 —— 月热力图与年条那两个"未来日"灰阶在开发模式里一天都看不到。
|
|
6463
|
+
开发模式的意义就是每种形态都能被看一眼,所以统一取 devNowAt(同一个峰谷档)。 */
|
|
6464
|
+
const dailyNow = devNowAt(peakNow);
|
|
6465
|
+
const daily = devDailyFixture(dailyNow, statusCount);
|
|
6466
|
+
|
|
6467
|
+
/* 轮次项气泡那三行:压缩固定两条(自动 + 手动,展示契约),技能条数听 counts */
|
|
6468
|
+
const skillPool = [
|
|
6469
|
+
{ name: "i-have-adhd", manual: true },
|
|
6470
|
+
{ name: "cangjie-skill", manual: false },
|
|
6471
|
+
{ name: "ponytail", manual: false },
|
|
6472
|
+
{ name: "deepseek-harness", manual: false },
|
|
6473
|
+
{ name: "sub-capability-demo", manual: false, sub: true }
|
|
6474
|
+
];
|
|
6475
|
+
/* 技能列表:**第 1 格 30 条**(触发多列 + 逐档加行)、**第 4 格 6 条**且两种角标都在,
|
|
6476
|
+
其余格沿用原来那 2~5 条的小池子。
|
|
6477
|
+
这里必须取 DEV_SKILL_NAMES(30 条名字池)而不是下面那个只有 5 条的 skillPool:
|
|
6478
|
+
5 条无论怎么 slice 都凑不出 30 条 —— 那正是旧代码「30 条那格永远出不来」的根因
|
|
6479
|
+
(旧条件是 `=== 3 || === 5`,而 countsScene 只可能是 0~4,5 永不成立,
|
|
6480
|
+
而 count 为 30 的第 1 格又落到了 `skillPool.slice(0, 30)`,被截成 5 条)。
|
|
6481
|
+
变量名不能叫 skillList —— 那是同名的列表构造函数,遮蔽了会静默改掉语义。 */
|
|
6482
|
+
const devSkills = countsScene === 1 || countsScene === 4
|
|
6483
|
+
? DEV_SKILL_NAMES.slice(0, skillCount).map((name, index) => ({
|
|
6484
|
+
name: name,
|
|
6485
|
+
/* 第 4 格:隔三个标一种来源,`名字*` 与 `名字+` 都能看到 */
|
|
6486
|
+
manual: countsScene === 4 ? index % 3 === 0 : index % 5 === 1,
|
|
6487
|
+
sub: countsScene === 4 ? index % 3 === 1 : index % 5 === 3
|
|
6488
|
+
}))
|
|
6489
|
+
: skillPool.slice(0, skillCount);
|
|
6490
|
+
|
|
6491
|
+
/* 队友两段(几处刻意归零,各自只服务一个形态,都不动别人的那份):
|
|
6492
|
+
· cost 第 1 格「一条都算不出价」—— 队友金额一进账本就会把 priced 置 true,
|
|
6493
|
+
不归零就永远走不到横杠;第 2 格「队友不并入」同样清零。
|
|
6494
|
+
· lastCost 第 1 格「正在跑但本轮没调用」要 ¥0.00、第 3 格要横杠 ——
|
|
6495
|
+
两者都要求本轮队友金额为 0,否则活分支会直接把队友那笔显示出来。
|
|
6496
|
+
· ttft / tps / cacheHit 的「没数据」与「极端值」格:这三个段都是
|
|
6497
|
+
**本会话 + 队友**合并后再算的(mergedSessionStats / billedInputTokens + sub),
|
|
6498
|
+
本会话那一半清零了,队友那份仍会把结果抬起走 —— 横杠与个位数速度因此永远出不来。
|
|
6499
|
+
所以按字段只清**它自己依赖的那几项**(ttft 组 / decode 组 / tokens 组互不重叠),
|
|
6500
|
+
避免"点首字却把速度也改了"这种串扰。 */
|
|
6501
|
+
const subagent = devSubagentFixture("dev-" + String(value), devSeedOf(10));
|
|
6502
|
+
const zeroCostOf = (slice) => {
|
|
6503
|
+
slice.total.cost = 0;
|
|
6504
|
+
slice.total.costByCurrency = { CNY: 0 };
|
|
6505
|
+
slice.children.forEach((one) => { one.cost = 0; });
|
|
6506
|
+
};
|
|
6507
|
+
if (subSessionZeroed === true) zeroCostOf(subagent.session);
|
|
6508
|
+
/* 只清「在跑但还没调用」那一格(lastCost 第 1 格):要把 ¥0.00 显示出来,
|
|
6509
|
+
本轮队友那份金额必须是 0,否则活分支会直接把队友那笔显示成金额。
|
|
6510
|
+
(原来还有第 3 格「算不出价 ⇒ 横杠」,那格已按用户要求删掉。) */
|
|
6511
|
+
if (lastCostScene === 1) zeroCostOf(subagent.turn);
|
|
6512
|
+
/* 队友的 stats 两段都在 session 那份里(subagentStatsOf 只读 session.total.stats) */
|
|
6513
|
+
const subStats = subagent.session.total.stats;
|
|
6514
|
+
if (ttftScene !== 0) {
|
|
6515
|
+
subStats.ttftMs = 0;
|
|
6516
|
+
subStats.ttftSteps = 0;
|
|
6517
|
+
}
|
|
6518
|
+
if (tpsScene !== 0) {
|
|
6519
|
+
subStats.decodeMs = 0;
|
|
6520
|
+
subStats.decodeTokens = 0;
|
|
6521
|
+
}
|
|
6522
|
+
/* 命中率:两个极端格都要把队友那份也清掉,否则合并后的比例永远到不了 0 / 99.99:
|
|
6523
|
+
第 1 格要封顶(队友输入会把比例拉回常态)、第 2 格要命中为 0
|
|
6524
|
+
(队友的 cacheRead 会把它抬起来)。 */
|
|
6525
|
+
if (hitScene === 1 || hitScene === 2) {
|
|
6526
|
+
subagent.session.total.tokens.cacheRead = 0;
|
|
6527
|
+
if (hitScene === 1) {
|
|
6528
|
+
/* 封顶那一格必须一个未命中 token 都不留 —— 分母只靠本会话那 1 个 */
|
|
6529
|
+
subagent.session.total.tokens.input = 0;
|
|
6530
|
+
subagent.session.total.tokens.cacheWrite = 0;
|
|
6531
|
+
}
|
|
6532
|
+
}
|
|
6533
|
+
|
|
6534
|
+
return {
|
|
6535
|
+
/* 余额:6 种形态轮换(**不再有 ok:false 那格**,横杠不演示)。
|
|
6536
|
+
dashBalance 兜底仍在 client.js 里,真实环境查不到余额时照旧用。 */
|
|
6537
|
+
balance: devBalanceFixture(balanceRound),
|
|
6538
|
+
/* 官方 sessionStats:底栏的轮次/步数、首字平均、综合速度、总用时都读它 */
|
|
6539
|
+
stats: {
|
|
6540
|
+
turns: turns, steps: steps,
|
|
6541
|
+
ttftMs: ttftMs, ttftSteps: ttftSteps,
|
|
6542
|
+
decodeMs: decodeMs, decodeTokens: decodeTokens,
|
|
6543
|
+
toolMs: toolMs, llmMs: llmMs
|
|
6544
|
+
},
|
|
6545
|
+
/* 官方 tokenUsage:综合命中率的分母 */
|
|
6546
|
+
usage: usage,
|
|
6547
|
+
sessionUsage: sessionUsage,
|
|
6548
|
+
timing: {
|
|
6549
|
+
samples: ttftSteps,
|
|
6550
|
+
fastestTtft: { value: fastestTtft, at: at - 1200000, model: "deepseek-flash" },
|
|
6551
|
+
slowestTtft: { value: slowestTtft, at: at - 240000, model: "deepseek-flash" },
|
|
6552
|
+
fastestTps: { value: fastestTps, at: at - 1200000, model: "deepseek-flash" },
|
|
6553
|
+
slowestTps: { value: slowestTps, at: at - 240000, model: "deepseek-flash" },
|
|
6554
|
+
/* 本轮轮号跟着 lastCost 走:它一变「本轮」的粘性下限就作废 */
|
|
6555
|
+
turn: sessionUsage.turn,
|
|
6556
|
+
turnTtftMs: turnTtftMs,
|
|
6557
|
+
turnTtftSamples: 1,
|
|
6558
|
+
turnDecodeMs: turnDecodeMs,
|
|
6559
|
+
turnDecodeTokens: turnDecodeTokens
|
|
6560
|
+
},
|
|
6561
|
+
/* 上下文占用:官方 contextPressure / contextBreakdown + 本插件折的 capacity。
|
|
6562
|
+
capacity 照 host 的 sessionCapacitySchema(在 lib/index.js,就是 .strict() 那段)给全三个字段 ——
|
|
6563
|
+
少一个 updatedAt 的话真实投影过不了校验,假数据也要同一套形状。
|
|
6564
|
+
这里**只保留「有窗口」这一种形态**:`contextOccupancy` 见到窗口缺失就返回 null、
|
|
6565
|
+
圆环退回空环 + 横杠,而那个形态用户明确不要(「都没数据,我看得出来什么?」)。
|
|
6566
|
+
兜底代码仍在 meterView / dashMeter 里(真实环境会话刚发出时照样会走),
|
|
6567
|
+
只是开发模式不再主动演示它。 */
|
|
6568
|
+
pressure: { projectedTokens: projectedTokens, pressureTokens: projectedTokens, contextWindow: DEV_CONTEXT_WINDOW },
|
|
6569
|
+
breakdown: { systemTokens: systemTokens, toolsTokens: toolsTokens, messageTokens: messageTokens },
|
|
6570
|
+
/* 缺 maxOutputTokens ⇒ compactPercent 返回 null ⇒ 圆环不挖缺口、横条不压竖线。
|
|
6571
|
+
其余格给齐三字段,缺口就能算出来(1M 窗口 / 384K 输出 → 55.0%,与实机一致)。 */
|
|
6572
|
+
capacity: ctxScene.notchKnown === true
|
|
6573
|
+
? { contextWindow: DEV_CONTEXT_WINDOW, maxOutputTokens: DEV_CONTEXT_MAX_OUTPUT, updatedAt: null }
|
|
6574
|
+
: { contextWindow: DEV_CONTEXT_WINDOW, updatedAt: null },
|
|
6575
|
+
/* 轮次项气泡那三行:压缩 / 技能注入 / 工具调用。
|
|
6576
|
+
压缩的**条数与列表必须是同一个数据源**:用户实机看到「上下文压缩 4」点进去只有 2 条,
|
|
6577
|
+
因为条数是独立随机的、列表是写死两条。现在先按 compactTotal 生成数组,
|
|
6578
|
+
再让 `compactCount` 直接取数组长度 —— 数字与点开的条数**不可能**再对不上。
|
|
6579
|
+
展示契约(必须同时存在,不能因为"随机"被切掉):
|
|
6580
|
+
· 至少一条自动压缩(带轮号 turn/step,manual:false)
|
|
6581
|
+
· 至少一条手动压缩(轮间压缩,turn:null + afterTurn,manual:true)
|
|
6582
|
+
所以 compactTotal 下限是 2;多出来的按序补自动压缩,轮号往前推。 */
|
|
6583
|
+
progress: (() => {
|
|
6584
|
+
const compacts = [];
|
|
6585
|
+
const total = Math.max(2, compactTotal);
|
|
6586
|
+
for (let i = 0; i < total; i += 1) {
|
|
6587
|
+
if (i === total - 1) {
|
|
6588
|
+
/* 最后一条固定是「手动 / 轮间压缩」:turn=null,落点靠 afterTurn */
|
|
6589
|
+
compacts.push({ turn: null, step: null, afterTurn: Math.max(1, turnNo - 1), manual: true });
|
|
6590
|
+
} else {
|
|
6591
|
+
/* 其余是自动压缩:轮号从前往后推,步号取本轮步数 */
|
|
6592
|
+
compacts.push({
|
|
6593
|
+
turn: Math.max(1, turnNo - (total - 1 - i)),
|
|
6594
|
+
step: turnSteps,
|
|
6595
|
+
afterTurn: null,
|
|
6596
|
+
manual: false
|
|
6597
|
+
});
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
return {
|
|
6601
|
+
turn: turnNo, turnSteps: turnSteps,
|
|
6602
|
+
toolCalls: toolCalls, toolCounts: toolCounts,
|
|
6603
|
+
skills: devSkills,
|
|
6604
|
+
globalInstruction: "AGENTS.md",
|
|
6605
|
+
/* **直接取数组长度**:一个数据源,行数与点开后的条数永远一致 */
|
|
6606
|
+
compactCount: compacts.length,
|
|
6607
|
+
compactStep: compacts[compacts.length - 1].step,
|
|
6608
|
+
compacts: compacts
|
|
6609
|
+
};
|
|
6610
|
+
})(),
|
|
6611
|
+
/* 总用时(desktopStatusbarActiveTime):空闲态,只有累计值、没有进行中的基线。
|
|
6612
|
+
第 1 格 durationMs 为 0 ⇒ segmentView 先看时间窗(0 不算),
|
|
6613
|
+
再退回 timeline;那条路也要一起断(见 devSrc.timeline),否则横杠出不来。 */
|
|
6614
|
+
timeRange: { turns: durationMs, since: null, steps: 0, stepSince: null },
|
|
6615
|
+
daily: daily,
|
|
6616
|
+
/* 队友那两段**不随任何字段变**:它同时进「总计」与「本轮」,一旦跟着变就会
|
|
6617
|
+
让点别的气泡时金额也动 —— 那正是用户反对的串扰。
|
|
6618
|
+
(cost 第 1/2 格与 lastCost 第 1/3 格例外,各自只清金额、不动令牌,见上) */
|
|
6619
|
+
subagent: subagent,
|
|
6620
|
+
sessionModel: { provider: "deepseek-official", model: "deepseek-flash", updatedAt: at },
|
|
6621
|
+
/* 圆环状态色:**只听 context 计数**,与占用百分比同表同格。
|
|
6622
|
+
为什么必须由 fixture 给:真实判定读的是 running / pendingApprovals /
|
|
6623
|
+
lastAgentError / 分层在跑,这几样在开发模式里仍是真实值、不会因为点圆环而变 ——
|
|
6624
|
+
于是各档状态色在开发模式里永远只显示当前那一种,用户看不到其余的。
|
|
6625
|
+
取值域就是 contextRing 认得的那六个字符串;给别的值只会退回默认灰。 */
|
|
6626
|
+
agentState: ctxScene.state,
|
|
6627
|
+
/* **不是投影,是 StatusBar 那几个 state 的开发模式替代值**,所以单独收在一个键里,
|
|
6628
|
+
不跟上面那些"与投影同名"的字段混在一起(混了以后没人分得清哪个能给 projected() 用)。
|
|
6629
|
+
每一项的用途见 StatusBar 里 srcNow / srcTimeline / … 那几行。 */
|
|
6630
|
+
devSrc: {
|
|
6631
|
+
/* 分层在跑的**开发模式替代值**:让蓝档 / 紫档连弧长都能被点到。
|
|
6632
|
+
`ringPercentOf` 在 agentState 是 teamRunning / deepRunning 时会优先读它,
|
|
6633
|
+
所以那两格必须带上对应百分比(见 DEV_CONTEXT_SCENARIO 最后两格)。
|
|
6634
|
+
其余格给全 0 / null ⇒ ringPercentOf 原样退回 Lead 自己的占用,与真实环境一致。
|
|
6635
|
+
形状与 host 那四个字段同名,免得两套名字对不上。
|
|
6636
|
+
**放在 devSrc 里而不是顶层**:`devFixture` 的顶层键数被既有测试逐个数着
|
|
6637
|
+
(13 个投影键 + 2 个开发模式专用键),而 devSrc 本来就是"开发模式专用替代值"
|
|
6638
|
+
的容器,语义正好。 */
|
|
6639
|
+
agentTiers: {
|
|
6640
|
+
team: typeof ctxScene.teamRunning === "number" ? ctxScene.teamRunning : 0,
|
|
6641
|
+
deep: typeof ctxScene.deepRunning === "number" ? ctxScene.deepRunning : 0,
|
|
6642
|
+
teamPercent: typeof ctxScene.teamContextPercent === "number" ? ctxScene.teamContextPercent : null,
|
|
6643
|
+
deepPercent: typeof ctxScene.deepContextPercent === "number" ? ctxScene.deepContextPercent : null
|
|
6644
|
+
},
|
|
6645
|
+
/* status 的峰谷与 sessionTime 的 elapsed 都读 now。真实 now 每秒在跳,
|
|
6646
|
+
永远落在"此刻"那个时段,「高峰」那一支几乎点不到;假 now 从同一张
|
|
6647
|
+
时段表里取,峰谷与柱状图因此自洽。
|
|
6648
|
+
**必须与上面的 dailyNow 是同一个值**:气泡里"今天"那根柱/那一格
|
|
6649
|
+
靠 `dateKeyOf(src.now)` 与月历数据比大小,两边不同源就一定是跨月的
|
|
6650
|
+
(一个真实当月、一个假当月),未来日灰阶因此永远出不来。 */
|
|
6651
|
+
now: dailyNow,
|
|
6652
|
+
/* 开发模式没有真实会话节点可折:给 null,让「时间窗算不出 + timeline 也算不出」
|
|
6653
|
+
这条横杠路径真的走得通。 */
|
|
6654
|
+
timeline: null,
|
|
6655
|
+
/* 只服务「本轮正在跑但还没调用」那一格(那段判定读的就是这三样) */
|
|
6656
|
+
running: devRunning,
|
|
6657
|
+
partial: null,
|
|
6658
|
+
runningCalls: [],
|
|
6659
|
+
/* 本轮用量与"这份用量属于哪一轮":给确定值,形态才可复现。
|
|
6660
|
+
第 1 格(在跑但还没调用)与第 3 格(算不出价)都必须是 null:
|
|
6661
|
+
第 1 格若留着用量,segmentView 就会走「本轮正在进行中的精确口径」算出金额,
|
|
6662
|
+
¥0.00 那条 active 分支永远到不了;第 3 格要的就是整份拿不到 ⇒ 横杠。 */
|
|
6663
|
+
turnUsage: devTurnEmpty === true
|
|
6664
|
+
? null
|
|
6665
|
+
: {
|
|
6666
|
+
input: sessionUsage.current.input, cacheRead: sessionUsage.current.cacheRead,
|
|
6667
|
+
cacheWrite: sessionUsage.current.cacheWrite, output: sessionUsage.current.output
|
|
6668
|
+
},
|
|
6669
|
+
turnUsageTurn: sessionUsage.turn,
|
|
6670
|
+
/* 换格即换轮:粘性下限随之作废,横杠与 ¥0.00 才出得来 */
|
|
6671
|
+
turnKey: devTurnKey
|
|
6672
|
+
}
|
|
6673
|
+
};
|
|
6674
|
+
}
|
|
6675
|
+
|
|
4361
6676
|
const updateListeners = new Set();
|
|
4362
6677
|
function subscribeUpdate(fn) {
|
|
4363
6678
|
updateListeners.add(fn);
|
|
@@ -4580,9 +6895,24 @@ window.__ModuleLoader__.load({
|
|
|
4580
6895
|
* 开关开发模式。
|
|
4581
6896
|
* 打开时把状态摆成「有新版本可更新」的样子(但版本号是假的一眼可辨),
|
|
4582
6897
|
* 关闭时清掉定时器并恢复成「没有更新」。
|
|
6898
|
+
*
|
|
6899
|
+
* 进入时做两件事:
|
|
6900
|
+
* · `devRound` +1(退出**不清零**):只作为"这是第几次进开发模式"的记录;
|
|
6901
|
+
* · **所有字段计数归零**:一次开发模式就是一次「从头看一遍」,
|
|
6902
|
+
* 上次点到第几种不影响这次(用户明确要求重置)。
|
|
6903
|
+
* 退出时也把字段计数清一遍,保证下次进来从第 1 组开始、且不会残留到真实界面。
|
|
4583
6904
|
*/
|
|
4584
6905
|
function setDevMode(on) {
|
|
6906
|
+
const was = devMode;
|
|
4585
6907
|
devMode = on === true;
|
|
6908
|
+
if (devMode === true && was !== true) {
|
|
6909
|
+
devRound += 1;
|
|
6910
|
+
resetDevFieldScenes();
|
|
6911
|
+
}
|
|
6912
|
+
if (devMode !== true) resetDevFieldScenes();
|
|
6913
|
+
/* 通知底栏:假数据(含 sessionId 换成 dev-<round>-c<cost计数>、各字段计数归零)
|
|
6914
|
+
必须立刻生效 / 撤销。 */
|
|
6915
|
+
publishDev();
|
|
4586
6916
|
if (devTimer !== null) { clearTimeout(devTimer); devTimer = null; }
|
|
4587
6917
|
if (devMode === true) {
|
|
4588
6918
|
setUpdateState({ hasUpdate: true, latest: DEV_VERSION, busy: false, note: null, reason: null });
|
|
@@ -4600,15 +6930,31 @@ window.__ModuleLoader__.load({
|
|
|
4600
6930
|
}
|
|
4601
6931
|
|
|
4602
6932
|
/**
|
|
4603
|
-
*
|
|
6933
|
+
* 版本号被点了一下。两种手势,取决于「这一版解锁过没有」:
|
|
6934
|
+
*
|
|
6935
|
+
* · **解锁过**(localStorage 里记的版本 === 当前 VERSION)→ 单击直接切换开关。
|
|
6936
|
+
* 作者改这一块的界面时会反复进出,每次都连点 5 下太折腾。
|
|
6937
|
+
* · **没解锁过**(首次、换了版本、或 localStorage 写不进去)→ 连点 DEV_CLICKS 次,
|
|
6938
|
+
* 成功后把当前 VERSION 记下来。普通用户装了这插件,界面上不该多出一个面向
|
|
6939
|
+
* 作者的调试项;连点是常见的开发者模式手势:记得住、也不会被误触。
|
|
6940
|
+
*
|
|
6941
|
+
* 「记版本」而不是「记一个 true」:VERSION 一升级,旧记录自动失配 ⇒ 重新要求连点,
|
|
6942
|
+
* 不需要任何迁移代码。两次点击间隔超过 DEV_CLICK_WINDOW_MS 就重新计数 ——
|
|
6943
|
+
* 否则慢慢点 5 次也会进去。
|
|
4604
6944
|
*
|
|
4605
|
-
*
|
|
4606
|
-
*
|
|
4607
|
-
* 两次点击间隔超过 DEV_CLICK_WINDOW_MS 就重新计数 —— 否则慢慢点 5 次也会进去。
|
|
6945
|
+
* 纯同步:开发模式是主插件的一部分,没有任何"组件在不在"要问宿主 ——
|
|
6946
|
+
* 入口只看这一次点击的手势。
|
|
4608
6947
|
*
|
|
4609
6948
|
* @returns {object} 供测试断言的计数快照
|
|
4610
6949
|
*/
|
|
4611
6950
|
function onVersionClick() {
|
|
6951
|
+
/* 解锁记忆读不出来(无痕模式抛错 / 从没写过)⇒ 按未解锁走,退化成"每次都连点" */
|
|
6952
|
+
if (devUnlockVersion() === VERSION) {
|
|
6953
|
+
devClickCount = 0;
|
|
6954
|
+
devClickAt = 0;
|
|
6955
|
+
setDevMode(devMode !== true);
|
|
6956
|
+
return { count: 0, on: devMode };
|
|
6957
|
+
}
|
|
4612
6958
|
const now = Date.now();
|
|
4613
6959
|
if (devClickAt !== 0 && now - devClickAt > DEV_CLICK_WINDOW_MS) devClickCount = 0;
|
|
4614
6960
|
devClickAt = now;
|
|
@@ -4616,7 +6962,10 @@ window.__ModuleLoader__.load({
|
|
|
4616
6962
|
if (devClickCount >= DEV_CLICKS) {
|
|
4617
6963
|
devClickCount = 0;
|
|
4618
6964
|
devClickAt = 0;
|
|
4619
|
-
|
|
6965
|
+
const next = devMode !== true;
|
|
6966
|
+
setDevMode(next);
|
|
6967
|
+
/* 只在**进入**时记解锁(退出不记):写失败(无痕模式)就静默,行为仍是每次连点 */
|
|
6968
|
+
if (next === true) rememberDevUnlock();
|
|
4620
6969
|
}
|
|
4621
6970
|
return { count: devClickCount, on: devMode };
|
|
4622
6971
|
}
|
|
@@ -4706,36 +7055,76 @@ window.__ModuleLoader__.load({
|
|
|
4706
7055
|
const t = props.t;
|
|
4707
7056
|
const useProjection = props.useProjection;
|
|
4708
7057
|
const useSession = props.useSession;
|
|
4709
|
-
const
|
|
7058
|
+
const realSessionId = props.sessionId;
|
|
4710
7059
|
const cfg = useConfig();
|
|
4711
7060
|
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
7061
|
+
/* 开发模式:开着的时候整条底栏改吃假数据(见 devFixture)。
|
|
7062
|
+
`devState.round` 与各字段计数都是**原始值**,给下面几个 effect 当依赖用 ——
|
|
7063
|
+
依赖一个每次都新建的对象会让 effect 每帧重跑。
|
|
7064
|
+
|
|
7065
|
+
`devState.fields` 是**每字段独立计数**({ status, counts, ttft, tps, cacheHit,
|
|
7066
|
+
sessionTime, cost, lastCost, balance, context }):点开哪个气泡就只推进哪个计数,
|
|
7067
|
+
于是只有那个气泡的数据变。这正是用户要的「点一个,只变那一个」。 */
|
|
7068
|
+
const devState = useDevState();
|
|
7069
|
+
const devActive = devState.on === true;
|
|
7070
|
+
/* 各字段计数单独拎出来:effect 依赖数组要用原始值(不能依赖整个 fields 对象,
|
|
7071
|
+
否则任何字段一变所有 effect 都重跑 —— 又回到"点一个全变")。 */
|
|
7072
|
+
const devFields = devState.fields !== null && devState.fields !== undefined ? devState.fields : {};
|
|
7073
|
+
const devBalanceCount = devFields.balance;
|
|
7074
|
+
const devStatusCount = devFields.status;
|
|
7075
|
+
const devCostCount = devFields.cost;
|
|
7076
|
+
/* 假数据体量大(420 个工具名那档),按「是否开发模式 + 各字段计数」缓存一份:
|
|
7077
|
+
底栏每秒重渲染一次,不缓存就是每秒重建一遍。 */
|
|
7078
|
+
const devData = react.useMemo(
|
|
7079
|
+
() => (devActive === true ? devFixture(devState.round, t, devState.fields) : null),
|
|
7080
|
+
[devActive, devState.round,
|
|
7081
|
+
devFields.status, devFields.counts, devFields.cacheHit, devFields.tps, devFields.ttft,
|
|
7082
|
+
devFields.sessionTime, devFields.lastCost, devFields.cost, devFields.balance, devFields.context]
|
|
7083
|
+
);
|
|
7084
|
+
/** 取一个投影:开发模式下用假值覆盖(只覆盖 fixture 里真的给了的键)。 */
|
|
7085
|
+
const projected = (name, real) => (devData !== null && devData[name] !== undefined ? devData[name] : real);
|
|
7086
|
+
|
|
7087
|
+
const stats = projected("stats", useProjection("sessionStats"));
|
|
7088
|
+
|
|
7089
|
+
const usage = projected("usage", useProjection("tokenUsage"));
|
|
7090
|
+
const sessionModel = projected("sessionModel", useProjection("desktopStatusbarModel"));
|
|
7091
|
+
const sessionUsage = projected("sessionUsage", useProjection("desktopStatusbarUsage"));
|
|
7092
|
+
const timeRange = projected("timeRange", useProjection("desktopStatusbarActiveTime"));
|
|
4717
7093
|
/* 逐次调用的首字/速度极值(host 侧折出来的,官方 sessionStats 只有累计值) */
|
|
4718
|
-
const timing = useProjection("desktopStatusbarTiming");
|
|
7094
|
+
const timing = projected("timing", useProjection("desktopStatusbarTiming"));
|
|
4719
7095
|
/* 上下文占用直接读 DSH 自带的两个投影,跟官方那一行同源同口径 */
|
|
4720
|
-
const pressure = useProjection("contextPressure");
|
|
4721
|
-
const breakdown = useProjection("contextBreakdown");
|
|
7096
|
+
const pressure = projected("pressure", useProjection("contextPressure"));
|
|
7097
|
+
const breakdown = projected("breakdown", useProjection("contextBreakdown"));
|
|
4722
7098
|
/* 压缩点的两个入参(窗口 + 本次请求输出上限)由本插件从请求事件里折出来 */
|
|
4723
|
-
const capacity = useProjection("desktopStatusbarCapacity");
|
|
7099
|
+
const capacity = projected("capacity", useProjection("desktopStatusbarCapacity"));
|
|
4724
7100
|
/* 轮次项气泡那三行(上下文压缩 / 技能注入 / 工具调用)同样由本插件折 */
|
|
4725
|
-
const progress = useProjection("desktopStatusbarProgress");
|
|
7101
|
+
const progress = projected("progress", useProjection("desktopStatusbarProgress"));
|
|
7102
|
+
/* 会话标识:开发模式下换成 `dev-<轮次>-c<cost计数>` —— applyTotalFloor / applyTurnFloor
|
|
7103
|
+
都以它为界,换了就等于「换会话」,账本自动清空重算。
|
|
7104
|
+
**必须带上 cost 计数**:只带 round 的话,点「总计」气泡时 round 不变,
|
|
7105
|
+
applyTotalFloor 认为是同一会话、只结算"没见过的调用指纹",而假数据时间戳固定
|
|
7106
|
+
(DEV_FIXTURE_ANCHOR)⇒ 指纹全见过 ⇒ 一分钱都不会再加(用户实测的"总计点开不涨")。
|
|
7107
|
+
只带 cost 计数(不带别的字段计数)也很重要:点别的气泡时总计账本不能被重置,
|
|
7108
|
+
否则就是反向串扰。 */
|
|
7109
|
+
const sessionId = devData !== null
|
|
7110
|
+
? "dev-" + String(devState.round) + "-c" + String(typeof devCostCount === "number" ? devCostCount : 0)
|
|
7111
|
+
: realSessionId;
|
|
4726
7112
|
/* 设置页拿不到会话投影,把当前模型上报给 host,供设置页读取 */
|
|
4727
7113
|
const reportedModel = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.model === "string" && sessionModel.model.length > 0
|
|
4728
7114
|
? sessionModel.model
|
|
4729
7115
|
: null;
|
|
4730
7116
|
const reportedProvider = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.provider === "string" ? sessionModel.provider : null;
|
|
4731
7117
|
react.useEffect(() => {
|
|
7118
|
+
/* 开发模式:假模型绝不报给 host —— 否则设置页会照着假数据改真实会话的「当前模型」,
|
|
7119
|
+
而且开发模式要求一个真实请求都不发。 */
|
|
7120
|
+
if (devActive === true) return;
|
|
4732
7121
|
if (reportedModel === null) return;
|
|
4733
7122
|
window.fetch(ACTIVE_MODEL_URL, {
|
|
4734
7123
|
method: "POST",
|
|
4735
7124
|
headers: { "content-type": "application/json" },
|
|
4736
7125
|
body: JSON.stringify({ provider: reportedProvider, model: reportedModel })
|
|
4737
7126
|
}).catch(() => { /* 上报失败不影响底栏 */ });
|
|
4738
|
-
}, [reportedModel, reportedProvider]);
|
|
7127
|
+
}, [reportedModel, reportedProvider, devActive]);
|
|
4739
7128
|
|
|
4740
7129
|
/* 价格库上报给 host:活跃总览的 cost 是 host 记账时算的,
|
|
4741
7130
|
它拿不到 localStorage,必须由这里把当前价格库送过去,
|
|
@@ -4744,6 +7133,8 @@ window.__ModuleLoader__.load({
|
|
|
4744
7133
|
即当时没有单价、还没算钱的那部分用量,已经算好的历史费用不动。 */
|
|
4745
7134
|
const reportedPrices = react.useMemo(() => JSON.stringify(cfg.models === undefined || cfg.models === null ? {} : cfg.models), [cfg.models]);
|
|
4746
7135
|
react.useEffect(() => {
|
|
7136
|
+
/* 开发模式:一次真实请求都不发(verify-dev-mode ⑦ 盯着这条) */
|
|
7137
|
+
if (devActive === true) return;
|
|
4747
7138
|
let models = null;
|
|
4748
7139
|
try {
|
|
4749
7140
|
models = JSON.parse(reportedPrices);
|
|
@@ -4769,7 +7160,7 @@ window.__ModuleLoader__.load({
|
|
|
4769
7160
|
}).catch((error) => {
|
|
4770
7161
|
console.warn("[dsh-desktop-statusbar] 价格库上报失败:" + String(error && error.message));
|
|
4771
7162
|
});
|
|
4772
|
-
}, [reportedPrices]);
|
|
7163
|
+
}, [reportedPrices, devActive]);
|
|
4773
7164
|
|
|
4774
7165
|
const running = pick(useSession, (s) => s.running, undefined);
|
|
4775
7166
|
const partial = pick(useSession, (s) => s.partial, undefined);
|
|
@@ -4787,6 +7178,25 @@ window.__ModuleLoader__.load({
|
|
|
4787
7178
|
同一轮里某次请求失败就沿用上一次成功的值,换了轮/换了会话一律清空重算,
|
|
4788
7179
|
界面上不出现任何提示文字。段落取值用 `subagentState.sessionPayload / turnPayload`。 */
|
|
4789
7180
|
const [subagentState, setSubagentState] = react.useState(SUBAGENT_STATE_EMPTY);
|
|
7181
|
+
/* 队友里**还在跑**的分层情况(host 响应里的 `running` / `teamRunning` / `deepRunning` +
|
|
7182
|
+
两级的占用百分比)。两个用途:
|
|
7183
|
+
① `all > 0` 时让时钟继续每秒跳 —— 主对话已经结束、但队友还在干活,
|
|
7184
|
+
否则「总计」与队友数据会静止到下一个峰谷边界(可能几小时),
|
|
7185
|
+
用户看到的症状是「必须点开总计气泡才刷新」;
|
|
7186
|
+
② 决定上下文圆环的颜色(Lead 绿 / 成员蓝 / 更深层紫)与弧长(那两级各自的占用)。
|
|
7187
|
+
**合并成一个对象 state**:五个数放一起,一次 setState 收尾 —— 拆成五个
|
|
7188
|
+
state 会让同一份响应触发五次重渲染,而底栏每秒本来就在重渲染。
|
|
7189
|
+
单独一个 state 而不是塞进 subagentState:后者是两段取值的状态机,
|
|
7190
|
+
形状被既有测试逐字段断言着(见 nextSubagentState 的契约),不该混进新字段。
|
|
7191
|
+
**语义必须守住**:`all === null` 表示"这次没结论、保留旧值";`all === 0` 是
|
|
7192
|
+
真空闲、必须落下去(这是修过的真实缺陷:把"问不到"当 0 会让时钟降到省电档)。 */
|
|
7193
|
+
const [subagentTiers, setSubagentTiers] = react.useState(SUBAGENT_TIERS_EMPTY);
|
|
7194
|
+
const subagentRunning = subagentTiers.all;
|
|
7195
|
+
/* host 推出来的**本轮起点**(`turnStartMs`)+ 它属于哪个会话/哪一轮。
|
|
7196
|
+
只在客户端自己折叠不出起点(或折叠出的是上一轮)时兜底用,主路径不碰它。
|
|
7197
|
+
带 sessionId / turnKey 是为了换会话、换轮时自动失配 —— 复用 nextSubagentState
|
|
7198
|
+
那套「归属对不上就作废」的判定思路,不另立一套。 */
|
|
7199
|
+
const hostTurnStartRef = react.useRef({ sessionId: null, turnKey: null, value: null });
|
|
4790
7200
|
/* 「本轮」显示值的粘性下限盒子(同轮内只增不减,换轮作废):一个可变盒子,
|
|
4791
7201
|
由 lastCost 段在渲染时更新,跨渲染靠这个 ref 留住(见 applyTurnFloor)。 */
|
|
4792
7202
|
const lastCostHoldRef = react.useRef({ sessionId: null, turnKey: null, value: 0 });
|
|
@@ -4808,8 +7218,13 @@ window.__ModuleLoader__.load({
|
|
|
4808
7218
|
/* 积分套餐气泡的下钻层:null = 根视图,数字 = 点开了第几个聚合行(×N 那行)。
|
|
4809
7219
|
换字段 / 收起气泡 / 点标题返回都清掉,下次打开回到套餐列表。 */
|
|
4810
7220
|
const [creditSub, setCreditSub] = react.useState(null);
|
|
4811
|
-
/*
|
|
4812
|
-
逐档 +1(最多 12 行,见下面气泡定位那条 layout effect)。换字段 / 换子视图都重置回 3。
|
|
7221
|
+
/* 子视图「每列几行」:默认 3 行一列;量到一排放不下(列被折到下一排)就
|
|
7222
|
+
逐档 +1(最多 12 行,见下面气泡定位那条 layout effect)。换字段 / 换子视图都重置回 3。
|
|
7223
|
+
**名字保留 `toolRows`(历史原因),但它现在服务三个列表**:工具调用、技能注入、
|
|
7224
|
+
上下文压缩 —— 后两个是用户后加的(「技能注入也和工具调用同理……上下文压缩也同理」)。
|
|
7225
|
+
三者共用同一个 `rowsPanel` 入口与同一个 state,所以三处 `setToolRows(LIST_ROWS_PER_COLUMN)`
|
|
7226
|
+
重置(换字段 / sub.open / sub.back)对三者一视同仁,不需要各写一份。
|
|
7227
|
+
改名成 listRows 会牵动既有测试里对这个名字的引用,收益不值当。 */
|
|
4813
7228
|
const [toolRows, setToolRows] = react.useState(LIST_ROWS_PER_COLUMN);
|
|
4814
7229
|
/* 柱 / 方块 / 每周底条的悬停浮层:内容 + 触发元素的位置(自己算 fixed 坐标) */
|
|
4815
7230
|
const [hoverCard, setHoverCard] = react.useState(null);
|
|
@@ -4953,10 +7368,28 @@ window.__ModuleLoader__.load({
|
|
|
4953
7368
|
const clockActive = timeRangeNow !== null
|
|
4954
7369
|
&& ((timeRangeNow.since !== null && timeRangeNow.since !== undefined)
|
|
4955
7370
|
|| (timeRangeNow.stepSince !== null && timeRangeNow.stepSince !== undefined));
|
|
7371
|
+
/* 队友还在跑 ⇒ 时钟必须继续跳(哪怕主对话已经结束)。
|
|
7372
|
+
为什么需要它:主对话一结束 `running` 与 `clockActive` 都会转 false,旧行为是
|
|
7373
|
+
睡到下一个峰谷边界(可能几小时),于是「总计」里的队友金额与队友数据都不再
|
|
7374
|
+
每秒更新 —— 用户观察到的「必须点开总计气泡才刷新」就是它。
|
|
7375
|
+
`subagentRunning` 由 host 响应的 `running` 维护,队友全部结束就回 0,
|
|
7376
|
+
时钟随即退回原来的省电行为(不会永久每秒跳)。 */
|
|
7377
|
+
const subagentBusy = subagentRunning > 0;
|
|
4956
7378
|
react.useEffect(() => {
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
7379
|
+
/* 队友在跑时**必须**有钟:即使「总用时 / 峰谷」两段都被关掉,`now` 每秒一跳
|
|
7380
|
+
才能驱动队友用量的重拉(那条 effect 依赖 now,节流靠 SUBAGENT_REFRESH_MS),
|
|
7381
|
+
否则「总计」又退回到"不点气泡就不更新"。
|
|
7382
|
+
**开发模式开着时也必须有钟**:底栏的时间类字段(本轮金额的粘性下限、
|
|
7383
|
+
总用时累加)都按 now 走,没有钟它们就冻在上一拍。
|
|
7384
|
+
(这里以前还写着「作为每 N 秒核对组件还在不在的驱动源」——那段组件探测
|
|
7385
|
+
逻辑已经整体删掉了,注释一并移除,免得后来人去找一个不存在的定时核对。) */
|
|
7386
|
+
if (needsNow !== true && subagentBusy !== true && devActive !== true) return undefined;
|
|
7387
|
+
if (running === true || clockActive || subagentBusy === true || devActive === true) {
|
|
7388
|
+
/* 1 秒一档:主对话在跑、或队友在跑 —— 这两件事都需要"实时";
|
|
7389
|
+
开发模式也走 1 秒档(演示数据要跟着气泡点击立即反映到时间类字段上);
|
|
7390
|
+
只有 clockActive(正在计时但没人在产出)才降到 10 秒省电。 */
|
|
7391
|
+
const fast = running === true || subagentBusy === true || devActive === true;
|
|
7392
|
+
const timer = window.setInterval(() => setNow(Date.now()), fast === true ? 1000 : 10000);
|
|
4960
7393
|
return () => window.clearInterval(timer);
|
|
4961
7394
|
}
|
|
4962
7395
|
/* 空闲:睡到下一个峰谷边界再刷新,不轮询 */
|
|
@@ -4967,12 +7400,14 @@ window.__ModuleLoader__.load({
|
|
|
4967
7400
|
};
|
|
4968
7401
|
timer = window.setTimeout(tick, msToNextPeakBoundary(Date.now()) + 1000);
|
|
4969
7402
|
return () => window.clearTimeout(timer);
|
|
4970
|
-
}, [needsNow, running, clockActive]);
|
|
7403
|
+
}, [needsNow, running, clockActive, subagentBusy, devActive]);
|
|
4971
7404
|
|
|
4972
|
-
/* 价格库缺省或全 0
|
|
7405
|
+
/* 价格库缺省或全 0 时自动补上官方参考价(不用用户手动填)。
|
|
7406
|
+
开发模式跳过:它内部会发一次真实 fetch(verify-dev-mode ⑦ 要求开发模式 0 次请求)。 */
|
|
4973
7407
|
react.useEffect(() => {
|
|
7408
|
+
if (devActive === true) return;
|
|
4974
7409
|
void ensurePrices();
|
|
4975
|
-
}, []);
|
|
7410
|
+
}, [devActive]);
|
|
4976
7411
|
|
|
4977
7412
|
const wantsBalance = isSegmentOn(cfg, "balance");
|
|
4978
7413
|
/* 当前会话用的 provider / model:host 按它决定查哪个平台的余额。
|
|
@@ -4985,14 +7420,29 @@ window.__ModuleLoader__.load({
|
|
|
4985
7420
|
下一次轮询(最多 60 秒),气泡里直接是此刻的数字。轮询本身不带 force。 */
|
|
4986
7421
|
const balanceReloadRef = react.useRef(null);
|
|
4987
7422
|
react.useEffect(() => {
|
|
7423
|
+
/* 开发模式:一次请求都不发,直接吃假余额(6 种轮换见 devFixture)。 */
|
|
7424
|
+
if (devData !== null) {
|
|
7425
|
+
setBalance(devData.balance);
|
|
7426
|
+
balanceReloadRef.current = () => setBalance(devData.balance);
|
|
7427
|
+
return () => { balanceReloadRef.current = null; };
|
|
7428
|
+
}
|
|
4988
7429
|
if (!wantsBalance) return undefined;
|
|
7430
|
+
/* 换了 provider / 换了 model 时**先清空**再查:不清的话,新平台的第一次查询
|
|
7431
|
+
回来之前(或它一直查不到时),界面会把上一个平台的数字当成当前平台的余额显示,
|
|
7432
|
+
那比横杠更误导(用户拍板:宁可先显示横杠)。 */
|
|
7433
|
+
setBalance(null);
|
|
4989
7434
|
let alive = true;
|
|
4990
7435
|
const load = (force) => {
|
|
4991
7436
|
const url = BALANCE_URL + balanceQuery + (force === true ? "&force=1" : "");
|
|
4992
7437
|
window.fetch(url, { cache: "no-store" })
|
|
4993
7438
|
.then((response) => response.json())
|
|
4994
|
-
.then((data) => {
|
|
4995
|
-
|
|
7439
|
+
.then((data) => {
|
|
7440
|
+
/* **只有真的拿到余额才覆盖**:某一轮失败(平台临时 5xx / 超时 / 该平台没配 key)
|
|
7441
|
+
不能把上一次成功的数字换成空,否则底栏会在 ¥12.34 与 ¥- 之间来回跳,
|
|
7442
|
+
用户每次抬眼看到的都是"查不到"。 */
|
|
7443
|
+
if (alive && data !== null && data !== undefined && data.ok === true) setBalance(data);
|
|
7444
|
+
})
|
|
7445
|
+
.catch(() => { /* 网络异常同样保留上一次成功值(首次就一直保持 null = 横杠) */ });
|
|
4996
7446
|
};
|
|
4997
7447
|
balanceReloadRef.current = () => load(true);
|
|
4998
7448
|
load(false);
|
|
@@ -5002,7 +7452,10 @@ window.__ModuleLoader__.load({
|
|
|
5002
7452
|
balanceReloadRef.current = null;
|
|
5003
7453
|
window.clearInterval(timer);
|
|
5004
7454
|
};
|
|
5005
|
-
|
|
7455
|
+
/* 依赖里**必须带 devBalanceCount**:开发模式下余额来自 devFixture,而这个 effect 负责
|
|
7456
|
+
把它 setBalance 进去。只依赖 round 的话,点余额气泡时 round 不变 ⇒ effect 不重跑
|
|
7457
|
+
⇒ setBalance 压根不执行 ⇒ 余额数字冻住(用户实测的"余额点开不变"就是这个)。 */
|
|
7458
|
+
}, [wantsBalance, balanceProvider, balanceModel, devActive, devState.round, devBalanceCount]);
|
|
5006
7459
|
|
|
5007
7460
|
/* 活跃总览(峰谷段的气泡):不轮询。挂载时取一次(走 host 缓存),
|
|
5008
7461
|
之后每次点开气泡都带 force=1 —— force 只绕过 host 那 30 秒缓存;
|
|
@@ -5011,7 +7464,16 @@ window.__ModuleLoader__.load({
|
|
|
5011
7464
|
const wantsWeekly = isSegmentOn(cfg, "status");
|
|
5012
7465
|
const dailyReloadRef = react.useRef(null);
|
|
5013
7466
|
react.useEffect(() => {
|
|
7467
|
+
/* 开发模式:直接吃假数据(近 7 天 + 当月日历 + 12 个月都齐),一次请求都不发 */
|
|
7468
|
+
if (devData !== null) {
|
|
7469
|
+
setDaily(devData.daily);
|
|
7470
|
+
dailyReloadRef.current = () => setDaily(devData.daily);
|
|
7471
|
+
return () => { dailyReloadRef.current = null; };
|
|
7472
|
+
}
|
|
5014
7473
|
if (!wantsWeekly) return undefined;
|
|
7474
|
+
/* 与余额同一条规矩:退出开发模式后先清空,别让真实的活跃总览还没回来时
|
|
7475
|
+
继续显示开发模式那套假数据。 */
|
|
7476
|
+
setDaily(null);
|
|
5015
7477
|
let alive = true;
|
|
5016
7478
|
const load = (force) => {
|
|
5017
7479
|
const url = DAILY_URL + (force === true ? "?force=1" : "");
|
|
@@ -5026,7 +7488,11 @@ window.__ModuleLoader__.load({
|
|
|
5026
7488
|
alive = false;
|
|
5027
7489
|
dailyReloadRef.current = null;
|
|
5028
7490
|
};
|
|
5029
|
-
|
|
7491
|
+
/* 依赖里**必须带 devStatusCount**:与余额那条完全同一个道理 —— 活跃总览的假数据来自
|
|
7492
|
+
devFixture,而这个 effect 负责把它 setDaily 进去。只依赖 round 的话,点「峰谷」
|
|
7493
|
+
气泡时 round 不变 ⇒ effect 不重跑 ⇒ setDaily 不执行 ⇒ 柱状图/月历一直冻在旧值
|
|
7494
|
+
(用户实测的"柱状图一直同高、月份一直 10 月"里,一半原因就是这个)。 */
|
|
7495
|
+
}, [wantsWeekly, devActive, devState.round, devStatusCount]);
|
|
5030
7496
|
|
|
5031
7497
|
/* 队友用量(「总计」「本轮」都要算上它):换会话、对话新增了节点、本轮换了、
|
|
5032
7498
|
或距上次超过 3 秒(活跃时)才重拉;点开气泡再强制拉一次。
|
|
@@ -5046,16 +7512,32 @@ window.__ModuleLoader__.load({
|
|
|
5046
7512
|
? sessionUsage.turn
|
|
5047
7513
|
: null;
|
|
5048
7514
|
const turnIsCurrent = foldedTurnNum === null || hostTurn === null || foldedTurnNum === hostTurn;
|
|
5049
|
-
const turnStartMs = turnIsCurrent === true && foldedTurnNow !== null && typeof foldedTurnNow.at === "number"
|
|
5050
|
-
? foldedTurnNow.at
|
|
5051
|
-
: null;
|
|
5052
7515
|
/* 「同一轮」的轮标识(给队友状态机与"本轮只增不减"用)。两个来源都只会在真的换轮时前进:
|
|
5053
7516
|
host 投影的 turn 随用量事件走,节点折叠的轮号随助手节点走。取**较大者**是为了尽早认定
|
|
5054
7517
|
"换轮了"——两者谁先看到新一轮,就从那一刻起归零重算;认不出轮次(两个都拿不到)时为 null,
|
|
5055
|
-
此时既不沿用旧值也不做粘性,避免把两轮的数字混在一起。
|
|
7518
|
+
此时既不沿用旧值也不做粘性,避免把两轮的数字混在一起。
|
|
7519
|
+
**必须定义在下面 turnStartMs 之前**:host 起点兜底要拿它判定"这份值属于哪一轮"。 */
|
|
5056
7520
|
const subagentTurnKey = hostTurn !== null && foldedTurnNum !== null
|
|
5057
7521
|
? Math.max(hostTurn, foldedTurnNum)
|
|
5058
7522
|
: (hostTurn !== null ? hostTurn : foldedTurnNum);
|
|
7523
|
+
/* 客户端自己折叠出来的起点。**这是主路径**,算得出来就用它,不碰 host 那个兜底值。 */
|
|
7524
|
+
const foldedStartMs = turnIsCurrent === true && foldedTurnNow !== null && typeof foldedTurnNow.at === "number"
|
|
7525
|
+
? foldedTurnNow.at
|
|
7526
|
+
: null;
|
|
7527
|
+
/* host 推出来的那一个起点(上一次响应里存下的)。**只在主路径拿不到时才用**:
|
|
7528
|
+
典型场景是客户端折叠出的是上一轮起点(新一轮已开、主助手还没输出),
|
|
7529
|
+
host 通常能收紧到真正的本轮起点;而 host 自己读不出父会话日志时会返回 null,
|
|
7530
|
+
那就还是 null(宁少不多,本轮不并队友)。
|
|
7531
|
+
归属必须同时对上**会话 + 轮**才认,否则就是上一个会话/上一轮留下的旧值 ——
|
|
7532
|
+
与 nextSubagentState 的判定思路一致(归属对不上就作废,不沿用)。 */
|
|
7533
|
+
const hostStart = hostTurnStartRef.current;
|
|
7534
|
+
const hostStartUsable = hostStart !== null && hostStart !== undefined
|
|
7535
|
+
&& hostStart.sessionId === sessionId
|
|
7536
|
+
&& subagentTurnKey !== null && hostStart.turnKey === subagentTurnKey
|
|
7537
|
+
&& typeof hostStart.value === "number" && Number.isFinite(hostStart.value) && hostStart.value > 0;
|
|
7538
|
+
const turnStartMs = foldedStartMs !== null
|
|
7539
|
+
? foldedStartMs
|
|
7540
|
+
: (hostStartUsable === true ? hostStart.value : null);
|
|
5059
7541
|
const chatNodeCount = Array.isArray(chatNodes) ? chatNodes.length : -1;
|
|
5060
7542
|
const subagentReqRef = react.useRef({ sessionId: null, nodes: -1, since: null, at: 0 });
|
|
5061
7543
|
const subagentForceRef = react.useRef(false);
|
|
@@ -5063,19 +7545,40 @@ window.__ModuleLoader__.load({
|
|
|
5063
7545
|
/* 一次"要拉取"的凭据:nonce 变一次就拉一次(sessionId / since 跟着它一起走,读的时候不会串会话) */
|
|
5064
7546
|
const [subagentQuery, setSubagentQuery] = react.useState(null);
|
|
5065
7547
|
react.useEffect(() => {
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
7548
|
+
/* 开发模式:点开「总计」气泡时不该真去拉队友用量(0 请求要求)。 */
|
|
7549
|
+
subagentReloadRef.current = devActive === true
|
|
7550
|
+
? null
|
|
7551
|
+
: () => {
|
|
7552
|
+
subagentForceRef.current = true;
|
|
7553
|
+
setSubagentTick((n) => n + 1);
|
|
7554
|
+
};
|
|
5070
7555
|
return () => { subagentReloadRef.current = null; };
|
|
5071
|
-
}, []);
|
|
7556
|
+
}, [devActive]);
|
|
5072
7557
|
react.useEffect(() => {
|
|
7558
|
+
/* 开发模式:「总计 / 本轮」的队友那一段直接取假值(见 devFixture 的 subagent),
|
|
7559
|
+
不发请求,也不让上一条真实请求的残留值混进来。
|
|
7560
|
+
`running` 一并归零:假数据里没有"还在跑的队友"这回事,留着旧值会让
|
|
7561
|
+
时钟一直按每秒一档空转(假数据根本不刷新)。
|
|
7562
|
+
host 起点同理清掉,免得退出开发模式后拿假轮次的旧起点去算本轮。 */
|
|
7563
|
+
if (devData !== null) {
|
|
7564
|
+
setSubagentState(SUBAGENT_STATE_EMPTY);
|
|
7565
|
+
setSubagentTiers(SUBAGENT_TIERS_EMPTY);
|
|
7566
|
+
hostTurnStartRef.current = { sessionId: null, turnKey: null, value: null };
|
|
7567
|
+
return undefined;
|
|
7568
|
+
}
|
|
5073
7569
|
if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
5074
7570
|
const last = subagentReqRef.current;
|
|
5075
7571
|
const switched = last.sessionId !== sessionId;
|
|
5076
7572
|
const forced = subagentForceRef.current;
|
|
5077
7573
|
subagentForceRef.current = false;
|
|
5078
|
-
|
|
7574
|
+
/* `since`(本轮起点)**不**参与这里的判定:它是从对话节点时间戳折叠出来的,
|
|
7575
|
+
每秒都在毫秒级漂移;拿它当"有变化"的信号会让每次重渲染都判成 due,
|
|
7576
|
+
把 SUBAGENT_REFRESH_MS 的节流整个绕过去,实际每秒打好几次请求、白烧 CPU。
|
|
7577
|
+
本轮起点变了这类变化,由下面 `subagentRunning > 0`(队友还在跑就每秒一次)
|
|
7578
|
+
和 `last.at` 的节流兜住;它依旧写进 subagentReqRef,别处读它当请求参数。 */
|
|
7579
|
+
const due = switched === true
|
|
7580
|
+
|| last.nodes !== chatNodeCount
|
|
7581
|
+
|| subagentRunning > 0
|
|
5079
7582
|
|| Date.now() - last.at >= SUBAGENT_REFRESH_MS;
|
|
5080
7583
|
if (forced !== true && due !== true) return undefined;
|
|
5081
7584
|
subagentReqRef.current = { sessionId: sessionId, nodes: chatNodeCount, since: turnStartMs, at: Date.now() };
|
|
@@ -5090,6 +7593,8 @@ window.__ModuleLoader__.load({
|
|
|
5090
7593
|
return undefined;
|
|
5091
7594
|
}, [sessionId, chatNodeCount, turnStartMs, now, subagentTick, subagentTurnKey]);
|
|
5092
7595
|
react.useEffect(() => {
|
|
7596
|
+
/* 开发模式:队友那两段是假值,一个请求都不发(含"进开发模式前已经在途的那次")。 */
|
|
7597
|
+
if (devActive === true) return undefined;
|
|
5093
7598
|
if (subagentQuery === null) return undefined;
|
|
5094
7599
|
if (typeof window === "undefined" || typeof window.fetch !== "function") return undefined;
|
|
5095
7600
|
let alive = true;
|
|
@@ -5132,9 +7637,37 @@ window.__ModuleLoader__.load({
|
|
|
5132
7637
|
nextSubagentState(prev, action("session", both[0])),
|
|
5133
7638
|
action("turn", both[1])
|
|
5134
7639
|
));
|
|
7640
|
+
/* 队友还在跑的分层情况(个数 + 两级占用百分比):两段响应里任一段带这些字段就用它
|
|
7641
|
+
(host 两问都会带上同一份数)。
|
|
7642
|
+
**两问都没结论时整份保留旧值**(nextSubagentTiers 里的 stale-while-revalidate,
|
|
7643
|
+
内部沿用 nextSubagentRunning 的判定),与旁边 hostTurnStartRef 是同一条规矩。
|
|
7644
|
+
为什么不能当成 0:`all` 决定"时钟继续每秒跳"还是"睡到下一个峰谷边界",
|
|
7645
|
+
一次网络抖动把 0 写进去,时钟就会一路睡到几小时后,队友数据与「总计」里
|
|
7646
|
+
队友那笔金额跟着冻结 —— 正是用户报过的那个缺陷原样复现。
|
|
7647
|
+
反过来"两问都成功且都是 0"必须能落下去(队友真的全部结束,时钟要回到省电档),
|
|
7648
|
+
所以不能写成"新值 > 0 才更新"。
|
|
7649
|
+
五个数一次 setState:拆开会让同一份响应触发多次重渲染,而底栏每秒都在重渲染。 */
|
|
7650
|
+
setSubagentTiers((prev) => nextSubagentTiers(prev, both[1], both[0]));
|
|
7651
|
+
/* host 推出来的本轮起点:存进 ref,附上它属于哪个会话 / 哪一轮。
|
|
7652
|
+
只在**「本轮窗口」那一问**的响应里读 —— `load(0)`(整会话那一问)返回的起点
|
|
7653
|
+
语义是"最早的那次",拿它当本轮起点会把整个会话的队友花费算进本轮。
|
|
7654
|
+
归属(sessionId + turnKey)必须一起存:换会话换轮时自动失配作废,
|
|
7655
|
+
与 nextSubagentState 的判定思路一致(归属对不上就不沿用)。
|
|
7656
|
+
**同一归属下这一次没拿到就保留旧值**(和两段取值的 stale-while-revalidate 同理):
|
|
7657
|
+
起点偶尔读不出来时,不该让本轮立刻退回"不并队友"。 */
|
|
7658
|
+
const prevStart = hostTurnStartRef.current;
|
|
7659
|
+
const sameOwner = prevStart !== null && prevStart !== undefined
|
|
7660
|
+
&& prevStart.sessionId === subagentQuery.sessionId
|
|
7661
|
+
&& prevStart.turnKey === subagentQuery.turnKey;
|
|
7662
|
+
const turnStart = readTurnStartMs(both[1]);
|
|
7663
|
+
hostTurnStartRef.current = {
|
|
7664
|
+
sessionId: subagentQuery.sessionId,
|
|
7665
|
+
turnKey: subagentQuery.turnKey,
|
|
7666
|
+
value: turnStart !== null ? turnStart : (sameOwner === true ? prevStart.value : null)
|
|
7667
|
+
};
|
|
5135
7668
|
});
|
|
5136
7669
|
return () => { alive = false; };
|
|
5137
|
-
}, [subagentQuery]);
|
|
7670
|
+
}, [subagentQuery, devActive]);
|
|
5138
7671
|
|
|
5139
7672
|
/* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
|
|
5140
7673
|
不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
|
|
@@ -5142,8 +7675,22 @@ window.__ModuleLoader__.load({
|
|
|
5142
7675
|
react.useEffect(() => {
|
|
5143
7676
|
if (tipId === null) return undefined;
|
|
5144
7677
|
const close = () => { setTipId(null); setTipPos(null); setTipSub(null); setCreditSub(null); };
|
|
7678
|
+
/**
|
|
7679
|
+
* 点面板外部就收起。
|
|
7680
|
+
*
|
|
7681
|
+
* **气泡已经 Portal 到 body,不在 rootRef 里了**(见 tipPanelInner 上方那段说明),
|
|
7682
|
+
* 所以这里必须同时检查气泡自身 —— 否则点气泡会被当成"点在外面"、立刻把它关掉,
|
|
7683
|
+
* 表现为"气泡一出现就消失 / 点里面没反应"。
|
|
7684
|
+
* `tipRef.current` 是气泡节点自己的 ref(五个面板构造函数都收了它),
|
|
7685
|
+
* Portal 之后它指向的仍是那个真实 DOM,contains 判定照旧有效。
|
|
7686
|
+
* 两个 ref 都可能还是 null(首次渲染 / 气泡没开):逐个判空,不要直接 .contains。
|
|
7687
|
+
*/
|
|
5145
7688
|
const onDocClick = (event) => {
|
|
5146
|
-
|
|
7689
|
+
const target = event === null || event === undefined ? null : event.target;
|
|
7690
|
+
const root = rootRef === null || rootRef === undefined ? null : rootRef.current;
|
|
7691
|
+
if (root !== null && root !== undefined && root.contains(target)) return;
|
|
7692
|
+
const panel = tipRef === null || tipRef === undefined ? null : tipRef.current;
|
|
7693
|
+
if (panel !== null && panel !== undefined && typeof panel.contains === "function" && panel.contains(target)) return;
|
|
5147
7694
|
close();
|
|
5148
7695
|
};
|
|
5149
7696
|
/* 窗口尺寸变了只是位置要重算,不必把气泡收掉 */
|
|
@@ -5176,9 +7723,13 @@ window.__ModuleLoader__.load({
|
|
|
5176
7723
|
const width = node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
|
|
5177
7724
|
? node.getBoundingClientRect().width
|
|
5178
7725
|
: 0;
|
|
5179
|
-
/*
|
|
7726
|
+
/* **支持加行的子视图**(工具调用 / 技能注入 / 上下文压缩三个,判据是列表对象上的
|
|
7727
|
+
`growable: true`,不是 `tight`):默认每列 3 行,一排放不下就逐档加行
|
|
5180
7728
|
(3 → 4 → 5 …,上限 LIST_ROWS_PER_COLUMN_MAX)。只有这一套行数 ——
|
|
5181
7729
|
不再有「中文/英文整行块」两套结构(见 rowsPanel 的注释)。
|
|
7730
|
+
用户原话:「技能注入也和工具调用同理呀,如果超过每列 3 个依然排不下,
|
|
7731
|
+
就是加每列的个数啊,而不是直接可滚动啊,上下文压缩也同理啊」——
|
|
7732
|
+
所以三个列表走的是同一条路,都不直接横向滚动。
|
|
5182
7733
|
列网格是 nowrap(绝不折行),所以"放不下"用横向溢出判(见 toolColumnsOverflow);
|
|
5183
7734
|
每加一行列数就少一档,直到一排放得下。行数一加气泡就变窄 —— 这就是用户要的
|
|
5184
7735
|
"宽度 = 加完行数之后刚好不折的那个宽度"(理解 B),不会停在加行前的旧宽度上。
|
|
@@ -5188,6 +7739,23 @@ window.__ModuleLoader__.load({
|
|
|
5188
7739
|
const overflow = toolColumnsOverflow(node);
|
|
5189
7740
|
const nextRows = nextToolRows(toolRows, overflow);
|
|
5190
7741
|
if (nextRows !== toolRows) setToolRows(nextRows);
|
|
7742
|
+
/* 逐档加行之后**必须重算一次右缘渐隐**。
|
|
7743
|
+
用户实机报的现象:4 行 × 5 列、内容明明能完整显示,右侧却挂着一道淡出。
|
|
7744
|
+
成因链:打开时按 3 行排 → 6 列放不下 → 挂遮罩 → 这里把行数加到 4 → 列数降到 5、
|
|
7745
|
+
气泡变宽、已经不用滚了,但节点被 React 复用(key 没变、ref 是模块级常量),
|
|
7746
|
+
ref 不会再触发 ⇒ 遮罩一直留着。
|
|
7747
|
+
为什么放在这里、并且**不加条件**:
|
|
7748
|
+
· 这个 useTipLayout 本来就**没有依赖数组**(每次渲染都跑),而它上面几行已经
|
|
7749
|
+
调过 `getBoundingClientRect()` —— 布局早就被强制刷新过了,这里再读一次
|
|
7750
|
+
scrollWidth/clientWidth 的边际成本≈0(同一帧内的第二次读不再触发重排)。
|
|
7751
|
+
所以「每帧多一次强制布局」这个担心在本位置不成立。
|
|
7752
|
+
· 不加 `nextRows !== toolRows` 条件:加行是「这一帧测到溢出、下一帧才换行数」,
|
|
7753
|
+
真正需要重算的是**换完行之后那一帧**(那时 nextRows 已经等于 toolRows 了)。
|
|
7754
|
+
无条件调用才能在换行后的第一次渲染就纠正;applyColsFade 内部有记忆,
|
|
7755
|
+
几何没变时连 classList / style 都不碰,不会造成每帧栅格化。
|
|
7756
|
+
· 不用 rAF 延后一帧:那样中间会有一帧"遮罩还在"的可见闪烁,而这里根本没有
|
|
7757
|
+
时序问题(下一帧渲染时 DOM 已是新行数,量到的就是新几何)。 */
|
|
7758
|
+
refreshColsFade(node);
|
|
5191
7759
|
const centered = anchor.left + anchor.width / 2 - width / 2;
|
|
5192
7760
|
/* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
|
|
5193
7761
|
左 —— 可见左边缘压在上下文占用那个**圆圈灰底**的左边。灰底是圆环按钮自己画的
|
|
@@ -5312,9 +7880,12 @@ window.__ModuleLoader__.load({
|
|
|
5312
7880
|
return;
|
|
5313
7881
|
}
|
|
5314
7882
|
const box = panel.getBoundingClientRect();
|
|
7883
|
+
/* 悬停浮层与主气泡之间只留 3px(原为 6px):用户看实机后觉得"两个气泡中间的缝太大"。
|
|
7884
|
+
两个都是圆角 16px 的毛玻璃块,叠在一起时 6px 会被圆角吃出明显的空隙感,
|
|
7885
|
+
收到 3px 既能看出是两层、又不会像断开的两块。 */
|
|
5315
7886
|
setHoverPos({
|
|
5316
7887
|
left: Math.max(8, box.left),
|
|
5317
|
-
top: Math.max(8, box.top - size.height -
|
|
7888
|
+
top: Math.max(8, box.top - size.height - 3),
|
|
5318
7889
|
width: box.width
|
|
5319
7890
|
});
|
|
5320
7891
|
}, [hoverCard, hoverPlain, hoverMouse]);
|
|
@@ -5356,10 +7927,14 @@ window.__ModuleLoader__.load({
|
|
|
5356
7927
|
状态里还是上一轮的值,直接拿来加会把上一轮的队友钱算进本轮(实测会让本轮虚高
|
|
5357
7928
|
并被粘性下限锁住)。轮次认不出来(turnKey 为 null)时同样不用它 —— 宁少不多。 */
|
|
5358
7929
|
const turnSliceUsable = subagentTurnKey !== null && subagentState.turnKey === subagentTurnKey;
|
|
5359
|
-
const subagent =
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
7930
|
+
const subagent = devData !== null
|
|
7931
|
+
/* 开发模式:两段都直接用假值(都带 ok:true、cost>0、tokens 三段、stats),
|
|
7932
|
+
所以「总计」「本轮」都看得出队友已经并进来了。 */
|
|
7933
|
+
? { session: devData.subagent.session, turn: devData.subagent.turn }
|
|
7934
|
+
: {
|
|
7935
|
+
session: subagentState.sessionPayload,
|
|
7936
|
+
turn: turnSliceUsable === true ? subagentState.turnPayload : null
|
|
7937
|
+
};
|
|
5363
7938
|
/* 底栏币种:主模型那个条目的币种(消耗最多的模型),认不出主模型才退回默认币种。
|
|
5364
7939
|
段里的「总计 / 本轮 / 余额」都读这一个值。 */
|
|
5365
7940
|
const mainModel = mainModelOf(sessionUsage);
|
|
@@ -5400,20 +7975,42 @@ window.__ModuleLoader__.load({
|
|
|
5400
7975
|
sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t, displayCurrency)
|
|
5401
7976
|
};
|
|
5402
7977
|
|
|
7978
|
+
/* 开发模式在 src 层的覆盖(**只在这一个对象里生效**,组件 state 与时钟一行不动)。
|
|
7979
|
+
来源是 fixture 的 `devSrc` 那一格(它刻意不叫 now/timeline 这些投影名,
|
|
7980
|
+
就是为了让人一眼看出"这不是投影、是 state 替代值")。四项各自的理由:
|
|
7981
|
+
· now —— status 的峰谷与 sessionTime 的 elapsed 都读它;真实 now 每秒跳,
|
|
7982
|
+
「高峰」那一支在开发模式里几乎点不到。
|
|
7983
|
+
· timeline —— 开发模式没有真实会话节点可折,给 null,用时横杠才走得通。
|
|
7984
|
+
· running 等 —— 只服务「本轮正在跑但还没调用」那一格;其余格一律空闲。
|
|
7985
|
+
· 本轮用量三件套 —— 给确定值,本轮费用那几格形态才可复现;换格即换轮,
|
|
7986
|
+
粘性下限随之作废。
|
|
7987
|
+
退出开发模式时 devData 为 null,全部退回真实值 —— 机制与原来完全一致。 */
|
|
7988
|
+
const devSrc = devData !== null && devData.devSrc !== undefined && devData.devSrc !== null
|
|
7989
|
+
? devData.devSrc
|
|
7990
|
+
: null;
|
|
7991
|
+
const srcNow = devSrc !== null && typeof devSrc.now === "number" ? devSrc.now : now;
|
|
7992
|
+
const srcTimeline = devSrc !== null ? devSrc.timeline : timeline;
|
|
7993
|
+
const srcRunning = devSrc !== null && typeof devSrc.running === "boolean" ? devSrc.running : running;
|
|
7994
|
+
const srcPartial = devSrc !== null ? devSrc.partial : partial;
|
|
7995
|
+
const srcRunningCalls = devSrc !== null && Array.isArray(devSrc.runningCalls) ? devSrc.runningCalls : runningCalls;
|
|
7996
|
+
const srcTurnUsage = devSrc !== null && devSrc.turnUsage !== undefined ? devSrc.turnUsage : turnUsage;
|
|
7997
|
+
const srcTurnUsageTurn = devSrc !== null && devSrc.turnUsageTurn !== undefined ? devSrc.turnUsageTurn : turnUsageTurn;
|
|
7998
|
+
const srcLastCostTurn = devSrc !== null && typeof devSrc.turnKey === "string" ? devSrc.turnKey : subagentTurnKey;
|
|
7999
|
+
|
|
5403
8000
|
const src = {
|
|
5404
8001
|
stats: stats, usage: usage,
|
|
5405
8002
|
sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRange,
|
|
5406
|
-
running:
|
|
5407
|
-
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline:
|
|
5408
|
-
now:
|
|
8003
|
+
running: srcRunning, partial: srcPartial, runningCalls: srcRunningCalls,
|
|
8004
|
+
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: srcTimeline,
|
|
8005
|
+
now: srcNow, balance: balance, turnUsage: srcTurnUsage, currency: displayCurrency,
|
|
5409
8006
|
models: config.models, defaultCurrency: config.currency,
|
|
5410
8007
|
pressure: pressure, breakdown: breakdown, timing: timing,
|
|
5411
8008
|
daily: daily,
|
|
5412
|
-
turnUsageTurn:
|
|
8009
|
+
turnUsageTurn: srcTurnUsageTurn, currentTurn: currentTurn,
|
|
5413
8010
|
progress: progress, subagent: subagent,
|
|
5414
8011
|
/* 「本轮」同轮粘性下限的载体:sessionId/turnKey 说明"这份下限属于谁",
|
|
5415
8012
|
lastCostHold 是个可变盒子(ref 里那个对象),段里渲染时按需更新它 —— 见 applyTurnFloor。 */
|
|
5416
|
-
sessionId: sessionId, lastCostTurn:
|
|
8013
|
+
sessionId: sessionId, lastCostTurn: srcLastCostTurn, lastCostHold: lastCostHoldRef.current,
|
|
5417
8014
|
/* 「总计」增量式账本的载体(见 applyTotalFloor 的注释) */
|
|
5418
8015
|
totalHold: totalHoldRef.current
|
|
5419
8016
|
};
|
|
@@ -5421,10 +8018,19 @@ window.__ModuleLoader__.load({
|
|
|
5421
8018
|
const views = [];
|
|
5422
8019
|
cfg.segments.filter((id) => isSegmentOn(cfg, id)).forEach((id) => {
|
|
5423
8020
|
const view = segmentView(id, src, t);
|
|
5424
|
-
/* 保留 view 上的 state
|
|
8021
|
+
/* 保留 view 上的 state(状态点靠它上色),只把缺数据的段换成占位文本。
|
|
8022
|
+
**占位段也必须可点**:`segmentView` 返回 null 时若只给 { id, text },
|
|
8023
|
+
渲染处「tip !== null 才挂 onClick」会让它变成死块 —— 用户点不开气泡,
|
|
8024
|
+
也就没法推进场景,**直接被锁死在空态里出不来**(用户实测踩到)。
|
|
8025
|
+
所以空态补一个极简气泡(标题 = 段名,一行说明 + 一行横杠),
|
|
8026
|
+
点开后 toggleTip 照常把该字段计数 +1,下一格就不是空态了。 */
|
|
5425
8027
|
views.push(view !== null && view !== undefined
|
|
5426
8028
|
? view
|
|
5427
|
-
: {
|
|
8029
|
+
: {
|
|
8030
|
+
id: id,
|
|
8031
|
+
text: segmentText(id, view, t, displayCurrency),
|
|
8032
|
+
tip: emptySegmentTip(id, view, t, displayCurrency)
|
|
8033
|
+
});
|
|
5428
8034
|
});
|
|
5429
8035
|
|
|
5430
8036
|
/* 运行状态:只用来给上下文占用圆环上色(空闲灰 / 运行绿 / 错误红 / 待审批黄) */
|
|
@@ -5435,7 +8041,24 @@ window.__ModuleLoader__.load({
|
|
|
5435
8041
|
const approvalWaiting = approvals !== undefined && approvals !== null
|
|
5436
8042
|
&& (Array.isArray(approvals) ? approvals.length > 0 : true);
|
|
5437
8043
|
const agentFailed = !agentRunning && src.lastAgentError !== undefined && src.lastAgentError !== null;
|
|
5438
|
-
|
|
8044
|
+
/* 队友分层在跑:成员一级 / 更深一级。只影响"Lead 没在跑时圆环该显示哪个颜色",
|
|
8045
|
+
不参与上面 running / error / approval 的判定(那三档现有顺序一个字不动)。 */
|
|
8046
|
+
const memberBusy = subagentTiers.team > 0;
|
|
8047
|
+
const deepBusy = subagentTiers.deep > 0;
|
|
8048
|
+
/* 开发模式下 6 种状态色必须都能点到:真实那几样(running / pendingApprovals /
|
|
8049
|
+
lastAgentError / 分层在跑)在开发模式里不会被假数据覆盖,光靠真实判定圆环
|
|
8050
|
+
永远只有一种颜色。所以只在这一处**前置**一个判断:fixture 给了 agentState 就用它,
|
|
8051
|
+
没给就走下面原封不动的真实判定。真实路径字号没改 —— 退出开发模式立刻回到真实状态。
|
|
8052
|
+
`teamRunning` / `deepRunning` 两档插在 `idle` 之前(用户明确要求的优先级):
|
|
8053
|
+
三级同时在工作时显示 **Lead 优先(绿)**,Lead 不工作了才蓝,成员也不工作了才紫。
|
|
8054
|
+
注意它们排在 approval **之后** —— 「等你点确认」比"队友在跑"更需要人看,
|
|
8055
|
+
而且这条既有顺序用户没有要求改,保持 `running > error > approval > …` 现状。 */
|
|
8056
|
+
const agentState = devData !== null && typeof devData.agentState === "string"
|
|
8057
|
+
? devData.agentState
|
|
8058
|
+
: (agentRunning ? "running"
|
|
8059
|
+
: (agentFailed ? "error"
|
|
8060
|
+
: (approvalWaiting ? "approval"
|
|
8061
|
+
: (memberBusy ? "teamRunning" : (deepBusy ? "deepRunning" : "idle")))));
|
|
5439
8062
|
/* 上下文占用是独立开关:固定在栏首、不参与排序。
|
|
5440
8063
|
拿不到占用数据时给**空环 + 横杠**,绝不整块消失 —— 会话刚发出就被中断时
|
|
5441
8064
|
provider 还没报过压力,一整块没掉,底栏就少一项,看着像插件坏了(实测 2026-10-09)。 */
|
|
@@ -5459,6 +8082,12 @@ window.__ModuleLoader__.load({
|
|
|
5459
8082
|
setTipPos(null);
|
|
5460
8083
|
return;
|
|
5461
8084
|
}
|
|
8085
|
+
/* 开发模式:**只推进被点开的那一个字段的计数**(关闭 / 换字段到这里之前就 return 了,
|
|
8086
|
+
所以只有"从关到开"这一下推进)。
|
|
8087
|
+
用户原话:「点一个,应该只变那一个啊」—— 所以这里传 `id`,只让 `devFieldScenes[id]`
|
|
8088
|
+
+1,其余字段一动不动。点余额只换余额、点总计只换总计。
|
|
8089
|
+
注意推进发生在 setTipId 之前:这一次渲染拿到的 devData 就是新数据,气泡一开就是新值。 */
|
|
8090
|
+
advanceDevBubbleScene(id);
|
|
5462
8091
|
/* 点开余额 / 峰谷用量:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
|
|
5463
8092
|
if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
|
|
5464
8093
|
if (id === "status" && dailyReloadRef.current !== null) dailyReloadRef.current();
|
|
@@ -5473,7 +8102,23 @@ window.__ModuleLoader__.load({
|
|
|
5473
8102
|
setTipPos(null);
|
|
5474
8103
|
};
|
|
5475
8104
|
/* 面板挂在触发项自己身上(当子元素),这样气泡正好出现在该项正上方 */
|
|
5476
|
-
|
|
8105
|
+
/**
|
|
8106
|
+
* 气泡面板节点。**构造逻辑一行不动**,只在最后统一用 Portal 挂到 body。
|
|
8107
|
+
*
|
|
8108
|
+
* 为什么必须 Portal(2026-10 实机才发现,光调 z-index 治不了):
|
|
8109
|
+
* 本插件的底栏是通过 `conversation.composer.dock` 注入的,也就是挂在官方的
|
|
8110
|
+
* `.an3WzG_composerSeat` **内部**,而官方给那个容器设了
|
|
8111
|
+
* `z-index:7; position:sticky` —— 于是气泡的 `z-index:105` 只能在这个
|
|
8112
|
+
* z-index:7 的层叠上下文**内部**比大小,跟外面的官方浮动控件
|
|
8113
|
+
* (「回到底部」那个圆形按钮在 `.V4vHZW_toBottomSlot` 里)根本不在同一层比较,
|
|
8114
|
+
* 数值给到 105 也压不住它(用户实测:改完按钮依旧在上面)。
|
|
8115
|
+
* 挂到 `document.body` 之后气泡成为 body 的直接子节点,105 才真正参与
|
|
8116
|
+
* 与官方 100/101 那一档的比较。
|
|
8117
|
+
* 附带好处:那个 `position:sticky` + `overflow:hidden` 的祖先不再裁剪气泡。
|
|
8118
|
+
* 取不到 createPortal 时退回就地渲染(与设置页拖拽卡片同一套兜底):
|
|
8119
|
+
* 功能照旧,只是层级问题依旧存在。
|
|
8120
|
+
*/
|
|
8121
|
+
const tipPanelInner = (tip) => {
|
|
5477
8122
|
if (tip === undefined || tip === null) return null;
|
|
5478
8123
|
if (tip.kind === "tokens") return tokenPanel(tip.panel, tip.title, tipPos, tipRef);
|
|
5479
8124
|
if (tip.kind === "credits") {
|
|
@@ -5510,6 +8155,34 @@ window.__ModuleLoader__.load({
|
|
|
5510
8155
|
hoverMove: onHoverMove
|
|
5511
8156
|
});
|
|
5512
8157
|
};
|
|
8158
|
+
/** 气泡的最终节点:就地构造,再套一层 Portal(拿不到 createPortal 时原样返回)。 */
|
|
8159
|
+
const tipPanelOf = (tip) => {
|
|
8160
|
+
const node = tipPanelInner(tip);
|
|
8161
|
+
if (node === null) return null;
|
|
8162
|
+
if (canPortal !== true) return node;
|
|
8163
|
+
if (typeof document === "undefined" || document === null) return node;
|
|
8164
|
+
if (document.body === undefined || document.body === null) return node;
|
|
8165
|
+
return reactDom.createPortal(node, document.body);
|
|
8166
|
+
};
|
|
8167
|
+
/* 圆环的弧长:**跟着"谁在跑"换数据源**,颜色与弧长是两件独立的事。
|
|
8168
|
+
用户原话:「那个条还是有上下文占用的百分比的,要增长的。所以如果有多个成员
|
|
8169
|
+
同时在运行,那么这个圆圈应该显示为同一级内上下文占用最多的那一个。成员下派的
|
|
8170
|
+
子智能体那一级别(也就是该显示紫色的那一部分),上下文占用要显示最少的那个。」
|
|
8171
|
+
即:
|
|
8172
|
+
· Lead 在跑(绿)/ error / approval / idle ⇒ 用 Lead 自己的占用 contextMeter.percent
|
|
8173
|
+
· 只有成员在跑(蓝)⇒ 用 host 算好的**成员级最大** teamContextPercent
|
|
8174
|
+
· 只有更深层在跑(紫)⇒ 用 host 算好的**深层最小** deepContextPercent
|
|
8175
|
+
「最大 / 最小」由 host 在那一级内部先算好(它才看得到每个子会话的占用),
|
|
8176
|
+
客户端只负责"切数据源",不在前端重新聚合。
|
|
8177
|
+
分层百分比为 null(老 host / 算不出)⇒ **退回 contextMeter.percent**:
|
|
8178
|
+
不能显示 0,否则圆环会突然空掉,看着像坏了。
|
|
8179
|
+
开发模式下用 fixture 自带的分层值(`devData.devSrc.agentTiers`):真实 host 在开发模式里
|
|
8180
|
+
一次请求都不发,`subagentTiers` 永远是空对象,蓝紫两格的弧长就只能靠它。 */
|
|
8181
|
+
const ringTiers = devData !== null && devData.devSrc !== undefined && devData.devSrc !== null
|
|
8182
|
+
&& devData.devSrc.agentTiers !== undefined && devData.devSrc.agentTiers !== null
|
|
8183
|
+
? devData.devSrc.agentTiers
|
|
8184
|
+
: subagentTiers;
|
|
8185
|
+
const ringPercent = ringPercentOf(contextMeter, agentState, ringTiers);
|
|
5513
8186
|
/* 圆环自己就是触发项:点开上下文占用面板;峰谷那两个字不带气泡 */
|
|
5514
8187
|
const contextTip = contextMeter === null ? null : { kind: "meter", meter: contextMeter };
|
|
5515
8188
|
const children = [];
|
|
@@ -5521,7 +8194,7 @@ window.__ModuleLoader__.load({
|
|
|
5521
8194
|
"aria-expanded": tipId === "context" ? "true" : "false",
|
|
5522
8195
|
onClick: (event) => toggleTip("context", event)
|
|
5523
8196
|
}, [
|
|
5524
|
-
contextRing(
|
|
8197
|
+
contextRing(ringPercent, agentState, contextMeter.compact),
|
|
5525
8198
|
tipId === "context" ? tipPanelOf(contextTip) : null
|
|
5526
8199
|
]));
|
|
5527
8200
|
}
|
|
@@ -5625,26 +8298,29 @@ window.__ModuleLoader__.load({
|
|
|
5625
8298
|
const t = props.t;
|
|
5626
8299
|
const cfg = useConfig();
|
|
5627
8300
|
const update = useUpdateState();
|
|
5628
|
-
/*
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
8301
|
+
/* 开发模式的状态**只读模块级那一份**(useDevState 订阅 publishDev)。
|
|
8302
|
+
以前这里是组件本地的 `devOn` useState,那是"开发模式只影响设置页"时代的做法;
|
|
8303
|
+
现在开发模式还接管了**底栏**的假数据(底栏在对话页),本地 state 会带来两个毛病:
|
|
8304
|
+
① 关掉设置页再打开,胶囊消失而底栏仍是假数据(两边不同步);
|
|
8305
|
+
② 从设置页以外的入口进出开发模式时,这里读不到。
|
|
8306
|
+
改用模块级快照后组件不再持有副本,也就不存在"两边不一致"这回事。
|
|
8307
|
+
**不给它可见开关**:入口仍是页脚版本号的「连点 5 次」(见 onVersionClick),
|
|
8308
|
+
进入后标题右侧挂一枚「开发模式」胶囊 —— 那是唯一的可见迹象。 */
|
|
8309
|
+
const devState = useDevState();
|
|
8310
|
+
const devOn = devState.on === true;
|
|
8311
|
+
|
|
5632
8312
|
/* 版本号被点击:累计连点次数,够 5 次就切换开发模式。
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
没开还调用等于每次卸载白打一次网络请求,并且会把已有的真实更新提示清掉。 */
|
|
5645
|
-
react.useEffect(() => () => {
|
|
5646
|
-
if (devMode === true) setDevMode(false);
|
|
5647
|
-
}, []);
|
|
8313
|
+
计数与时间窗都在模块级(onVersionClick);
|
|
8314
|
+
切换成功后 setDevMode 里的 publishDev 会通知订阅者,本组件因此自然重渲染 ——
|
|
8315
|
+
这里**不需要**再自己 setState。 */
|
|
8316
|
+
const onVersionClicked = () => onVersionClick();
|
|
8317
|
+
/* 这里**刻意不再有**「卸载时把开发模式关掉」的复位 effect。
|
|
8318
|
+
那条复位是旧设计的残留:那时开发模式只影响设置页那颗更新按钮,卸载后不复位会留下
|
|
8319
|
+
"按钮显示演示版本号、胶囊却没了"的状态。现在开发模式接管了**底栏的假数据**,
|
|
8320
|
+
而底栏在对话页 —— 关掉设置页就把开发模式一起关掉,等于把对话页的假数据也带没了。
|
|
8321
|
+
用户已确认:**开发模式跨页面一直存在是预期行为**,要退出就连点版本号,不是关页面。
|
|
8322
|
+
卸载时仍然要清的只有「更新中」那一步的演示定时器(见 setDevMode 里那段),
|
|
8323
|
+
它由 setDevMode 自己管着,且模块级定时器不随组件卸载泄漏。 */
|
|
5648
8324
|
|
|
5649
8325
|
/* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
|
|
5650
8326
|
原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
|
|
@@ -5792,8 +8468,13 @@ window.__ModuleLoader__.load({
|
|
|
5792
8468
|
}, [currencyOpen]);
|
|
5793
8469
|
|
|
5794
8470
|
/* 当前会话模型:底栏在模型一变时就 POST 给 host(那个 effect 依赖 sessionModel),
|
|
5795
|
-
所以 host 的投影始终最新;设置页进来读一次就够,不需要轮询。
|
|
8471
|
+
所以 host 的投影始终最新;设置页进来读一次就够,不需要轮询。
|
|
8472
|
+
**开发模式开着时一次真实请求都不发**(项目硬约束):这时底栏吃的是假数据,
|
|
8473
|
+
设置页显示的"当前会话使用"也是假的,再去问真实 host 既没意义、又破坏
|
|
8474
|
+
「开发模式纯本地」这个前提。依赖里必须带 devOn:退出开发模式后要真去取一次,
|
|
8475
|
+
否则设置页会一直停在「未识别」。 */
|
|
5796
8476
|
react.useEffect(() => {
|
|
8477
|
+
if (devOn === true) return undefined;
|
|
5797
8478
|
let alive = true;
|
|
5798
8479
|
window.fetch(ACTIVE_MODEL_URL, { cache: "no-store" })
|
|
5799
8480
|
.then((response) => response.json())
|
|
@@ -5804,7 +8485,7 @@ window.__ModuleLoader__.load({
|
|
|
5804
8485
|
})
|
|
5805
8486
|
.catch(() => { /* 拿不到就保持未识别 */ });
|
|
5806
8487
|
return () => { alive = false; };
|
|
5807
|
-
}, []);
|
|
8488
|
+
}, [devOn]);
|
|
5808
8489
|
|
|
5809
8490
|
/* ---- 价格库操作:先改草稿,点保存才写入配置 ---- */
|
|
5810
8491
|
/* 顺序来自 modelOrder:新加的模型追加在末尾 */
|
|
@@ -6484,28 +9165,7 @@ window.__ModuleLoader__.load({
|
|
|
6484
9165
|
|
|
6485
9166
|
/* --------------------------------------------------------------- apply */
|
|
6486
9167
|
|
|
6487
|
-
/*
|
|
6488
|
-
注意:测试 harness 习惯在 `exports.apply = apply;` 那一行**前**插一份自己的
|
|
6489
|
-
`exports.__test = {...}`,位置在下面这行之后 ⇒ 它们那份会覆盖这一份(这是有意的,
|
|
6490
|
-
免得两份列表互相打架);要测这里的东西,把它们加进 harness 自己那份列表即可。 */
|
|
6491
|
-
exports.__test = {
|
|
6492
|
-
nextSubagentState: nextSubagentState,
|
|
6493
|
-
applyTurnFloor: applyTurnFloor,
|
|
6494
|
-
SUBAGENT_STATE_EMPTY: SUBAGENT_STATE_EMPTY,
|
|
6495
|
-
/* 开发模式的驱动入口:测试要能直接触发连点、并读回状态,
|
|
6496
|
-
不然这段只存在于设置页组件里的逻辑没法离线验证。 */
|
|
6497
|
-
setDevMode: setDevMode,
|
|
6498
|
-
runDevPreview: runDevPreview,
|
|
6499
|
-
isDevMode: isDevMode,
|
|
6500
|
-
onVersionClick: onVersionClick,
|
|
6501
|
-
/* 更新按钮的两个点击入口:失败态只退回可重试、其余真发起更新 */
|
|
6502
|
-
onUpdateClick: onUpdateClick,
|
|
6503
|
-
dismissUpdateFailure: dismissUpdateFailure,
|
|
6504
|
-
DEV_VERSION: DEV_VERSION,
|
|
6505
|
-
DEV_FAIL_REASON: DEV_FAIL_REASON,
|
|
6506
|
-
DEV_CLICKS: DEV_CLICKS,
|
|
6507
|
-
DEV_CLICK_WINDOW_MS: DEV_CLICK_WINDOW_MS
|
|
6508
|
-
};
|
|
9168
|
+
/* 各测试用 replace(marker) 注入自己的 __test 表(插在文件末尾 exports.apply 赋值那行之前),本文件不自带表。 */
|
|
6509
9169
|
|
|
6510
9170
|
function apply(ctx) {
|
|
6511
9171
|
/* 注意:这里**不保存 ctx**。更新走本插件自己的 host 路由
|