dsh-desktop-statusbar 2.0.2 → 2.1.1
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/LICENSE +21 -77
- package/README.en.md +108 -96
- package/README.md +108 -96
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/docs/statusbar.en.png +0 -0
- package/docs/statusbar.png +0 -0
- package/icon.svg +10 -0
- package/lib/balance-api.js +214 -16
- package/lib/client.js +2781 -333
- package/lib/index.js +2728 -158
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -62
package/lib/client.js
CHANGED
|
@@ -23,6 +23,17 @@ window.__ModuleLoader__.load({
|
|
|
23
23
|
|
|
24
24
|
const react = require("react");
|
|
25
25
|
const h = react.createElement;
|
|
26
|
+
/* 跟手浮层要 portal 到 body(见 dragCard 处的长注释)。react-dom 由 DSH 的客户端
|
|
27
|
+
模块加载器提供(与 react 同一个表);取不到就退回就地渲染,功能不受影响,
|
|
28
|
+
只是那层玻璃在场时位置会偏 —— 所以这里绝不因为缺它而抛错。 */
|
|
29
|
+
const reactDom = (() => {
|
|
30
|
+
try {
|
|
31
|
+
return require("react-dom");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
const canPortal = reactDom !== null && reactDom !== undefined && typeof reactDom.createPortal === "function";
|
|
26
37
|
|
|
27
38
|
const NS = "dsh-desktop-statusbar";
|
|
28
39
|
const STYLE_TAG_ID = "dsh-desktop-statusbar/styles";
|
|
@@ -33,17 +44,33 @@ window.__ModuleLoader__.load({
|
|
|
33
44
|
const ACTIVE_MODEL_URL = "/dsh-desktop-statusbar/api/active-model";
|
|
34
45
|
/* 余额轮询:host 端不再缓存,所以这个间隔就是底栏的更新粒度(1 分钟一次) */
|
|
35
46
|
const BALANCE_POLL_MS = 60000;
|
|
47
|
+
const DAILY_URL = "/dsh-desktop-statusbar/api/daily-usage";
|
|
48
|
+
/** 气泡定位诊断的上报端点(host 落到 tip-diag.log)。 */
|
|
49
|
+
const TIP_DIAG_URL = "/dsh-desktop-statusbar/api/tip-diag";
|
|
36
50
|
const PRICES_URL = "/dsh-desktop-statusbar/api/prices";
|
|
37
51
|
const HOLIDAYS_URL = "/dsh-desktop-statusbar/api/holidays";
|
|
52
|
+
const VERSION_URL = "/dsh-desktop-statusbar/api/version";
|
|
53
|
+
/* 子智能体(Agent Teams 的队友)用量的聚合端点:队友是**独立会话**,它的消耗不在本会话
|
|
54
|
+
的投影里,只能由 host 按 parentSession 找出来,「总计」「本轮」才真的把队友算进去。
|
|
55
|
+
带 since(毫秒)= 只要那个时刻之后的用量,客户端拿它取「本轮」这一段。 */
|
|
56
|
+
const SUBAGENT_URL = "/dsh-desktop-statusbar/api/subagent-usage";
|
|
57
|
+
/** 队友用量多久重拉一次:底栏本来就有一个**每秒**跳的 now 时钟(活跃时),
|
|
58
|
+
所以这里取 1 秒 —— 用户要的「实时跳动」;也不必为它新增定时器。
|
|
59
|
+
没有变化时后台只比对文件的 mtime/size(实测几毫秒),每秒重拉的开销可以忽略。 */
|
|
60
|
+
const SUBAGENT_REFRESH_MS = 1000;
|
|
61
|
+
/** 聚合请求自己的超时:接口卡住时不能把「总计」一直吊着,超时就退回只算本会话。 */
|
|
62
|
+
const SUBAGENT_TIMEOUT_MS = 8000;
|
|
38
63
|
/**
|
|
39
64
|
* 配置结构版本。
|
|
40
65
|
* v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
|
|
41
66
|
*/
|
|
42
67
|
const CONFIG_VERSION = 6;
|
|
43
68
|
/** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
|
|
44
|
-
const VERSION = "2.
|
|
69
|
+
const VERSION = "2.1.1";
|
|
45
70
|
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
46
71
|
const PRICE_VERSION = 2;
|
|
72
|
+
/** 项目主页:设置页那个「去 GitHub 点 star」按钮跳这里(与 package.json 的 repository 同址)。 */
|
|
73
|
+
const REPO_URL = "https://github.com/raphael-y7/dsh-desktop-statusbar";
|
|
47
74
|
|
|
48
75
|
/* ------------------------------------------------------------ 价格库 */
|
|
49
76
|
|
|
@@ -76,7 +103,111 @@ window.__ModuleLoader__.load({
|
|
|
76
103
|
return currency === "USD" ? DEFAULT_PRICES_USD : DEFAULT_PRICES;
|
|
77
104
|
}
|
|
78
105
|
|
|
79
|
-
|
|
106
|
+
/** 一组数字的中位数(偶数个取中间两个的平均)。 */
|
|
107
|
+
function medianNumber(list) {
|
|
108
|
+
if (list.length === 0) return null;
|
|
109
|
+
const sorted = list.slice().sort((a, b) => a - b);
|
|
110
|
+
const mid = Math.floor(sorted.length / 2);
|
|
111
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 人民币 ↔ 美元的统一折算率,**从两套官方参考价表推导**(不硬编码任何来历不明的数字):
|
|
116
|
+
* ① 对每个"两张表里都有"的模型,取它每一格非零价格在两表中的比值,得到该模型的中位比值;
|
|
117
|
+
* ② 再对所有模型的中位比值取中位数。
|
|
118
|
+
* 当前两张表(2026-09 核对)推出:flash 6.666667、v4-pro 6.818182 → **6.742424**。
|
|
119
|
+
* 用法:`人民币金额 / REFERENCE_RATE = 美元金额`,反向乘回去。
|
|
120
|
+
* 仅供**显示层折算**与"切换条目币种时换算该条目数值",绝不改写价格库、也不碰台账。
|
|
121
|
+
*/
|
|
122
|
+
const REFERENCE_RATE = (() => {
|
|
123
|
+
const perModel = [];
|
|
124
|
+
Object.keys(DEFAULT_PRICES).forEach((name) => {
|
|
125
|
+
const usd = DEFAULT_PRICES_USD[name];
|
|
126
|
+
if (usd === undefined || usd === null) return;
|
|
127
|
+
const ratios = [];
|
|
128
|
+
["peak", "offPeak"].forEach((tier) => {
|
|
129
|
+
const left = DEFAULT_PRICES[name][tier];
|
|
130
|
+
const right = usd[tier];
|
|
131
|
+
if (left === undefined || left === null || right === undefined || right === null) return;
|
|
132
|
+
["input", "cacheRead", "cacheWrite", "output"].forEach((field) => {
|
|
133
|
+
const a = Number(left[field]);
|
|
134
|
+
const b = Number(right[field]);
|
|
135
|
+
if (Number.isFinite(a) === true && Number.isFinite(b) === true && a > 0 && b > 0) ratios.push(a / b);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
const one = medianNumber(ratios);
|
|
139
|
+
if (one !== null) perModel.push(one);
|
|
140
|
+
});
|
|
141
|
+
return medianNumber(perModel);
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
/** 币种只认 CNY / USD,其余一律当人民币(与配置读取同一口径)。 */
|
|
145
|
+
function normalizeCurrency(raw) {
|
|
146
|
+
return raw === "USD" ? "USD" : "CNY";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 一个模型条目的币种:`models[name].currency`;没写就退回默认币种(`config.currency`)。
|
|
151
|
+
* 用 pickPriceKey 匹配(与计价同一套规则),所以"会话里的模型名"和"价格库的键名"
|
|
152
|
+
* 不逐字相同也能找到同一个条目。
|
|
153
|
+
*/
|
|
154
|
+
function entryCurrencyOf(book, model, defaultCurrency) {
|
|
155
|
+
const fallback = normalizeCurrency(defaultCurrency);
|
|
156
|
+
if (book === null || book === undefined || typeof book !== "object" || typeof model !== "string") return fallback;
|
|
157
|
+
const key = pickPriceKey(book, model);
|
|
158
|
+
if (key === null) return fallback;
|
|
159
|
+
const entry = book[key];
|
|
160
|
+
if (entry === null || entry === undefined || typeof entry !== "object") return fallback;
|
|
161
|
+
return entry.currency === undefined || entry.currency === null ? fallback : normalizeCurrency(entry.currency);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 金额换币种 —— **只用于底栏显示层**(混币折成一个数字)。
|
|
166
|
+
*
|
|
167
|
+
* 绝不用于"切换条目币种时改价格库数值":那是用户明确反对的做法(官方价是定死的两套,
|
|
168
|
+
* 自建/改过的价该填多少由用户自己填,工具不按汇率替他算)。见 `setModelCurrency`。
|
|
169
|
+
* 同一个币种原样返回;推导不出比率时也不动。
|
|
170
|
+
*/
|
|
171
|
+
function convertCurrency(amount, from, to) {
|
|
172
|
+
const value = Number(amount);
|
|
173
|
+
if (Number.isFinite(value) !== true) return amount;
|
|
174
|
+
const a = normalizeCurrency(from);
|
|
175
|
+
const b = normalizeCurrency(to);
|
|
176
|
+
if (a === b || REFERENCE_RATE === null || Number.isFinite(REFERENCE_RATE) !== true || REFERENCE_RATE <= 0) return value;
|
|
177
|
+
/* 比率是"人民币 / 美元":CNY → USD 就除,USD → CNY 就乘 */
|
|
178
|
+
return a === "CNY" ? value / REFERENCE_RATE : value * REFERENCE_RATE;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 一格未填时的占位:用 `null` 表示"这里还没填过",与用户明确填的 0 区分开。 */
|
|
182
|
+
const EMPTY_TIER = { input: null, cacheRead: null, cacheWrite: null, output: null };
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 单个价格格:**没填过就保留成 `null`**(`null` / `undefined` / 空字符串都算没填),
|
|
186
|
+
* 其余转成数字 —— 用户明确填的 0 就是数字 0,不会被当成"没填"。
|
|
187
|
+
* 计价时 `null` 参与算术会当 0 用,所以不影响算钱,只影响编辑框要不要留空。
|
|
188
|
+
*/
|
|
189
|
+
function tierValue(raw) {
|
|
190
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
191
|
+
const n = Number(raw);
|
|
192
|
+
/* 非法输入(手改配置、粘贴进字母)当作"没填",不要压成 0 —— 压成 0 会被当成
|
|
193
|
+
"用户明确把价定成了 0",第二轮匹配就会拿它算钱 */
|
|
194
|
+
return Number.isFinite(n) === true ? n : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 峰谷联动:官方口径里空闲价正好是高峰价的一半,所以用户只需填一档,
|
|
199
|
+
* 另一档按比例算出来 —— 填高峰 → 空闲 ÷2;填空闲 → 高峰 ×2。
|
|
200
|
+
* `raw` 是输入框里的字符串:空串原样返回(清空一档时另一档也清空),
|
|
201
|
+
* 非法输入返回空串(宁可留空让用户重填,也不要写进一个错价)。
|
|
202
|
+
*/
|
|
203
|
+
function linkedTierValue(raw, fromPeak) {
|
|
204
|
+
if (raw === "") return "";
|
|
205
|
+
const n = Number(raw);
|
|
206
|
+
if (Number.isFinite(n) !== true) return "";
|
|
207
|
+
const linked = fromPeak === true ? n / 2 : n * 2;
|
|
208
|
+
/* 去掉浮点尾巴(0.07×2 之类),最多留 6 位小数 */
|
|
209
|
+
return String(Math.round(linked * 1e6) / 1e6);
|
|
210
|
+
}
|
|
80
211
|
|
|
81
212
|
/** 把一条价格规范化成 { peak, offPeak };旧的平铺结构视为两档同价。 */
|
|
82
213
|
/** 两档数值是否逐项相同:用来识别被写坏的「空闲价 = 高峰价」。 */
|
|
@@ -88,12 +219,8 @@ window.__ModuleLoader__.load({
|
|
|
88
219
|
|
|
89
220
|
function normalizePrice(entry) {
|
|
90
221
|
if (entry === null || entry === undefined || typeof entry !== "object") return null;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
cacheRead: Number(value.cacheRead) || 0,
|
|
94
|
-
cacheWrite: Number(value.cacheWrite) || 0,
|
|
95
|
-
output: Number(value.output) || 0
|
|
96
|
-
});
|
|
222
|
+
/* 直接复用 normalizeTier:两处口径必须一致,否则读回价格库时会把"没填"洗成 0 */
|
|
223
|
+
const flatten = (value) => normalizeTier(value);
|
|
97
224
|
if (entry.peak !== undefined || entry.offPeak !== undefined) {
|
|
98
225
|
const peak = entry.peak === undefined || entry.peak === null ? null : flatten(entry.peak);
|
|
99
226
|
const offPeak = entry.offPeak === undefined || entry.offPeak === null ? null : flatten(entry.offPeak);
|
|
@@ -106,17 +233,136 @@ window.__ModuleLoader__.load({
|
|
|
106
233
|
return { peak: flat, offPeak: flat };
|
|
107
234
|
}
|
|
108
235
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
236
|
+
/* ------------------------------------------------------------ 模型名匹配 */
|
|
237
|
+
/**
|
|
238
|
+
* 模型名切成词元:忽略大小写,分隔符(- . _ 空格等)都当边界。
|
|
239
|
+
* `DeepSeek-V4.1-Flash-SG` → ["deepseek","v4","1","flash","sg"]
|
|
240
|
+
*/
|
|
241
|
+
function modelTokens(name) {
|
|
242
|
+
return typeof name === "string"
|
|
243
|
+
? name.toLowerCase().split(/[^a-z0-9]+/).filter((one) => one.length > 0)
|
|
244
|
+
: [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 词元是否对得上:**完全相等**(大小写已经在切词时归一)。 */
|
|
248
|
+
function tokenMatches(needle, hay) {
|
|
249
|
+
return needle === hay;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 一条价格是不是"什么都没填":两档四项加起来是 0。用来把未定价的条目挡在匹配之外。 */
|
|
253
|
+
function priceIsBlank(entry) {
|
|
254
|
+
const price = normalizePrice(entry);
|
|
255
|
+
if (price === null) return true;
|
|
256
|
+
const sum = (tier) => tier.input + tier.cacheRead + tier.cacheWrite + tier.output;
|
|
257
|
+
return sum(price.peak) + sum(price.offPeak) === 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 这条价格里**有没有任何一格是数字**(= 用户真的填过)。
|
|
262
|
+
* 八格全是 `null`(只在设置页点过「添加」、还没填价就收起了)的条目,**两轮匹配都不该算数**:
|
|
263
|
+
* 否则第二轮兜底会让它命中同名模型,把该模型的费用静默算成 ¥0(实测缺陷)。
|
|
264
|
+
* "明确填了 0"的条目有数字格,第二轮兜底照常生效。
|
|
265
|
+
*/
|
|
266
|
+
function hasAnyPriceValue(entry) {
|
|
267
|
+
if (entry === null || entry === undefined || typeof entry !== "object") return false;
|
|
268
|
+
const price = normalizePrice(entry);
|
|
269
|
+
if (price === null) return false;
|
|
270
|
+
const tiers = [price.peak, price.offPeak];
|
|
271
|
+
for (let i = 0; i < tiers.length; i += 1) {
|
|
272
|
+
const one = tiers[i];
|
|
273
|
+
if (one === null || one === undefined) continue;
|
|
274
|
+
if (typeof one.input === "number" || typeof one.cacheRead === "number"
|
|
275
|
+
|| typeof one.cacheWrite === "number" || typeof one.output === "number") return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 一轮匹配:① 同名单独先行;②③(子串命中与词元全含命中)**合并评选**,
|
|
282
|
+
* 按 weight = 词元数 * 1000 + 键名长度 取最具体的那个 —— 不能分档跑,
|
|
283
|
+
* 否则模型名里含一个短键(如 flash)就会把更具体的长键挤掉。
|
|
284
|
+
* 含数字的键只允许子串命中,不走词元档。
|
|
285
|
+
* skipBlank=true 时跳过"还没定价"(整条全 0)的条目。
|
|
286
|
+
*/
|
|
287
|
+
function matchPriceKeyOnce(book, model, skipBlank) {
|
|
115
288
|
const keys = Object.keys(book);
|
|
289
|
+
const lower = model.toLowerCase();
|
|
290
|
+
/* 空键(手改配置或第三方 POST 才可能出现)不参选;八格全 null 的条目两轮都不参选;
|
|
291
|
+
skipBlank 那一轮再额外跳过"有数字、但总和为 0"的条目 */
|
|
292
|
+
const usable = (key) => key.length > 0
|
|
293
|
+
&& hasAnyPriceValue(book[key]) === true
|
|
294
|
+
&& (skipBlank !== true || priceIsBlank(book[key]) !== true);
|
|
295
|
+
/* ① 同名:单独先行,最高优先 */
|
|
116
296
|
for (let i = 0; i < keys.length; i += 1) {
|
|
117
|
-
if (
|
|
297
|
+
if (keys[i].toLowerCase() === lower && usable(keys[i])) return keys[i];
|
|
118
298
|
}
|
|
119
|
-
|
|
299
|
+
/* ②③ 一次评选:子串命中的键与词元全含命中的键放在一起比 weight,取最具体的那个。
|
|
300
|
+
不能按"先全部子串、再全部词元"两轮跑 —— 那样模型名里只要含一个短键(如 flash),
|
|
301
|
+
就会把更具体的 deepseek-flash(只能靠词元档命中)挤掉。
|
|
302
|
+
含数字的键(如 deepseek-v4-pro)只允许子串命中,不走词元档。 */
|
|
303
|
+
const tokens = modelTokens(model);
|
|
304
|
+
const weightOf = (key) => modelTokens(key).length * 1000 + key.length;
|
|
305
|
+
let best = null;
|
|
306
|
+
let bestWeight = -1;
|
|
307
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
308
|
+
const key = keys[i];
|
|
309
|
+
if (usable(key) !== true) continue;
|
|
310
|
+
let hit = lower.indexOf(key.toLowerCase()) !== -1;
|
|
311
|
+
if (hit !== true && /[0-9]/.test(key) !== true) {
|
|
312
|
+
const want = modelTokens(key);
|
|
313
|
+
if (want.length > 0) {
|
|
314
|
+
hit = true;
|
|
315
|
+
for (let k = 0; k < want.length; k += 1) {
|
|
316
|
+
let found = false;
|
|
317
|
+
for (let m = 0; m < tokens.length; m += 1) {
|
|
318
|
+
if (tokenMatches(want[k], tokens[m]) === true) { found = true; break; }
|
|
319
|
+
}
|
|
320
|
+
if (found !== true) { hit = false; break; }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (hit !== true) continue;
|
|
325
|
+
const weight = weightOf(key);
|
|
326
|
+
if (weight > bestWeight) { bestWeight = weight; best = key; }
|
|
327
|
+
}
|
|
328
|
+
return best;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 价格键 → 模型名:① 同名最优先;② 子串命中与③ 词元全含命中**合并评选**,取最具体的那个;
|
|
333
|
+
* 名字里含数字的键(如 `deepseek-v4-pro`)只认子串,不做词元匹配 ——
|
|
334
|
+
* 版本号不同的 pro 是另一个模型、另一个价,不该被旧版号吃掉。
|
|
335
|
+
*
|
|
336
|
+
* 为什么必须有词元档:默认键叫 `deepseek-flash`,而会话日志里记的是
|
|
337
|
+
* `deepseek-v4.1-flash-sg` / `deepseek-v41-flash` —— 版本号插在中间,子串匹配命不中,
|
|
338
|
+
* 于是默认价对这类模型永远不生效、每笔都被当成"没有单价"。
|
|
339
|
+
*
|
|
340
|
+
* **分两轮**:第一轮跳过"还没填价"(整条全 0)的条目 —— 用户加了条目但没填价,
|
|
341
|
+
* 不该把该模型的费用算成 0;第一轮一无所获时才放开,让"明确把某模型定为 0 价"仍算得出来。
|
|
342
|
+
* **host 侧 lib/index.js 的 pickPriceKey 必须与这里逐条同规则**,否则底栏与活跃总览会算出两个价。
|
|
343
|
+
*/
|
|
344
|
+
function pickPriceKey(book, model) {
|
|
345
|
+
if (book === null || book === undefined || typeof book !== "object") return null;
|
|
346
|
+
if (typeof model !== "string" || model.length === 0) return null;
|
|
347
|
+
if (Object.keys(book).length === 0) return null;
|
|
348
|
+
const strict = matchPriceKeyOnce(book, model, true);
|
|
349
|
+
if (strict !== null) return strict;
|
|
350
|
+
return matchPriceKeyOnce(book, model, false);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 按当前价格库查模型单价(规范化成峰谷两档);匹配规则见 pickPriceKey;找不到返回 null。
|
|
355
|
+
* 顺带带上 `tiered`:**没勾「峰谷计价」的条目是全天一个价**,计价时必须两档都取 peak ——
|
|
356
|
+
* 否则空闲时段会挑到那个没填的档,把这段时间的费用全算成 0(实测缺陷)。
|
|
357
|
+
*/
|
|
358
|
+
function priceOf(model) {
|
|
359
|
+
const book = config.models === undefined || config.models === null ? {} : config.models;
|
|
360
|
+
const key = pickPriceKey(book, model);
|
|
361
|
+
if (key === null) return null;
|
|
362
|
+
const price = normalizePrice(book[key]);
|
|
363
|
+
if (price === null) return null;
|
|
364
|
+
price.tiered = isTiered(book[key]);
|
|
365
|
+
return price;
|
|
120
366
|
}
|
|
121
367
|
|
|
122
368
|
/**
|
|
@@ -241,17 +487,17 @@ window.__ModuleLoader__.load({
|
|
|
241
487
|
return dayStart + 33 * 3600 * 1000 - at;
|
|
242
488
|
}
|
|
243
489
|
|
|
244
|
-
/**
|
|
490
|
+
/**
|
|
491
|
+
* 价格库是不是"还没填过":**所有条目的所有格子都是空的**才算缺失。
|
|
492
|
+
* 不能再用"数值总和为 0"判断 —— 用户明确填 0(表示免费)也是一种填写,
|
|
493
|
+
* 旧判据会把这种库整个换成官方参考价,用户自己的条目直接消失(实测缺陷)。
|
|
494
|
+
*/
|
|
245
495
|
function pricesMissing(book) {
|
|
246
496
|
if (book === null || book === undefined || typeof book !== "object") return true;
|
|
247
497
|
const names = Object.keys(book);
|
|
248
498
|
if (names.length === 0) return true;
|
|
249
499
|
for (let i = 0; i < names.length; i += 1) {
|
|
250
|
-
|
|
251
|
-
if (price === null) continue;
|
|
252
|
-
const total = price.peak.input + price.peak.cacheRead + price.peak.cacheWrite + price.peak.output
|
|
253
|
-
+ price.offPeak.input + price.offPeak.cacheRead + price.offPeak.cacheWrite + price.offPeak.output;
|
|
254
|
-
if (total > 0) return false;
|
|
500
|
+
if (hasAnyPriceValue(book[names[i]]) === true) return false;
|
|
255
501
|
}
|
|
256
502
|
return true;
|
|
257
503
|
}
|
|
@@ -310,6 +556,50 @@ window.__ModuleLoader__.load({
|
|
|
310
556
|
return currency === "USD" ? "$" : "¥";
|
|
311
557
|
}
|
|
312
558
|
|
|
559
|
+
/**
|
|
560
|
+
* 当前会话的**主模型**:消耗最多(四项 token 之和最大)的那个模型名。
|
|
561
|
+
*
|
|
562
|
+
* 为什么用累计 token 而不是"最近一次调用":一次调用就让币种来回跳会晃;
|
|
563
|
+
* 累计量只增不减,币种因此是稳的。并列时按名字排序定一个,保证同输入同输出。
|
|
564
|
+
* 没有任何正用量(拿不到会话聚合、或全是 0)时返回 `null` —— 调用方退回默认币种。
|
|
565
|
+
*/
|
|
566
|
+
function mainModelOf(sessionUsage) {
|
|
567
|
+
if (sessionUsage === null || sessionUsage === undefined || typeof sessionUsage !== "object") return null;
|
|
568
|
+
const models = sessionUsage.models;
|
|
569
|
+
if (models === null || models === undefined || typeof models !== "object") return null;
|
|
570
|
+
let best = null;
|
|
571
|
+
let bestTotal = 0;
|
|
572
|
+
Object.keys(models).forEach((name) => {
|
|
573
|
+
const one = models[name];
|
|
574
|
+
if (one === null || one === undefined || typeof one !== "object") return;
|
|
575
|
+
const total = (Number(one.input) || 0) + (Number(one.cacheRead) || 0)
|
|
576
|
+
+ (Number(one.cacheWrite) || 0) + (Number(one.output) || 0);
|
|
577
|
+
if (total <= 0) return;
|
|
578
|
+
if (total > bestTotal || (total === bestTotal && best !== null && name < best)) {
|
|
579
|
+
best = name;
|
|
580
|
+
bestTotal = total;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
return best;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 底栏(总计 / 本轮)的显示币种 = **主模型那个条目的币种**;认不出主模型就退回
|
|
588
|
+
* "新增条目的默认币种"(`config.currency`)。与"我还没用过那个模型"无关的全局币种,
|
|
589
|
+
* 到这里彻底退出底栏显示。
|
|
590
|
+
*/
|
|
591
|
+
function displayCurrencyOf(book, defaultCurrency, sessionUsage) {
|
|
592
|
+
const main = mainModelOf(sessionUsage);
|
|
593
|
+
if (main === null) return normalizeCurrency(defaultCurrency);
|
|
594
|
+
return entryCurrencyOf(book, main, defaultCurrency);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** 按某模型条目单价算出来的金额 → 折成显示币种(只用于显示,不回写任何地方)。 */
|
|
598
|
+
function toDisplayCurrency(amount, book, model, displayCurrency, defaultCurrency) {
|
|
599
|
+
if (amount === null || amount === undefined) return amount;
|
|
600
|
+
return convertCurrency(amount, entryCurrencyOf(book, model, defaultCurrency), displayCurrency);
|
|
601
|
+
}
|
|
602
|
+
|
|
313
603
|
/** 模型名显示用:deepseek → DeepSeek、glm → GLM、gpt → GPT,其余按 - 分段首字母大写。 */
|
|
314
604
|
const MODEL_NAME_SPECIAL = { deepseek: "DeepSeek", glm: "GLM", gpt: "GPT" };
|
|
315
605
|
function displayModelName(name) {
|
|
@@ -327,6 +617,22 @@ window.__ModuleLoader__.load({
|
|
|
327
617
|
return entry === null || entry === undefined || entry.tiered !== false;
|
|
328
618
|
}
|
|
329
619
|
|
|
620
|
+
/**
|
|
621
|
+
* 旧版本遗留的"全 0 条目":八个价格格全是**数字 0**、没有任何 `null`。
|
|
622
|
+
* 那时的数据分不清"没填"与"填了 0",只能按没填过处理(编辑时整栏留空)。
|
|
623
|
+
* 新版数据里没填的格子是 `null`,所以只要有一格是 `null` 就不会被误判。
|
|
624
|
+
*/
|
|
625
|
+
function isLegacyZeroEntry(entry) {
|
|
626
|
+
const price = normalizePrice(entry);
|
|
627
|
+
if (price === null) return false;
|
|
628
|
+
const tiers = [price.peak, price.offPeak];
|
|
629
|
+
for (let i = 0; i < tiers.length; i += 1) {
|
|
630
|
+
const one = tiers[i];
|
|
631
|
+
if (one.input !== 0 || one.cacheRead !== 0 || one.cacheWrite !== 0 || one.output !== 0) return false;
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
330
636
|
|
|
331
637
|
/**
|
|
332
638
|
* 按价格库算一段用量的费用(按给定时刻定峰谷),返回**未舍入的数值**。
|
|
@@ -336,7 +642,8 @@ window.__ModuleLoader__.load({
|
|
|
336
642
|
function costOf(bucket, model, at) {
|
|
337
643
|
const price = priceOf(model);
|
|
338
644
|
if (price === null || bucket === undefined || bucket === null) return null;
|
|
339
|
-
|
|
645
|
+
/* 没勾「峰谷计价」= 全天一个价:两档都取 peak,别在空闲时段挑到空档、把费用算成 0 */
|
|
646
|
+
const tier = price.tiered === false ? price.peak : (isPeakTime(at) ? price.peak : price.offPeak);
|
|
340
647
|
return ((bucket.input || 0) * tier.input
|
|
341
648
|
+ (bucket.cacheRead || 0) * tier.cacheRead
|
|
342
649
|
+ (bucket.cacheWrite || 0) * tier.cacheWrite
|
|
@@ -354,6 +661,207 @@ window.__ModuleLoader__.load({
|
|
|
354
661
|
return n.toFixed(2);
|
|
355
662
|
}
|
|
356
663
|
|
|
664
|
+
/**
|
|
665
|
+
* 队友那一段的花费 → 折成显示币种。
|
|
666
|
+
*
|
|
667
|
+
* host 新版本会额外给 `total.costByCurrency`(按每个队友自己的条目币种分桶汇总),
|
|
668
|
+
* 这里逐桶折算再相加 —— 队友用了另一种币种时这才准。
|
|
669
|
+
* 老版本只有 `total.cost`(没带币种信息),就当作它已经是显示币种、不做折算:
|
|
670
|
+
* 队友与会话用同一个模型是最常见的情形,那时两者币种本来就相同。
|
|
671
|
+
*/
|
|
672
|
+
function subagentDisplayCost(total, displayCurrency) {
|
|
673
|
+
if (total === null || total === undefined || typeof total !== "object") return 0;
|
|
674
|
+
const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
|
|
675
|
+
const byCurrency = total.costByCurrency;
|
|
676
|
+
if (byCurrency !== null && byCurrency !== undefined && typeof byCurrency === "object") {
|
|
677
|
+
let sum = 0;
|
|
678
|
+
Object.keys(byCurrency).forEach((code) => {
|
|
679
|
+
sum += convertCurrency(positive(byCurrency[code]), code, displayCurrency);
|
|
680
|
+
});
|
|
681
|
+
return sum;
|
|
682
|
+
}
|
|
683
|
+
return positive(total.cost);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 从队友的原始响应里取某一段的费用,并折成显示币种(拿不到就是 0)。 */
|
|
687
|
+
function subagentCostOf(subagent, which, displayCurrency) {
|
|
688
|
+
const payload = subagent === null || subagent === undefined ? null : subagent[which];
|
|
689
|
+
if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return 0;
|
|
690
|
+
return subagentDisplayCost(payload.total, displayCurrency);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* host 返回的队友(Agent Teams 子智能体)用量 → 界面要的两个数:费用与令牌三段。
|
|
695
|
+
* 队友是**独立会话**,它的用量 host 按 parentSession 现算;拿不到(还在加载、请求失败、
|
|
696
|
+
* 后端老版本、形状不对)时一律给 0 —— 界面就退回"只算本会话",不报错也不显示额外文字。
|
|
697
|
+
* 注意:这个形状(cost / cached / missInput / output)是既有约定,别往里塞别的字段 ——
|
|
698
|
+
* 「首字 / 解码」那两组的累计走 subagentStatsOf,两者互不影响。
|
|
699
|
+
*/
|
|
700
|
+
function subagentSummary(payload) {
|
|
701
|
+
const none = { cost: 0, cached: 0, missInput: 0, output: 0 };
|
|
702
|
+
if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return none;
|
|
703
|
+
const total = payload.total;
|
|
704
|
+
if (total === null || total === undefined || typeof total !== "object") return none;
|
|
705
|
+
const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
|
|
706
|
+
const tokens = total.tokens !== null && total.tokens !== undefined && typeof total.tokens === "object"
|
|
707
|
+
? total.tokens
|
|
708
|
+
: {};
|
|
709
|
+
/* 令牌三段按本面板自己的口径合并:命中缓存 = cacheRead;未命中 = 未缓存输入 + 缓存写入
|
|
710
|
+
(与 billableParts 一致,否则总计用量会对不上三行之和);输出 = output。 */
|
|
711
|
+
return {
|
|
712
|
+
cost: positive(total.cost),
|
|
713
|
+
cached: positive(tokens.cacheRead),
|
|
714
|
+
missInput: positive(tokens.input) + positive(tokens.cacheWrite),
|
|
715
|
+
output: positive(tokens.output)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 队友的首字 / 解码累计(`total.stats`)—— 只给"会话级"那三项(首字平均、综合速度)用。
|
|
721
|
+
* 单独一个函数而不是并进 subagentSummary:后者那个四字段形状是既有约定,
|
|
722
|
+
* 混进去会让别处(含测试)的深比较全变。拿不到就全 0,等于不加。
|
|
723
|
+
*/
|
|
724
|
+
function subagentStatsOf(subagentData) {
|
|
725
|
+
const none = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 };
|
|
726
|
+
const payload = subagentData === null || subagentData === undefined ? null : subagentData.session;
|
|
727
|
+
if (payload === null || payload === undefined || typeof payload !== "object" || payload.ok !== true) return none;
|
|
728
|
+
const total = payload.total;
|
|
729
|
+
if (total === null || total === undefined || typeof total !== "object") return none;
|
|
730
|
+
const stats = total.stats;
|
|
731
|
+
if (stats === null || stats === undefined || typeof stats !== "object") return none;
|
|
732
|
+
const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
|
|
733
|
+
return {
|
|
734
|
+
ttftMs: positive(stats.ttftMs),
|
|
735
|
+
ttftSteps: positive(stats.ttftSteps),
|
|
736
|
+
decodeMs: positive(stats.decodeMs),
|
|
737
|
+
decodeTokens: positive(stats.decodeTokens)
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* 会话级那三项(首字平均 / 综合速度 / 缓存命中率)用的合并量 = 本会话 + 全部子会话。
|
|
743
|
+
* 「本轮」那一份**只算本页面**(用户明确要求),所以不走这里。
|
|
744
|
+
*
|
|
745
|
+
* 首字与解码都是"总量 / 样本数"的比值,所以**按组**合并:某一组只要缺了分子或分母
|
|
746
|
+
* (拿不到、为 0、非数)就整组不计 —— 只加半组会把平均值算歪(例如只有 ttftMs 没有
|
|
747
|
+
* ttftSteps)。host 只会整组给,这里是防坏响应/手改数据。
|
|
748
|
+
* 纯函数、只做几次加法:客户端每秒重算也不会成为负担。
|
|
749
|
+
*/
|
|
750
|
+
function mergedSessionStats(stats, sub) {
|
|
751
|
+
const value = (one, key) => {
|
|
752
|
+
const raw = one === null || one === undefined ? 0 : Number(one[key]);
|
|
753
|
+
return Number.isFinite(raw) === true && raw > 0 ? raw : 0;
|
|
754
|
+
};
|
|
755
|
+
const sessionTtftMs = value(stats, "ttftMs");
|
|
756
|
+
const sessionTtftSteps = value(stats, "ttftSteps");
|
|
757
|
+
const sessionDecodeMs = value(stats, "decodeMs");
|
|
758
|
+
const sessionDecodeTokens = value(stats, "decodeTokens");
|
|
759
|
+
const sessionTtft = sessionTtftMs > 0 && sessionTtftSteps > 0;
|
|
760
|
+
const sessionDecode = sessionDecodeMs > 0 && sessionDecodeTokens > 0;
|
|
761
|
+
const subTtft = sub.ttftMs > 0 && sub.ttftSteps > 0;
|
|
762
|
+
const subDecode = sub.decodeMs > 0 && sub.decodeTokens > 0;
|
|
763
|
+
return {
|
|
764
|
+
ttftMs: (sessionTtft ? sessionTtftMs : 0) + (subTtft ? sub.ttftMs : 0),
|
|
765
|
+
ttftSteps: (sessionTtft ? sessionTtftSteps : 0) + (subTtft ? sub.ttftSteps : 0),
|
|
766
|
+
decodeMs: (sessionDecode ? sessionDecodeMs : 0) + (subDecode ? sub.decodeMs : 0),
|
|
767
|
+
decodeTokens: (sessionDecode ? sessionDecodeTokens : 0) + (subDecode ? sub.decodeTokens : 0)
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 从 host 拉回来的队友数据里取一段:`session` = 整会话(总计用),`turn` = 本轮窗口(本轮用)。 */
|
|
772
|
+
function subagentOf(subagent, which) {
|
|
773
|
+
return subagentSummary(subagent === null || subagent === undefined ? null : subagent[which]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/* ------------------------------------------- 「本轮」稳定性(同轮不跳、换轮归零) */
|
|
777
|
+
/* 底栏每秒问两个窗口(整会话 / 本轮窗口),任一问失败时旧实现会把队友那段当 0,
|
|
778
|
+
于是「本轮」在同轮内跳小、下一次成功又跳大(用户实测)。下面两件纯函数负责把它稳住:
|
|
779
|
+
`nextSubagentState` 管"这次失败要不要沿用上一次成功的值",`applyTurnFloor` 管
|
|
780
|
+
"同一轮内显示值只增不减"。两者都以**轮标识**(turnKey)为界:换了轮一律从头来。 */
|
|
781
|
+
|
|
782
|
+
/** 空状态:没有会话、没有轮、两段都没有值。 */
|
|
783
|
+
const SUBAGENT_STATE_EMPTY = { sessionId: null, turnKey: null, sessionPayload: null, turnPayload: null };
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 队友两段取值的状态机(纯函数,组件只负责调用)。
|
|
787
|
+
*
|
|
788
|
+
* 一次"客户端到期"问两个窗口,每个窗口各自维护"上一次成功的值 + 它属于哪一轮"。
|
|
789
|
+
* `ok` 的判定由调用方统一折算:网络失败、非 200、**HTTP 200 但 `ok:false`**(后端
|
|
790
|
+
* `scan-failed` 之类)、以及形状异常(拿到的不是 `{ok:true,…}`)**都算"这一次没拿到"**。
|
|
791
|
+
*
|
|
792
|
+
* 三种情形(用户明确要求的三态):
|
|
793
|
+
* · 同一会话、同一轮、之前成功过 → **保留上一次成功的值**(stale-while-revalidate,
|
|
794
|
+
* 宁可用稍旧的值,也不要在同轮内把已经显示的数字打回 0);
|
|
795
|
+
* · 首次加载(手里没有旧值)→ `null` ⇒ 界面按"仅本会话"显示(没有旧值可留);
|
|
796
|
+
* · 换了轮(`turnKey` 变了)或换了会话 → **先整体清空**,绝不让上一轮的旧值顶上
|
|
797
|
+
* (这就是用户说的"归零重算以轮次结束为准")。
|
|
798
|
+
* 返回值是**新对象**,不原地改入参。
|
|
799
|
+
*/
|
|
800
|
+
function nextSubagentState(prev, action) {
|
|
801
|
+
const base = prev === null || prev === undefined ? SUBAGENT_STATE_EMPTY : prev;
|
|
802
|
+
const sameSession = base.sessionId === action.sessionId;
|
|
803
|
+
const sameTurn = sameSession === true && base.turnKey === action.turnKey;
|
|
804
|
+
/* 整会话那一段与"轮"无关(它就是本会话有史以来的队友合计,只随会话累积):
|
|
805
|
+
只要会话没换就留着 —— 否则每次换轮都会把「总计」里的队友清一下再填回来(抖一下)。
|
|
806
|
+
「本轮窗口」那一段必须跟轮走:换轮就作废,不许让上一轮的窗口当这一轮的。 */
|
|
807
|
+
const carried = sameSession === true
|
|
808
|
+
? {
|
|
809
|
+
sessionId: action.sessionId,
|
|
810
|
+
turnKey: action.turnKey,
|
|
811
|
+
sessionPayload: base.sessionPayload,
|
|
812
|
+
turnPayload: sameTurn === true ? base.turnPayload : null
|
|
813
|
+
}
|
|
814
|
+
: { sessionId: action.sessionId, turnKey: action.turnKey, sessionPayload: null, turnPayload: null };
|
|
815
|
+
const sliceKey = action.slice === "session" ? "sessionPayload" : "turnPayload";
|
|
816
|
+
/* 这一段的"归属"判定:整会话只看会话,本轮窗口要看"会话 + 轮" */
|
|
817
|
+
const ownerOk = sliceKey === "sessionPayload" ? sameSession : sameTurn;
|
|
818
|
+
if (action.ok !== true) {
|
|
819
|
+
/* 没拿到:同一归属且这一段手里已经有值 → 原样留着;否则这一段就是 null
|
|
820
|
+
(首次加载、或者刚换轮/换会话 —— 前者没有旧值可留,后者不允许留) */
|
|
821
|
+
if (ownerOk === true && carried[sliceKey] !== null) return carried;
|
|
822
|
+
carried[sliceKey] = null;
|
|
823
|
+
return carried;
|
|
824
|
+
}
|
|
825
|
+
carried[sliceKey] = action.value;
|
|
826
|
+
return carried;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* 「本轮」显示值的**同轮粘性下限**(纯函数):一轮之内只增不减。
|
|
831
|
+
*
|
|
832
|
+
* `hold` 形如 `{ sessionId, turnKey, value }`;返回 `{ value, hold }`,调用方把新 `hold`
|
|
833
|
+
* 存回自己的盒子。规则:
|
|
834
|
+
* · 同一个会话、同一个轮 → `value = max(amount, hold.value)`(`amount` 为 null 时用旧值);
|
|
835
|
+
* · 换了轮/换了会话、或认不出轮次(`turnKey` 为 null)→ 不作粘性(`value = amount`)。
|
|
836
|
+
* 为什么需要它:一轮内窗口起点会移动(父会话日志多出一条 `user/message`、节点折叠的
|
|
837
|
+
* 最早时间戳变化、压缩替换节点、host 收敛起点),窗口一变数字就会小一截;用户要的是
|
|
838
|
+
* **一轮之内单调不减**,只有换轮才归零重算。
|
|
839
|
+
*/
|
|
840
|
+
function applyTurnFloor(hold, sessionId, turnKey, amount) {
|
|
841
|
+
const usable = hold !== null && hold !== undefined && turnKey !== null
|
|
842
|
+
&& hold.turnKey === turnKey && hold.sessionId === sessionId;
|
|
843
|
+
const floor = usable === true && typeof hold.value === "number" && Number.isFinite(hold.value) === true
|
|
844
|
+
? hold.value
|
|
845
|
+
: 0;
|
|
846
|
+
/* `amount === null` 是"这一拍没有可显示的金额"(界面给横杠),不是 0:
|
|
847
|
+
没有下限时要把 null 原样传出去(否则"横杠"会变成 ¥0.00,那是另一回事);
|
|
848
|
+
有下限时用下限顶住(同轮内已经显示过金额,就不该退回横杠)。 */
|
|
849
|
+
const value = amount === null || amount === undefined
|
|
850
|
+
? (floor > 0 ? floor : null)
|
|
851
|
+
: Math.max(amount, floor);
|
|
852
|
+
return { value: value, hold: { sessionId: sessionId, turnKey: turnKey, value: value === null ? floor : value } };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** 本会话的令牌三段 + 队友的三段:同一个数字直接相加,不新增行。 */
|
|
856
|
+
function mergeTokenParts(parts, sub) {
|
|
857
|
+
const base = parts === null || parts === undefined ? { cached: 0, missInput: 0, output: 0 } : parts;
|
|
858
|
+
return {
|
|
859
|
+
cached: base.cached + sub.cached,
|
|
860
|
+
missInput: base.missInput + sub.missInput,
|
|
861
|
+
output: base.output + sub.output
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
357
865
|
/** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
|
|
358
866
|
function diffUsage(current, base) {
|
|
359
867
|
if (current === null || current === undefined || base === null || base === undefined) return null;
|
|
@@ -368,21 +876,32 @@ window.__ModuleLoader__.load({
|
|
|
368
876
|
function normalizeTier(rawTier) {
|
|
369
877
|
const entry = rawTier === null || rawTier === undefined || typeof rawTier !== "object" ? {} : rawTier;
|
|
370
878
|
return {
|
|
371
|
-
input:
|
|
372
|
-
cacheRead:
|
|
373
|
-
cacheWrite:
|
|
374
|
-
output:
|
|
879
|
+
input: tierValue(entry.input),
|
|
880
|
+
cacheRead: tierValue(entry.cacheRead),
|
|
881
|
+
cacheWrite: tierValue(entry.cacheWrite),
|
|
882
|
+
output: tierValue(entry.output)
|
|
375
883
|
};
|
|
376
884
|
}
|
|
377
|
-
|
|
885
|
+
/**
|
|
886
|
+
* 规范化价格库。`defaultCurrency` 是"这条目没写币种时用哪个"——
|
|
887
|
+
* 老配置里币种是**全局**的(`config.currency`),迁移时把它当默认值传给每个条目,
|
|
888
|
+
* 于是"原来全部按人民币计价"的语义原样保留、一个条目都不丢币种。
|
|
889
|
+
*/
|
|
890
|
+
function normalizeModels(rawModels, defaultCurrency) {
|
|
378
891
|
const out = {};
|
|
379
892
|
if (rawModels === null || rawModels === undefined || typeof rawModels !== "object") return out;
|
|
893
|
+
const fallback = normalizeCurrency(defaultCurrency);
|
|
380
894
|
const names = Object.keys(rawModels);
|
|
381
895
|
for (let i = 0; i < names.length; i += 1) {
|
|
382
896
|
const price = normalizePrice(rawModels[names[i]]);
|
|
383
897
|
if (price === null) continue;
|
|
898
|
+
const raw = rawModels[names[i]];
|
|
384
899
|
out[names[i]] = {
|
|
385
|
-
tiered: isTiered(
|
|
900
|
+
tiered: isTiered(raw),
|
|
901
|
+
/* 条目自带的币种;没写就落到默认币种(迁移语义) */
|
|
902
|
+
currency: raw !== null && raw !== undefined && raw.currency !== undefined && raw.currency !== null
|
|
903
|
+
? normalizeCurrency(raw.currency)
|
|
904
|
+
: fallback,
|
|
386
905
|
peak: normalizeTier(price.peak),
|
|
387
906
|
offPeak: normalizeTier(price.offPeak)
|
|
388
907
|
};
|
|
@@ -390,11 +909,45 @@ window.__ModuleLoader__.load({
|
|
|
390
909
|
return out;
|
|
391
910
|
}
|
|
392
911
|
|
|
912
|
+
/* 节点上"这条记录发生在什么时候"在不同来源里可能叫不同名字:几个都试一遍。
|
|
913
|
+
拿不到就返回 null —— 调用方据此判断"本轮起点未知"(`turnStartMs` 为 null),
|
|
914
|
+
于是本轮不并队友(本轮只能小不能大),总计照旧按整会话并。 */
|
|
915
|
+
const NODE_TIME_KEYS = ["at", "time", "timestamp", "createdAt", "startedAt", "updatedAt", "ts", "timeMs", "timestampMs"];
|
|
916
|
+
|
|
917
|
+
/** 一个节点上的时间戳(连常见嵌套位置一起找);找不到返回 null。 */
|
|
918
|
+
function nodeTimeOf(node) {
|
|
919
|
+
const holders = [node, node.data, node.message, node.meta];
|
|
920
|
+
for (let i = 0; i < holders.length; i += 1) {
|
|
921
|
+
const holder = holders[i];
|
|
922
|
+
if (holder === null || holder === undefined || typeof holder !== "object") continue;
|
|
923
|
+
for (let k = 0; k < NODE_TIME_KEYS.length; k += 1) {
|
|
924
|
+
const value = holder[NODE_TIME_KEYS[k]];
|
|
925
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** 这一轮第一条记录的时间(该轮所有节点里最早的那个);一个都拿不到返回 null。 */
|
|
932
|
+
function turnStartOf(nodes, turn) {
|
|
933
|
+
let min = null;
|
|
934
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
935
|
+
const node = nodes[i];
|
|
936
|
+
if (node === null || node === undefined) continue;
|
|
937
|
+
if (node.kind !== "assistant" || node.turn !== turn) continue;
|
|
938
|
+
const at = nodeTimeOf(node);
|
|
939
|
+
if (at === null) continue;
|
|
940
|
+
if (min === null || at < min) min = at;
|
|
941
|
+
}
|
|
942
|
+
return min;
|
|
943
|
+
}
|
|
944
|
+
|
|
393
945
|
/**
|
|
394
946
|
* 把会话节点里**最后一轮**的用量折出来(节点带 turn 号与这条调用的 usage)。
|
|
395
|
-
* 返回 `{ turn, parts }`,没有可用节点时返回 null —— 轮号一起返回是为了让调用方
|
|
947
|
+
* 返回 `{ turn, parts, at }`,没有可用节点时返回 null —— 轮号一起返回是为了让调用方
|
|
396
948
|
* 判断这份数据属于当前进行中的那一轮,还是上一轮(不能靠"往后跳一轮"猜,
|
|
397
|
-
*
|
|
949
|
+
* 当前轮还没产出节点时那样会错位到上上轮)。`at` 是这一轮第一条记录的时间,
|
|
950
|
+
* 用来问 host"本轮窗口内队友用了多少";节点不带时间戳时为 null。
|
|
398
951
|
*/
|
|
399
952
|
function foldTurnUsage(nodes) {
|
|
400
953
|
if (!Array.isArray(nodes)) return null;
|
|
@@ -421,16 +974,20 @@ window.__ModuleLoader__.load({
|
|
|
421
974
|
acc.output += usage.outputTokens || 0;
|
|
422
975
|
found = true;
|
|
423
976
|
}
|
|
424
|
-
return found ? { turn: target, parts: acc } : null;
|
|
977
|
+
return found ? { turn: target, parts: acc, at: turnStartOf(nodes, target) } : null;
|
|
425
978
|
}
|
|
426
979
|
|
|
427
980
|
/* ---------------------------------------------------------------- i18n */
|
|
428
981
|
const zh = {
|
|
429
982
|
nav: "状态栏",
|
|
983
|
+
updatePill: "发现新版本",
|
|
984
|
+
updateTo: "更新 {version} 版本",
|
|
985
|
+
updateRunning: "正在更新中",
|
|
986
|
+
updateFailed: "更新失败",
|
|
430
987
|
sectionTitle: "状态栏设置",
|
|
431
988
|
intro: "接管对话区底部的统计行。勾选要显示的字段、拖动调整顺序或配置模型费用单价。",
|
|
432
|
-
segContext: "
|
|
433
|
-
segContextHint: "
|
|
989
|
+
segContext: "上下文占用",
|
|
990
|
+
segContextHint: "上下文占用明细与压缩阈值。",
|
|
434
991
|
segStatus: "峰谷判断",
|
|
435
992
|
segCounts: "轮次&步数",
|
|
436
993
|
segTtft: "首字延迟",
|
|
@@ -451,6 +1008,7 @@ window.__ModuleLoader__.load({
|
|
|
451
1008
|
f_tokensTotal: "总计用量",
|
|
452
1009
|
f_tokensTurn: "本轮用量",
|
|
453
1010
|
tipModelTime: "模型用时",
|
|
1011
|
+
tipWaitTime: "等待用时",
|
|
454
1012
|
tipToolTime: "工具调用",
|
|
455
1013
|
tipTtftTurn: "本轮平均首字",
|
|
456
1014
|
tipTtftFast: "最快首字延迟",
|
|
@@ -460,19 +1018,29 @@ window.__ModuleLoader__.load({
|
|
|
460
1018
|
tipTpsSlow: "最慢输出速度",
|
|
461
1019
|
tipTopUp: "充值余额",
|
|
462
1020
|
tipGranted: "赠金余额",
|
|
1021
|
+
tipQuotaLeft: "剩余配额",
|
|
463
1022
|
tipSpent: "累计消费",
|
|
464
|
-
tipHitTurn: "
|
|
465
|
-
tipHitHigh: "
|
|
466
|
-
tipHitLow: "
|
|
1023
|
+
tipHitTurn: "本轮命中率",
|
|
1024
|
+
tipHitHigh: "最高命中率",
|
|
1025
|
+
tipHitLow: "最低命中率",
|
|
1026
|
+
tipCountsTitle: "会话数据",
|
|
1027
|
+
tipCountsSkills: "技能注入",
|
|
1028
|
+
tipCountsCompactAt: "上下文压缩",
|
|
1029
|
+
tipCountsToolCalls: "工具调用",
|
|
1030
|
+
/* 压缩明细逐条列时的两段:序号用中文序数(第一次 / 第二次),落点用「第X轮Y步」 */
|
|
1031
|
+
tipCompactItem: "第{index}次压缩",
|
|
1032
|
+
tipCompactWhere: "第{turn}轮{step}步",
|
|
1033
|
+
tipCompactTurnOnly: "第{turn}轮",
|
|
467
1034
|
contextAria: "上下文已用 {percent}",
|
|
468
1035
|
contextSystem: "系统提示词",
|
|
469
1036
|
contextTools: "工具定义",
|
|
470
1037
|
contextMessages: "对话消息",
|
|
471
|
-
f_speed: "
|
|
1038
|
+
f_speed: "综合速度 {throughput}t/s",
|
|
472
1039
|
f_sessionTime: "总用时 {duration}",
|
|
473
1040
|
f_cost: "总计 {symbol}{cost}",
|
|
474
1041
|
priceTierPeak: "高峰时段",
|
|
475
1042
|
priceTierOffPeak: "空闲时段",
|
|
1043
|
+
priceTierAllDay: "全时段",
|
|
476
1044
|
f_balanceDash: "余额 {symbol}-",
|
|
477
1045
|
f_lastCost: "本轮 {symbol}{cost}",
|
|
478
1046
|
f_balance: "余额 {symbol}{amount}",
|
|
@@ -481,8 +1049,12 @@ window.__ModuleLoader__.load({
|
|
|
481
1049
|
secSegments: "统计字段",
|
|
482
1050
|
secSegmentsHint: "勾选显示;按住拖动调整顺序。",
|
|
483
1051
|
secPrices: "自定义模型价格",
|
|
484
|
-
secPricesHint: "填写你使用的模型单价(每百万 tokens
|
|
1052
|
+
secPricesHint: "填写你使用的模型单价(每百万 tokens)。",
|
|
485
1053
|
segStatusHint: "自动判断当前的峰谷时段。",
|
|
1054
|
+
tipWeekTitle: "活跃总览",
|
|
1055
|
+
hoverCalls: "API请求次数",
|
|
1056
|
+
hoverCost: "消费金额",
|
|
1057
|
+
hoverYearAll: "全年",
|
|
486
1058
|
segCountsHint: "当前会话的总轮次与执行步数。",
|
|
487
1059
|
segTtftHint: "首字的平均延迟。",
|
|
488
1060
|
segCacheHitHint: "当前会话的平均缓存命中率。",
|
|
@@ -495,6 +1067,7 @@ window.__ModuleLoader__.load({
|
|
|
495
1067
|
modelUnknown: "未识别",
|
|
496
1068
|
unitCountTurns: " 轮",
|
|
497
1069
|
unitCountSteps: " 步",
|
|
1070
|
+
unitCountCalls: " 次",
|
|
498
1071
|
unitSpeed: "t/s",
|
|
499
1072
|
priceSuggestedHint: "价格库正使用官方参考价。",
|
|
500
1073
|
priceNewModel: "自定义模型",
|
|
@@ -509,18 +1082,23 @@ window.__ModuleLoader__.load({
|
|
|
509
1082
|
priceSave: "保存",
|
|
510
1083
|
reset: "恢复默认设置",
|
|
511
1084
|
holidayUpdate: "更新节假峰谷",
|
|
1085
|
+
starRepo: "GitHub支持",
|
|
1086
|
+
starTip: "你的支持是我最大的动力!请帮我点亮Star吧~",
|
|
512
1087
|
holidayUpdateBusy: "正在搜索节假安排...",
|
|
513
1088
|
holidayUpdateOk: "已写入 {year} 年 {count} 个节假日",
|
|
514
|
-
holidayUpdateMissing: "国务院还没发布 {year} 年放假安排,过几天再试",
|
|
515
1089
|
holidayUpdateFail: "抓取失败({reason}),检查网络后重试",
|
|
516
1090
|
holidayUpdateNoRoute: "状态栏后台还是旧版:完全退出并重启 DSH 后再点"
|
|
517
1091
|
};
|
|
518
1092
|
const en = {
|
|
519
1093
|
nav: "Status Bar",
|
|
1094
|
+
updatePill: "Update available",
|
|
1095
|
+
updateTo: "Update to {version}",
|
|
1096
|
+
updateRunning: "Updating",
|
|
1097
|
+
updateFailed: "Update failed",
|
|
520
1098
|
sectionTitle: "Status Bar Settings",
|
|
521
1099
|
intro: "Replaces the stats line under the message input. Select the fields to show, drag to reorder, or set model prices.",
|
|
522
|
-
segContext: "
|
|
523
|
-
segContextHint: "
|
|
1100
|
+
segContext: "Context usage",
|
|
1101
|
+
segContextHint: "Context occupancy breakdown and compaction threshold.",
|
|
524
1102
|
segStatus: "Peak / off-peak check",
|
|
525
1103
|
segCounts: "Turns & steps",
|
|
526
1104
|
segTtft: "Time to first token",
|
|
@@ -541,6 +1119,7 @@ window.__ModuleLoader__.load({
|
|
|
541
1119
|
f_tokensTotal: "Total usage",
|
|
542
1120
|
f_tokensTurn: "This turn usage",
|
|
543
1121
|
tipModelTime: "Model time",
|
|
1122
|
+
tipWaitTime: "Wait time",
|
|
544
1123
|
tipToolTime: "Tool calls",
|
|
545
1124
|
tipTtftTurn: "Average TTFT this turn",
|
|
546
1125
|
tipTtftFast: "Fastest TTFT",
|
|
@@ -550,10 +1129,19 @@ window.__ModuleLoader__.load({
|
|
|
550
1129
|
tipTpsSlow: "Slowest TPS",
|
|
551
1130
|
tipTopUp: "Top-up balance",
|
|
552
1131
|
tipGranted: "Granted balance",
|
|
1132
|
+
tipQuotaLeft: "Quota left",
|
|
553
1133
|
tipSpent: "Total spent",
|
|
554
|
-
tipHitTurn: "
|
|
555
|
-
tipHitHigh: "Highest
|
|
556
|
-
tipHitLow: "Lowest
|
|
1134
|
+
tipHitTurn: "Hit rate this turn",
|
|
1135
|
+
tipHitHigh: "Highest hit rate",
|
|
1136
|
+
tipHitLow: "Lowest hit rate",
|
|
1137
|
+
tipCountsTitle: "Session data",
|
|
1138
|
+
tipCountsSkills: "Skills injected",
|
|
1139
|
+
tipCountsCompactAt: "Context compactions",
|
|
1140
|
+
tipCountsToolCalls: "Tool calls",
|
|
1141
|
+
/* 压缩明细逐条列时的两段:英文直接用阿拉伯数字,落点「Turn X·Step Y」 */
|
|
1142
|
+
tipCompactItem: "Compaction {index}",
|
|
1143
|
+
tipCompactWhere: "Turn {turn}·Step {step}",
|
|
1144
|
+
tipCompactTurnOnly: "Turn {turn}",
|
|
557
1145
|
contextAria: "{percent} of context used",
|
|
558
1146
|
contextSystem: "System prompt",
|
|
559
1147
|
contextTools: "Tool definitions",
|
|
@@ -563,6 +1151,7 @@ window.__ModuleLoader__.load({
|
|
|
563
1151
|
f_cost: "Total {symbol}{cost}",
|
|
564
1152
|
priceTierPeak: "Peak",
|
|
565
1153
|
priceTierOffPeak: "Off-peak",
|
|
1154
|
+
priceTierAllDay: "All day",
|
|
566
1155
|
f_lastCost: "This turn {symbol}{cost}",
|
|
567
1156
|
f_balance: "Balance {symbol}{amount}",
|
|
568
1157
|
f_balanceSubCent: "Balance <{symbol}0.01",
|
|
@@ -570,8 +1159,12 @@ window.__ModuleLoader__.load({
|
|
|
570
1159
|
secSegments: "Stats fields",
|
|
571
1160
|
secSegmentsHint: "Select to show; drag to reorder.",
|
|
572
1161
|
secPrices: "Custom model prices",
|
|
573
|
-
secPricesHint: "Prices per million tokens
|
|
1162
|
+
secPricesHint: "Prices per million tokens.",
|
|
574
1163
|
segStatusHint: "Automatically determines the current peak / off-peak window.",
|
|
1164
|
+
tipWeekTitle: "Activity overview",
|
|
1165
|
+
hoverCalls: "API calls",
|
|
1166
|
+
hoverCost: "Cost",
|
|
1167
|
+
hoverYearAll: "full year",
|
|
575
1168
|
segCountsHint: "Total turns and executed steps of the current session.",
|
|
576
1169
|
segTtftHint: "Average time to the first token.",
|
|
577
1170
|
segCacheHitHint: "Average cache hit rate of the current session.",
|
|
@@ -584,6 +1177,7 @@ window.__ModuleLoader__.load({
|
|
|
584
1177
|
modelUnknown: "unknown",
|
|
585
1178
|
unitCountTurns: " turns",
|
|
586
1179
|
unitCountSteps: " steps",
|
|
1180
|
+
unitCountCalls: " calls",
|
|
587
1181
|
unitSpeed: "t/s",
|
|
588
1182
|
priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
|
|
589
1183
|
f_balanceDash: "Balance {symbol}-",
|
|
@@ -599,9 +1193,10 @@ window.__ModuleLoader__.load({
|
|
|
599
1193
|
priceSave: "Save",
|
|
600
1194
|
reset: "Reset settings",
|
|
601
1195
|
holidayUpdate: "Update holidays",
|
|
1196
|
+
starRepo: "Support on GitHub",
|
|
1197
|
+
starTip: "Your support means a lot to me — please give it a star!",
|
|
602
1198
|
holidayUpdateBusy: "Searching the holiday schedule...",
|
|
603
1199
|
holidayUpdateOk: "Wrote {count} holidays for {year}",
|
|
604
|
-
holidayUpdateMissing: "The {year} schedule is not published yet — try again in a few days",
|
|
605
1200
|
holidayUpdateFail: "Fetch failed ({reason}); check the network and retry",
|
|
606
1201
|
holidayUpdateNoRoute: "The status bar backend is still the old build — fully quit and restart DSH, then try again"
|
|
607
1202
|
};
|
|
@@ -613,8 +1208,8 @@ window.__ModuleLoader__.load({
|
|
|
613
1208
|
{ id: "status", label: "segStatus", hint: "segStatusHint", def: true, short: { zh: "峰谷", en: "Period" } },
|
|
614
1209
|
{ id: "counts", label: "segCounts", hint: "segCountsHint", def: true, short: { zh: "轮次/步数", en: "Turns/steps" } },
|
|
615
1210
|
{ id: "cacheHit", label: "segCacheHit", hint: "segCacheHitHint", def: true, short: { zh: "综合命中", en: "Combined hit" } },
|
|
1211
|
+
{ id: "tps", label: "segTps", hint: "segTpsHint", def: true, short: { zh: "综合速度", en: "TPS" } },
|
|
616
1212
|
{ id: "ttft", label: "segTtft", hint: "segTtftHint", def: true, short: { zh: "首字平均", en: "TTFT" } },
|
|
617
|
-
{ id: "tps", label: "segTps", hint: "segTpsHint", def: true, short: { zh: "输出速度", en: "TPS" } },
|
|
618
1213
|
{ id: "sessionTime", label: "segSessionTime", hint: "segSessionTimeHint", def: true, short: { zh: "总用时", en: "Time" } },
|
|
619
1214
|
{ id: "lastCost", label: "segLastCost", hint: "segLastCostHint", def: true, short: { zh: "本轮", en: "This turn" } },
|
|
620
1215
|
{ id: "cost", label: "segCost", hint: "segCostHint", def: true, short: { zh: "总计", en: "Total" } },
|
|
@@ -643,7 +1238,7 @@ window.__ModuleLoader__.load({
|
|
|
643
1238
|
segments: DEFAULT_SEGMENTS.slice(),
|
|
644
1239
|
hidden: [],
|
|
645
1240
|
currency: "CNY",
|
|
646
|
-
models:
|
|
1241
|
+
models: normalizeModels(DEFAULT_PRICES, "CNY"),
|
|
647
1242
|
modelOrder: Object.keys(DEFAULT_PRICES),
|
|
648
1243
|
holidayDays: []
|
|
649
1244
|
};
|
|
@@ -672,16 +1267,22 @@ window.__ModuleLoader__.load({
|
|
|
672
1267
|
if (stored.indexOf(id) === -1) stored.push(id);
|
|
673
1268
|
});
|
|
674
1269
|
const hidden = hiddenStored.filter((id) => stored.indexOf(id) !== -1);
|
|
675
|
-
/* 计价单位:只认 CNY / USD
|
|
1270
|
+
/* 计价单位:只认 CNY / USD,其它值一律当人民币。
|
|
1271
|
+
迁移后它只是"**新增条目的默认币种**"—— 已有条目各自带币种
|
|
1272
|
+
(`models[name].currency`),任何选择器都不许再改写它们。 */
|
|
676
1273
|
const currency = parsed.currency === "USD" ? "USD" : "CNY";
|
|
677
1274
|
const reference = referencePrices(currency);
|
|
678
1275
|
/* 价格库落后于参考价版本时并入官方价;用户自己改过的条目不覆盖 */
|
|
679
1276
|
/* 价格库为空或全 0 时直接用内嵌官方价(自愈,无需手动填) */
|
|
680
|
-
if (pricesMissing(normalizeModels(parsed.models))) parsed.models = Object.assign({}, reference);
|
|
1277
|
+
if (pricesMissing(normalizeModels(parsed.models, currency))) parsed.models = Object.assign({}, reference);
|
|
681
1278
|
const priceVersion = typeof parsed.priceVersion === "number" ? parsed.priceVersion : 0;
|
|
1279
|
+
/* 价格库落后于参考价版本时并入官方价:**官方价打底、用户条目优先** ——
|
|
1280
|
+
官方新增的模型补进来,但用户已经存在的条目(改过价的、明确填 0 的)一律不动。
|
|
1281
|
+
原来把 reference 写在后面,结果是官方价盖掉用户改过的同名条目,
|
|
1282
|
+
与上面那句"用户自己改过的条目不覆盖"正好相反(实测缺陷,用户确认要修)。 */
|
|
682
1283
|
const models = priceVersion < PRICE_VERSION
|
|
683
|
-
? normalizeModels(Object.assign({}, parsed.models,
|
|
684
|
-
: normalizeModels(parsed.models);
|
|
1284
|
+
? normalizeModels(Object.assign({}, reference, parsed.models), currency)
|
|
1285
|
+
: normalizeModels(parsed.models, currency);
|
|
685
1286
|
/* 旧版取消勾选「峰谷计价」时会把空闲价写成高峰价的副本,再勾回来也回不去。
|
|
686
1287
|
这里按参考价补回:只修"勾着峰谷计价、两档却完全相同"的官方模型。 */
|
|
687
1288
|
Object.keys(reference).forEach((name) => {
|
|
@@ -702,14 +1303,13 @@ window.__ModuleLoader__.load({
|
|
|
702
1303
|
});
|
|
703
1304
|
return {
|
|
704
1305
|
version: CONFIG_VERSION,
|
|
705
|
-
priceConfigured: parsed.priceConfigured === true,
|
|
706
1306
|
contextMeter: parsed.contextMeter !== false,
|
|
707
1307
|
segments: stored,
|
|
708
1308
|
hidden: hidden,
|
|
709
1309
|
currency: currency,
|
|
710
1310
|
priceVersion: PRICE_VERSION,
|
|
711
1311
|
priceConfigured: priceConfigured,
|
|
712
|
-
models: Object.keys(models).length > 0 ? models : normalizeModels(reference),
|
|
1312
|
+
models: Object.keys(models).length > 0 ? models : normalizeModels(reference, currency),
|
|
713
1313
|
modelOrder: modelOrder.length > 0 ? modelOrder : Object.keys(reference),
|
|
714
1314
|
/* 抓来的放假日历:字符串数组,按年分组在 refreshHolidays() 里做 */
|
|
715
1315
|
holidayDays: Array.isArray(parsed.holidayDays)
|
|
@@ -744,31 +1344,126 @@ window.__ModuleLoader__.load({
|
|
|
744
1344
|
if (on !== true) hidden.push(id);
|
|
745
1345
|
setConfig({ hidden: hidden });
|
|
746
1346
|
}
|
|
1347
|
+
|
|
747
1348
|
/**
|
|
748
|
-
*
|
|
749
|
-
*
|
|
1349
|
+
* 官方内置价:某个价格键在**两套官方参考价表**里都有时,给出"该币种那一套定死的数字"。
|
|
1350
|
+
*
|
|
1351
|
+
* **判定规则**(与用户确认过):键名必须**逐字**同时命中 `DEFAULT_PRICES` 与
|
|
1352
|
+
* `DEFAULT_PRICES_USD`(不做模糊匹配 —— 用户自建的键、或官方表里没有的键一律算"非内置")。
|
|
1353
|
+
* 返回 `{ CNY: { peak, offPeak }, USD: { peak, offPeak } }`,找不到返回 `null`。
|
|
750
1354
|
*/
|
|
751
|
-
function
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1355
|
+
function officialPair(name) {
|
|
1356
|
+
const cny = DEFAULT_PRICES[name];
|
|
1357
|
+
const usd = DEFAULT_PRICES_USD[name];
|
|
1358
|
+
if (cny === undefined || cny === null || usd === undefined || usd === null) return null;
|
|
1359
|
+
return {
|
|
1360
|
+
CNY: { peak: normalizeTier(cny.peak), offPeak: normalizeTier(cny.offPeak) },
|
|
1361
|
+
USD: { peak: normalizeTier(usd.peak), offPeak: normalizeTier(usd.offPeak) }
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* 改**某一个模型条目**的币种。**不乘除任何比率** —— 数值只有两种去向:
|
|
1367
|
+
*
|
|
1368
|
+
* ① **官方内置且没被改过**(键同时命中两套官方表,且当前两档数字逐格等于该币种那一套官方值)
|
|
1369
|
+
* → 直接换成**官方另一套定死的数字**(逐字段等于目标币种的官方值)。用户的说法:
|
|
1370
|
+
* "默认两个模型的美元价格和人民币价格是定死的呀,你怎么能自己去按汇率切换呢"。
|
|
1371
|
+
* ② 其余(用户自建的键、官方没有的键、或**被改过数值**的条目)→ **数值一个字都不动**,
|
|
1372
|
+
* 只改 `currency` 标记 —— 另一种币种该填多少由用户自己填,工具不替他算。
|
|
1373
|
+
* "改过数值"就是这么识别的:拿当前两档跟当前币种的官方值逐格比,不一致即视为改过。
|
|
1374
|
+
*
|
|
1375
|
+
* 只动这一个条目:其它条目、台账、历史数字、`config.currency` 全都不动。
|
|
1376
|
+
*
|
|
1377
|
+
* `draftNow` 是**正在编辑的草稿**(可省)。"改没改过"必须看**用户此刻在框里看到的值**,
|
|
1378
|
+
* 不能只看库里的值:用户改了价还没保存就切币种时,库里的值仍是旧的官方值,
|
|
1379
|
+
* 会误判成"没改过"→ 整条换成官方另一套 → 用户刚填的数字被清空(实测反馈)。
|
|
1380
|
+
*
|
|
1381
|
+
* 返回 `{ entry, replaced }`;没变化返回 `null`。
|
|
1382
|
+
* `replaced === true`(官方内置、且草稿仍是该币种的官方值)→ 整条换成官方另一套,
|
|
1383
|
+
* 调用方必须按新数字重建草稿;否则数值一个字没动,调用方只该换草稿的币种标记,
|
|
1384
|
+
* **保住用户还没保存的输入**。
|
|
1385
|
+
*/
|
|
1386
|
+
function setModelCurrency(name, code, draftNow) {
|
|
1387
|
+
const entry = config.models === undefined || config.models === null ? null : config.models[name];
|
|
1388
|
+
if (entry === null || entry === undefined) return null;
|
|
1389
|
+
const from = entry.currency === undefined || entry.currency === null ? normalizeCurrency(config.currency) : normalizeCurrency(entry.currency);
|
|
1390
|
+
const to = normalizeCurrency(code);
|
|
1391
|
+
if (from === to) return null;
|
|
1392
|
+
const official = officialPair(name);
|
|
1393
|
+
const current = { peak: normalizeTier(entry.peak), offPeak: normalizeTier(entry.offPeak) };
|
|
1394
|
+
/* 草稿里的格子是字符串(`""` = 没填),`normalizeTier` 用同一套 `tierValue` 口径转成
|
|
1395
|
+
null / 数字 —— 与保存时落库的口径必须一致,否则"算不算改过"会和落库结果打架。 */
|
|
1396
|
+
const shown = draftNow === null || draftNow === undefined
|
|
1397
|
+
? current
|
|
1398
|
+
: { peak: normalizeTier(draftNow.peak), offPeak: normalizeTier(draftNow.offPeak) };
|
|
1399
|
+
const isPristineOfficial = official !== null
|
|
1400
|
+
&& sameTier(shown.peak, official[from].peak) && sameTier(shown.offPeak, official[from].offPeak);
|
|
1401
|
+
const next = {
|
|
1402
|
+
tiered: isPristineOfficial === true ? true : entry.tiered !== false,
|
|
1403
|
+
currency: to,
|
|
1404
|
+
peak: isPristineOfficial === true ? Object.assign({}, official[to].peak) : current.peak,
|
|
1405
|
+
offPeak: isPristineOfficial === true ? Object.assign({}, official[to].offPeak) : current.offPeak
|
|
1406
|
+
};
|
|
756
1407
|
const models = Object.assign({}, config.models);
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1408
|
+
models[name] = next;
|
|
1409
|
+
setConfig({ models: models });
|
|
1410
|
+
return { entry: next, replaced: isPristineOfficial === true };
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* 「恢复默认」要写回的价格库(**分两类**,两条要求同时满足):
|
|
1415
|
+
*
|
|
1416
|
+
* ① **官方内置**(键在 `DEFAULT_PRICES` 里命中)→ 恢复成**官方默认值**:逐字段等于
|
|
1417
|
+
* 官方表(`tiered` 与峰谷两档一起,连官方写的 `0`/`null` 也照抄),币种回 **CNY**;
|
|
1418
|
+
* **被删掉的官方条目会被加回来**;顺序按官方表顺序排在**前面**。
|
|
1419
|
+
* **取舍**:用户改过某个官方模型的价格,恢复默认会把它覆盖回官方价 —— 这就是
|
|
1420
|
+
* "恢复默认"的语义;想留住自定义价就别用官方键(换个名字建自建条目)。
|
|
1421
|
+
* ② **用户自建**(键不在官方表里)→ **原样保留**:数值、档位、`currency` 逐字段不变
|
|
1422
|
+
* (含用户明确填的 0),顺序跟在官方条目之后、保持原来的相对顺序。
|
|
1423
|
+
*
|
|
1424
|
+
* 判定与顺序(三条口径):
|
|
1425
|
+
* · "官方内置"以 **`DEFAULT_PRICES` 的键**为准 —— 它才代表"官方默认价"这一集合;
|
|
1426
|
+
* (`DEFAULT_PRICES` 与 `DEFAULT_PRICES_USD` 的键集合**完全一致**,见 lib 顶部两张表,
|
|
1427
|
+
* 所以不需要取并集。哪天两张表键集合不同了,这里要改成并集。)
|
|
1428
|
+
* · 官方顺序 = **`Object.keys(DEFAULT_PRICES)` 的字面顺序**,不另外定义一套;
|
|
1429
|
+
* · `modelOrder` = 官方键(官方顺序)在前 + 保留下来的自建键(原相对顺序)在后。
|
|
1430
|
+
* **用户在官方键上的排序调整会被覆盖**("恢复默认"的语义),自建键的相对顺序保留。
|
|
1431
|
+
*
|
|
1432
|
+
* 只产出配置内容,不碰台账、不换算任何历史数字。
|
|
1433
|
+
*/
|
|
1434
|
+
function resetPriceBook() {
|
|
1435
|
+
const models = {};
|
|
1436
|
+
/* ① 官方:固定顺序、固定值 */
|
|
1437
|
+
Object.keys(DEFAULT_PRICES).forEach((name) => {
|
|
1438
|
+
const official = DEFAULT_PRICES[name];
|
|
1439
|
+
models[name] = {
|
|
1440
|
+
tiered: true,
|
|
1441
|
+
currency: "CNY",
|
|
1442
|
+
peak: normalizeTier(official.peak),
|
|
1443
|
+
offPeak: normalizeTier(official.offPeak)
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
/* ② 自建:原样搬过来(顺序取 modelOrder 的相对顺序,不在其中的补在后面) */
|
|
1447
|
+
const source = config.models === undefined || config.models === null ? {} : config.models;
|
|
1448
|
+
const modelOrder = Object.keys(DEFAULT_PRICES).slice();
|
|
1449
|
+
const walk = (Array.isArray(config.modelOrder) ? config.modelOrder : []).concat(Object.keys(source));
|
|
1450
|
+
walk.forEach((name) => {
|
|
1451
|
+
if (models[name] !== undefined || modelOrder.indexOf(name) !== -1) return;
|
|
1452
|
+
const entry = source[name];
|
|
1453
|
+
if (entry === undefined || entry === null) return;
|
|
762
1454
|
models[name] = {
|
|
763
|
-
tiered:
|
|
764
|
-
|
|
765
|
-
|
|
1455
|
+
tiered: entry.tiered !== false,
|
|
1456
|
+
currency: entry.currency === undefined || entry.currency === null
|
|
1457
|
+
? normalizeCurrency(config.currency)
|
|
1458
|
+
: normalizeCurrency(entry.currency),
|
|
1459
|
+
peak: normalizeTier(entry.peak),
|
|
1460
|
+
offPeak: normalizeTier(entry.offPeak)
|
|
766
1461
|
};
|
|
1462
|
+
modelOrder.push(name);
|
|
767
1463
|
});
|
|
768
|
-
|
|
769
|
-
/* 把换完的价格回给调用方:正在编辑的草稿要拿它重建,否则框里还是旧币种的数字 */
|
|
770
|
-
return { currency: next, models: models };
|
|
1464
|
+
return { models: models, modelOrder: modelOrder };
|
|
771
1465
|
}
|
|
1466
|
+
|
|
772
1467
|
/** 该段是否勾选显示:在段序里且不在 hidden 里。 */
|
|
773
1468
|
function isSegmentOn(cfg, id) {
|
|
774
1469
|
if (Array.isArray(cfg.segments) && cfg.segments.indexOf(id) === -1) return false;
|
|
@@ -817,11 +1512,26 @@ window.__ModuleLoader__.load({
|
|
|
817
1512
|
}
|
|
818
1513
|
return out;
|
|
819
1514
|
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* token 数自适应短格式:1,000,000 → 1M、2,000,000 → 2M、1,050,000 → 1.05M。
|
|
1518
|
+
* 整数不带小数,有小数最多两位且去掉尾随 0;上下文窗口这类"整刻度"数字用它。
|
|
1519
|
+
*/
|
|
1520
|
+
function formatTokensShort(value) {
|
|
1521
|
+
const n = Math.max(0, Number(value) || 0);
|
|
1522
|
+
const trim = (x) => String(Number(x.toFixed(2)));
|
|
1523
|
+
if (n >= 1e9) return trim(n / 1e9) + "B";
|
|
1524
|
+
if (n >= 1e6) return trim(n / 1e6) + "M";
|
|
1525
|
+
if (n >= 1e3) return trim(n / 1e3) + "K";
|
|
1526
|
+
return String(Math.round(n));
|
|
1527
|
+
}
|
|
820
1528
|
/** 秒级时长,保留一位小数(首字延迟这类不会超过一分钟的指标用)。 */
|
|
821
1529
|
/* ------------------------------------------------ 上下文占用(对齐官方口径) */
|
|
822
1530
|
/** 圆环几何:与官方 ContextMeter 一致(14px viewBox、2px 描边、半径 5.5) */
|
|
823
1531
|
const METER_RADIUS = 5.5;
|
|
824
1532
|
const METER_CIRCUMFERENCE = 2 * Math.PI * METER_RADIUS;
|
|
1533
|
+
/** 描边宽度,与 CSS 里 .dsb-ring-track / .dsb-ring-fill 的 stroke-width 同值,改一处要同步另一处。 */
|
|
1534
|
+
const METER_STROKE_PX = 2;
|
|
825
1535
|
/** 官方把本地化句子从占位槽切开,好让百分比单独上色(中英词序不同) */
|
|
826
1536
|
const READING_SLOT = "\u0000";
|
|
827
1537
|
/** 构成三行:系统提示词 / 工具定义 / 对话消息,颜色与官方 ContextMeter 相同 */
|
|
@@ -850,12 +1560,69 @@ window.__ModuleLoader__.load({
|
|
|
850
1560
|
};
|
|
851
1561
|
}
|
|
852
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* 上下文压缩触发点占窗口的百分比。官方公式:
|
|
1565
|
+
* floor(min(窗口 × thresholdRatio, 窗口 − 本次请求输出上限 − headroomTokens))
|
|
1566
|
+
* 两处独立印证:@deepseek-ai/dsh-compaction-basic 的 lib/index.js(resolveCompactSpec)
|
|
1567
|
+
* 与同包 README.zh.md;本机 11 次真实压缩事件回验,触发点与公式最大偏差 0.6%
|
|
1568
|
+
* (deepseek 1M 窗口 / 384K 输出 → 550,464,即 55.0%)。
|
|
1569
|
+
* 两个比例取包默认值:thresholdRatio 虽可在 agent preset 的 compaction-basic.config 里覆盖,
|
|
1570
|
+
* 但会话日志不记录当前会话用的是哪个 preset,所以只按默认算。
|
|
1571
|
+
*/
|
|
1572
|
+
const COMPACT_RATIO = 0.8;
|
|
1573
|
+
const COMPACT_HEADROOM = 65536;
|
|
1574
|
+
/**
|
|
1575
|
+
* 压缩点标记的宽度,一个数管两处:圆环那边是缺口的净宽(圆头啃进去之后看得见的部分),
|
|
1576
|
+
* 横条那边是实线的粗细。1px 是这个尺寸下的可靠下限——圆环描边才 2px,再宽就不像缺口
|
|
1577
|
+
* 而像断了一大格;再细于 1px 会走亚像素合成,在 14px 的圈上直接发虚。
|
|
1578
|
+
*/
|
|
1579
|
+
const COMPACT_NOTCH_PX = 1;
|
|
1580
|
+
/** 横条用 calc 定宽,线就不随气泡变宽变窄;这是半宽。 */
|
|
1581
|
+
const BAR_NOTCH_HALF_PX = COMPACT_NOTCH_PX / 2;
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* 压缩点占窗口的百分比;算不出返回 null(缺窗口或缺输出上限)——此时不开缺口。
|
|
1585
|
+
* 分母就是算阈值用的那个 contextWindow,不借圆环那侧的值:两者本应是同一个模型的窗口,
|
|
1586
|
+
* 借过来只会在投影短暂不同步时让缺口位置漂移。
|
|
1587
|
+
* 小窗口扣不出余量时官方会直接配置报错,这里退回比例那条线。
|
|
1588
|
+
*/
|
|
1589
|
+
function compactPercent(capacity) {
|
|
1590
|
+
if (capacity === null || capacity === undefined) return null;
|
|
1591
|
+
const windowTokens = capacity.contextWindow;
|
|
1592
|
+
const outputTokens = capacity.maxOutputTokens;
|
|
1593
|
+
if (typeof windowTokens !== "number" || windowTokens <= 0) return null;
|
|
1594
|
+
if (typeof outputTokens !== "number" || outputTokens < 0) return null;
|
|
1595
|
+
const ratioLine = windowTokens * COMPACT_RATIO;
|
|
1596
|
+
const budgetLine = windowTokens - outputTokens - COMPACT_HEADROOM;
|
|
1597
|
+
const threshold = Math.floor(budgetLine > 0 ? Math.min(ratioLine, budgetLine) : ratioLine);
|
|
1598
|
+
if (!(threshold > 0)) return null;
|
|
1599
|
+
return Math.min(100, threshold / windowTokens * 100);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* 圆环轨道在压缩点处挖一个圆头缺口,返回 strokeDasharray 的三段长度(画—空—画)。
|
|
1604
|
+
* dash 上的空洞要留成「净宽 + 描边宽度」:linecap:round 让缺口两侧的两段各向洞内延伸
|
|
1605
|
+
* 半个线宽(共 2px),按净宽直接开洞会被两个圆头整个填平。
|
|
1606
|
+
* 三段之和必须精确等于周长——dasharray 是循环 pattern,零头会在闭合处(12 点)挤出一道假缝。
|
|
1607
|
+
*/
|
|
1608
|
+
function notchDashes(percent) {
|
|
1609
|
+
if (percent === null || percent === undefined) return null;
|
|
1610
|
+
if (!(percent > 0 && percent < 100)) return null;
|
|
1611
|
+
const gap = COMPACT_NOTCH_PX + METER_STROKE_PX;
|
|
1612
|
+
const at = METER_CIRCUMFERENCE * percent / 100;
|
|
1613
|
+
const head = at - gap / 2;
|
|
1614
|
+
const tail = METER_CIRCUMFERENCE - head - gap;
|
|
1615
|
+
/* 贴住两端就整条不画:挪中心会让缺口指错位置,比没缺口更糟(与横条的边界守卫同一取舍) */
|
|
1616
|
+
if (!(head > 0 && tail > 0)) return null;
|
|
1617
|
+
return head + " " + gap + " " + tail;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
853
1620
|
/**
|
|
854
1621
|
* 圆环与分项面板要的数据;缺用量或缺窗口时返回 null(官方这时候也不显示)。
|
|
855
1622
|
* 数字一律用千分位精确值(不缩写、不带约等于号),跟令牌面板保持同一套读法。
|
|
856
|
-
* @returns {{percent:number, used:string, window:string, rows:Array, segments:Array}|null}
|
|
1623
|
+
* @returns {{percent:number, used:string, window:string, rows:Array, segments:Array, compact:number|null}|null}
|
|
857
1624
|
*/
|
|
858
|
-
function meterView(pressure, breakdown, t) {
|
|
1625
|
+
function meterView(pressure, breakdown, capacity, t) {
|
|
859
1626
|
const occupancy = contextOccupancy(pressure);
|
|
860
1627
|
if (occupancy === null) return null;
|
|
861
1628
|
const parts = breakdown === null || breakdown === undefined
|
|
@@ -880,15 +1647,63 @@ window.__ModuleLoader__.load({
|
|
|
880
1647
|
return {
|
|
881
1648
|
percent: occupancy.percent,
|
|
882
1649
|
used: formatTokensExact(occupancy.usedTokens),
|
|
883
|
-
window:
|
|
1650
|
+
window: formatTokensShort(occupancy.contextWindow),
|
|
884
1651
|
rows: rows,
|
|
885
|
-
segments: segments
|
|
1652
|
+
segments: segments,
|
|
1653
|
+
compact: compactPercent(capacity)
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* 没有占用数据时的圆环与面板:空环 + 横杠(`empty` 标记让面板区分"没数据"与"0%")。
|
|
1659
|
+
*
|
|
1660
|
+
* 圆环是固定在栏首的底栏项,**不能因为拿不到数据就整块消失**:会话刚发出去就被
|
|
1661
|
+
* 中断时最容易撞上 —— 那一刻 provider 还没报过上下文压力,整块一没,底栏就少一项,
|
|
1662
|
+
* 看上去像插件坏了(实测 2026-10-09)。缺数据的面板与令牌面板同口径:一律横杠,
|
|
1663
|
+
* 保证任何状态下都点得开。
|
|
1664
|
+
*/
|
|
1665
|
+
function dashMeter() {
|
|
1666
|
+
return {
|
|
1667
|
+
percent: 0,
|
|
1668
|
+
used: "-",
|
|
1669
|
+
window: "-",
|
|
1670
|
+
rows: [],
|
|
1671
|
+
segments: [],
|
|
1672
|
+
compact: null,
|
|
1673
|
+
empty: true
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** 条的轨道底色,与 .dsb-bar 的 CSS 同一变量,内联覆盖时不留色差。 */
|
|
1678
|
+
const BAR_TRACK = "var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15))";
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* 横条上的压缩点压一条实线(不是圆环那样的镂空):镂空会被气泡的毛玻璃底透上来,读成缺了一段。
|
|
1682
|
+
* 取色跟界面文字同一档(label-primary),主题反转时一起变——写死 #000 在暗色下会融进深底。
|
|
1683
|
+
* 必须带 fallback:这个值进的是 linear-gradient 的色标,变量缺失会让整条 background 失效。
|
|
1684
|
+
*/
|
|
1685
|
+
const BAR_NOTCH_COLOR = "var(--dsw-alias-label-primary,#202020)";
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* 分项条的轨道背景:有压缩点就在同一百分比处压一条竖线,位置与圆环缺口同一个口径。
|
|
1689
|
+
* 贴住两端时不画(压在角上只会像渲染出错)。
|
|
1690
|
+
*/
|
|
1691
|
+
function barTrackStyle(compact) {
|
|
1692
|
+
if (compact === null || compact === undefined) return undefined;
|
|
1693
|
+
if (!(compact > 1 && compact < 99)) return undefined;
|
|
1694
|
+
const at = compact.toFixed(3);
|
|
1695
|
+
/* 混合 % 与 px:位置跟模型自适应,线本身定宽,不会随气泡变宽 */
|
|
1696
|
+
const from = "calc(" + at + "% - " + BAR_NOTCH_HALF_PX + "px)";
|
|
1697
|
+
const to = "calc(" + at + "% + " + BAR_NOTCH_HALF_PX + "px)";
|
|
1698
|
+
return {
|
|
1699
|
+
background: "linear-gradient(90deg," + BAR_TRACK + " 0 " + from + "," + BAR_NOTCH_COLOR
|
|
1700
|
+
+ " " + from + " " + to + "," + BAR_TRACK + " " + to + " 100%)"
|
|
886
1701
|
};
|
|
887
1702
|
}
|
|
888
1703
|
|
|
889
1704
|
/** 官方那种分段小条(分项面板里那条)。 */
|
|
890
1705
|
function meterBar(meter) {
|
|
891
|
-
return h("span", { className: "dsb-bar" },
|
|
1706
|
+
return h("span", { className: "dsb-bar", style: barTrackStyle(meter.compact) },
|
|
892
1707
|
meter.segments.map((segment, index) => h("span", {
|
|
893
1708
|
className: "dsb-bar-seg",
|
|
894
1709
|
key: "__bar" + index,
|
|
@@ -896,15 +1711,26 @@ window.__ModuleLoader__.load({
|
|
|
896
1711
|
})));
|
|
897
1712
|
}
|
|
898
1713
|
|
|
899
|
-
/**
|
|
900
|
-
|
|
1714
|
+
/**
|
|
1715
|
+
* 状态位上的圆环:起点与官方一致在 12 点方向,进度是占用比例,颜色跟随运行状态。
|
|
1716
|
+
* 轨道在压缩点处挖一个圆头缺口;算不出压缩点时轨道保持整圈。
|
|
1717
|
+
*/
|
|
1718
|
+
function contextRing(percent, state, compact) {
|
|
901
1719
|
const stateClass = state === "running" ? " dsb-ring-running"
|
|
902
1720
|
: (state === "error" ? " dsb-ring-error" : (state === "approval" ? " dsb-ring-approval" : ""));
|
|
903
1721
|
const offset = METER_CIRCUMFERENCE * (1 - Math.max(0, Math.min(100, percent)) / 100);
|
|
1722
|
+
const dashes = notchDashes(compact);
|
|
904
1723
|
return h("svg", {
|
|
905
1724
|
className: "dsb-ring" + stateClass, key: "__ring", viewBox: "0 0 14 14", width: 14, height: 14
|
|
906
1725
|
},
|
|
907
|
-
h("circle", {
|
|
1726
|
+
h("circle", {
|
|
1727
|
+
className: "dsb-ring-track", cx: 7, cy: 7, r: METER_RADIUS,
|
|
1728
|
+
/* 轨道转到 12 点起,dasharray 的度量才和占用弧同一口径;
|
|
1729
|
+
圆头把缺口两端收成弧形,而不是齐平的径向切口。 */
|
|
1730
|
+
transform: dashes === null ? undefined : "rotate(-90 7 7)",
|
|
1731
|
+
strokeLinecap: dashes === null ? undefined : "round",
|
|
1732
|
+
strokeDasharray: dashes === null ? undefined : dashes
|
|
1733
|
+
}),
|
|
908
1734
|
h("circle", {
|
|
909
1735
|
className: "dsb-ring-fill", cx: 7, cy: 7, r: METER_RADIUS,
|
|
910
1736
|
/* SVG 的弧默认从 3 点方向起,整体转 -90° 才是官方那种"12 点起、顺时针长" */
|
|
@@ -913,10 +1739,41 @@ window.__ModuleLoader__.load({
|
|
|
913
1739
|
}));
|
|
914
1740
|
}
|
|
915
1741
|
|
|
916
|
-
/**
|
|
1742
|
+
/** 气泡自己那一圈描边的宽度(.dsb-tip 的 box-shadow spread:0 0 0 .5px):
|
|
1743
|
+
可见边缘比 border-box 再往外这么多,左右对齐时要补掉,否则看得见的边还差半像素。 */
|
|
1744
|
+
const TIP_OUTLINE_PX = 0.5;
|
|
1745
|
+
|
|
1746
|
+
/** 气泡的 fixed 内联位置:JS 已按视口与底栏范围夹住;pos 为空时不写。
|
|
1747
|
+
宽度交给内容(CSS width:max-content),但比底栏还宽时按底栏跨度压住,多出的列自己折行。 */
|
|
917
1748
|
function panelStyle(pos) {
|
|
918
1749
|
if (pos === null || pos === undefined) return undefined;
|
|
919
|
-
|
|
1750
|
+
const style = { left: pos.left + "px", bottom: pos.bottom + "px" };
|
|
1751
|
+
if (typeof pos.maxWidth === "number" && pos.maxWidth > 0) style.maxWidth = pos.maxWidth + "px";
|
|
1752
|
+
return style;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* 气泡自身吃下鼠标事件:一来不会穿透点到气泡下面(页面或状态栏),
|
|
1757
|
+
* 二来不会冒泡回触发项,把刚打开的气泡又切成关闭。
|
|
1758
|
+
*/
|
|
1759
|
+
function stopTipClick(event) {
|
|
1760
|
+
event.stopPropagation();
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* 气泡定位诊断回传:把这次算出的基准与结果发给 host 落盘(只含数字与短字符串),
|
|
1765
|
+
* 便于在打不开控制台的环境里核对气泡为什么越界。任何失败都静默,不影响气泡本身。
|
|
1766
|
+
*/
|
|
1767
|
+
function reportTipDiag(payload) {
|
|
1768
|
+
try {
|
|
1769
|
+
window.fetch(TIP_DIAG_URL, {
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
headers: { "content-type": "application/json" },
|
|
1772
|
+
body: JSON.stringify(payload)
|
|
1773
|
+
}).catch(() => { /* 静默 */ });
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
/* 静默 */
|
|
1776
|
+
}
|
|
920
1777
|
}
|
|
921
1778
|
|
|
922
1779
|
/** 悬停气泡里的分项面板:标题 + 分段条 + 三行构成(复刻官方 ContextMeter)。 */
|
|
@@ -925,7 +1782,7 @@ window.__ModuleLoader__.load({
|
|
|
925
1782
|
.split(READING_SLOT).map((part) => part.trim());
|
|
926
1783
|
const head = [];
|
|
927
1784
|
if (headBefore.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__hb" }, headBefore));
|
|
928
|
-
head.push(h("span", { className: "dsb-meter-percent", key: "__hp" }, meter.percent + "%"));
|
|
1785
|
+
head.push(h("span", { className: "dsb-meter-percent", key: "__hp" }, meter.empty === true ? "-" : meter.percent + "%"));
|
|
929
1786
|
if (headAfter.length > 0) head.push(h("span", { className: "dsb-meter-headline", key: "__ha" }, headAfter));
|
|
930
1787
|
head.push(h("span", { className: "dsb-meter-figures", key: "__hf" }, meter.used + " / " + meter.window));
|
|
931
1788
|
const rows = meter.rows.map((row, index) => h("span", { className: "dsb-meter-row", key: "__mr" + index }, [
|
|
@@ -933,7 +1790,7 @@ window.__ModuleLoader__.load({
|
|
|
933
1790
|
h("span", { className: "dsb-meter-name", key: "__mn" }, row.label),
|
|
934
1791
|
h("span", { className: "dsb-meter-value", key: "__mv" }, row.value)
|
|
935
1792
|
]));
|
|
936
|
-
return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
|
|
1793
|
+
return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick }, [
|
|
937
1794
|
h("span", { className: "dsb-meter-head", key: "__mh" }, head),
|
|
938
1795
|
meterBar(meter),
|
|
939
1796
|
rows.length > 0 ? h("span", { className: "dsb-meter-rows", key: "__mrows" }, rows) : null
|
|
@@ -985,7 +1842,7 @@ window.__ModuleLoader__.load({
|
|
|
985
1842
|
h("span", { className: "dsb-meter-name", key: "__tn" }, row.label),
|
|
986
1843
|
h("span", { className: "dsb-meter-value", key: "__tv" }, row.value)
|
|
987
1844
|
]));
|
|
988
|
-
return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
|
|
1845
|
+
return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick }, [
|
|
989
1846
|
h("span", { className: "dsb-meter-head", key: "__th" }, [
|
|
990
1847
|
h("span", { className: "dsb-meter-headline", key: "__tl" }, title),
|
|
991
1848
|
h("span", { className: "dsb-meter-figures", key: "__tt" }, panel.totalText)
|
|
@@ -995,23 +1852,439 @@ window.__ModuleLoader__.load({
|
|
|
995
1852
|
]);
|
|
996
1853
|
}
|
|
997
1854
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1855
|
+
/* ------------------------------------------------------ 活跃总览柱状图 */
|
|
1856
|
+
/** 柱状图用的三色:与令牌面板的三段同色(命中缓存=灰 / 未命中=紫 / 输出=蓝)。 */
|
|
1857
|
+
const WEEK_COLORS = {
|
|
1858
|
+
hit: "var(--dsw-static-neutral-bluish-400)",
|
|
1859
|
+
miss: "#a78bfa",
|
|
1860
|
+
out: "var(--dsw-static-blue-450)"
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
/* 余额构成的两色:充值取灰(同「系统提示词」图例)、赠金取紫(同「工具定义」图例)。
|
|
1864
|
+
百分比配额(GLM 那种)只有「剩余」那段上色,取蓝(原赠金那一档)。 */
|
|
1865
|
+
const BALANCE_COLORS = {
|
|
1866
|
+
topUp: "var(--dsw-static-neutral-bluish-400)",
|
|
1867
|
+
granted: "#a78bfa",
|
|
1868
|
+
left: "var(--dsw-static-blue-450)"
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
/* 运行用时面板的三色:等待用时取灰(同「系统提示词」图例)、模型用时(解码)取紫、工具调用取蓝 */
|
|
1872
|
+
const DURATION_COLORS = {
|
|
1873
|
+
wait: "var(--dsw-static-neutral-bluish-400)",
|
|
1874
|
+
model: "#a78bfa",
|
|
1875
|
+
tool: "var(--dsw-static-blue-450)"
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
/** 本地日期键,用来认「今天」那根柱子。 */
|
|
1879
|
+
function dateKeyOf(ms) {
|
|
1880
|
+
const d = new Date(ms === undefined || ms === null ? Date.now() : ms);
|
|
1881
|
+
const pad = (n) => (n < 10 ? "0" + n : String(n));
|
|
1882
|
+
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* 活跃度分档:按**排名**切四档,而不是"数值 / 最大值"。
|
|
1887
|
+
*
|
|
1888
|
+
* 为什么不能用最大值比例:一旦本期有个离群值(比如某个月用量是别月的几十倍),
|
|
1889
|
+
* 其余项相对它全都不足 15%,会一起塌进最浅档,整条颜色就废了。
|
|
1890
|
+
* 按排名则天然铺开 —— 传入本期所有数值,返回一个"数值 → 0–4 档"的映射:
|
|
1891
|
+
* 0 表示没有用量(不参与排名),1–4 按 25% / 50% / 75% 分位切。
|
|
1892
|
+
*/
|
|
1893
|
+
function heatScale(values) {
|
|
1894
|
+
const nums = values
|
|
1895
|
+
.map((one) => Math.max(0, Number(one) || 0))
|
|
1896
|
+
.filter((one) => one > 0)
|
|
1897
|
+
.sort((a, b) => a - b);
|
|
1898
|
+
if (nums.length === 0) return () => 0;
|
|
1899
|
+
const quantile = (p) => {
|
|
1900
|
+
const pos = (nums.length - 1) * p;
|
|
1901
|
+
const lo = Math.floor(pos);
|
|
1902
|
+
const hi = Math.ceil(pos);
|
|
1903
|
+
return nums[lo] + (nums[hi] - nums[lo]) * (pos - lo);
|
|
1904
|
+
};
|
|
1905
|
+
const q1 = quantile(0.25);
|
|
1906
|
+
const q2 = quantile(0.5);
|
|
1907
|
+
const q3 = quantile(0.75);
|
|
1908
|
+
return (value) => {
|
|
1909
|
+
const n = Number(value) || 0;
|
|
1910
|
+
if (n <= 0) return 0;
|
|
1911
|
+
if (n <= q1) return 1;
|
|
1912
|
+
if (n <= q2) return 2;
|
|
1913
|
+
if (n <= q3) return 3;
|
|
1914
|
+
return 4;
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/** 浮层里的一行「名称 —— 数值」:名称靠左、数值靠右(复用气泡既有的双边对齐行)。 */
|
|
1919
|
+
function hoverRow(key, name, value) {
|
|
1920
|
+
return h("span", { className: "dsb-meter-row", key: key }, [
|
|
1921
|
+
h("span", { className: "dsb-meter-name", key: key + "n" }, name),
|
|
1922
|
+
h("span", { className: "dsb-meter-figures", key: key + "v" }, value)
|
|
1923
|
+
]);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* 悬停浮层的内容:日期(或周区间)+ 三项总计 → 命中/未命中/输出三行 → API 请求次数 → 消费金额。
|
|
1928
|
+
* 六行全部走「名称左、数值右」的双边对齐;数量精确到个位并带千分位,不做缩写。
|
|
1929
|
+
*/
|
|
1930
|
+
function hoverPanelRows(label, bucket, calls, cost, t) {
|
|
1931
|
+
const hit = Number(bucket.cacheRead) || 0;
|
|
1932
|
+
const miss = (Number(bucket.input) || 0) + (Number(bucket.cacheWrite) || 0);
|
|
1933
|
+
const out = Number(bucket.output) || 0;
|
|
1934
|
+
return h("span", { className: "dsb-meter-rows dsb-meter-rows-fit", key: "__hrows" }, [
|
|
1935
|
+
hoverRow("__h0", label, formatTokensExact(hit + miss + out)),
|
|
1936
|
+
hoverRow("__h1", t("f_tokensHit"), formatTokensExact(hit)),
|
|
1937
|
+
hoverRow("__h2", t("f_tokensMiss"), formatTokensExact(miss)),
|
|
1938
|
+
hoverRow("__h3", t("f_tokensOut"), formatTokensExact(out)),
|
|
1939
|
+
hoverRow("__h4", t("hoverCalls"), formatTokensExact(calls)),
|
|
1940
|
+
hoverRow("__h5", t("hoverCost"), currencySymbol("CNY") + (Number(cost) || 0).toFixed(2))
|
|
1941
|
+
]);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** 一天的数据;某个字段缺失时按 0 算。 */
|
|
1945
|
+
function bucketOf(day) {
|
|
1946
|
+
return {
|
|
1947
|
+
input: Number(day.input) || 0,
|
|
1948
|
+
cacheRead: Number(day.cacheRead) || 0,
|
|
1949
|
+
cacheWrite: Number(day.cacheWrite) || 0,
|
|
1950
|
+
output: Number(day.output) || 0
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** 把若干天并成一个桶(周条用)。 */
|
|
1955
|
+
function sumDays(days) {
|
|
1956
|
+
const total = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
1957
|
+
let calls = 0;
|
|
1958
|
+
let cost = 0;
|
|
1959
|
+
for (const day of days) {
|
|
1960
|
+
if (day === null || day === undefined) continue;
|
|
1961
|
+
const one = bucketOf(day);
|
|
1962
|
+
total.input += one.input;
|
|
1963
|
+
total.cacheRead += one.cacheRead;
|
|
1964
|
+
total.cacheWrite += one.cacheWrite;
|
|
1965
|
+
total.output += one.output;
|
|
1966
|
+
calls += Number(day.calls) || 0;
|
|
1967
|
+
cost += Number(day.cost) || 0;
|
|
1968
|
+
}
|
|
1969
|
+
return { bucket: total, calls: calls, cost: cost };
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* 当月活跃度日历:按周分块,每块是一行 7 格(周一→周日)外面套一层淡灰底板。
|
|
1974
|
+
* 方块只排当月(跨月的头尾几天留空位),按用量分档上色、月内最高的一天锁紫;
|
|
1975
|
+
* 底板代表整周(含跨月那几天),悬停弹浮层。
|
|
1976
|
+
*/
|
|
1977
|
+
function heatGrid(month, t, onHover, activeWeek, today) {
|
|
1978
|
+
if (month === null || month === undefined || !Array.isArray(month.days) || month.days.length === 0) return [];
|
|
1979
|
+
const todayKey = typeof today === "string" ? today : "";
|
|
1980
|
+
const max = Number(month.max) || 0;
|
|
1981
|
+
/* 分档只按当月的天算:跨月那几天留空位、不画方块,不该影响分位边界 */
|
|
1982
|
+
const scale = heatScale(month.days.filter((one) => one.inMonth !== false).map((one) => one.total));
|
|
1983
|
+
/* host 给的已经是整周对齐的网格(首周从周一起算、末周补到周日,含跨月的头尾几天),
|
|
1984
|
+
所以直接每 7 天切一块,不再自己补空位。 */
|
|
1985
|
+
const weeks = [];
|
|
1986
|
+
for (let i = 0; i < month.days.length; i += 7) weeks.push(month.days.slice(i, i + 7));
|
|
1987
|
+
return weeks.map((days, wi) => {
|
|
1988
|
+
const weekSum = sumDays(days);
|
|
1989
|
+
const first = days.find((day) => day !== null);
|
|
1990
|
+
const last = days.slice().reverse().find((day) => day !== null);
|
|
1991
|
+
const label = first === undefined || last === undefined ? "" : first.date.slice(5) + " ~ " + last.date.slice(5);
|
|
1992
|
+
const hasData = weekSum.bucket.input + weekSum.bucket.cacheRead + weekSum.bucket.cacheWrite + weekSum.bucket.output > 0;
|
|
1993
|
+
/* 判定区常驻:没有数据的周也一样会加深,只是不弹浮层。
|
|
1994
|
+
覆盖底板左右两端"露出来"的边,以及跨月天留下的空位格;方块自己报当天,不走这里。 */
|
|
1995
|
+
const edgeHandlers = {
|
|
1996
|
+
onMouseEnter: (event) => onHover(event, hasData
|
|
1997
|
+
? { rows: hoverPanelRows(label, weekSum.bucket, weekSum.calls, weekSum.cost, t), week: wi }
|
|
1998
|
+
: { week: wi }),
|
|
1999
|
+
onMouseLeave: () => onHover(null)
|
|
2000
|
+
};
|
|
2001
|
+
const cells = days.map((day, ci) => {
|
|
2002
|
+
/* 日历只排当月:跨月的头尾几天不画方块(留空位),但它们属于这一周,也算周浮层的判定区 */
|
|
2003
|
+
if (day === null || day === undefined || day.inMonth === false) {
|
|
2004
|
+
return h("span", Object.assign({
|
|
2005
|
+
className: "dsb-heat-cell dsb-heat-empty",
|
|
2006
|
+
key: "__he" + wi + "_" + ci
|
|
2007
|
+
}, edgeHandlers));
|
|
2008
|
+
}
|
|
2009
|
+
const total = Number(day.total) || 0;
|
|
2010
|
+
const top = max > 0 && total === max && day.date === month.maxDate;
|
|
2011
|
+
/* 没用量时的灰底只有一档:已经过去的日子与还没到的日子同色(25% 灰),
|
|
2012
|
+
都是「没有数据」,不再区分深浅。 */
|
|
2013
|
+
const shade = total > 0 || todayKey === ""
|
|
2014
|
+
? ""
|
|
2015
|
+
: day.date > todayKey ? " dsb-future" : " dsb-past";
|
|
2016
|
+
const cls = top ? "dsb-heat-top" : "dsb-heat-" + scale(total) + shade;
|
|
2017
|
+
return h("span", {
|
|
2018
|
+
className: "dsb-heat-cell " + cls,
|
|
2019
|
+
key: "__hc" + day.date,
|
|
2020
|
+
/* 没有用量的天不弹浮层 */
|
|
2021
|
+
onMouseEnter: total > 0
|
|
2022
|
+
? (event) => onHover(event, { rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, t) })
|
|
2023
|
+
: undefined,
|
|
2024
|
+
onMouseLeave: total > 0 ? () => onHover(null) : undefined
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
2027
|
+
return h("span", { className: "dsb-heat-week", key: "__hw" + wi }, [
|
|
2028
|
+
h("span", {
|
|
2029
|
+
className: "dsb-heat-pad" + (activeWeek === wi ? " dsb-heat-pad-active" : ""),
|
|
2030
|
+
key: "__hp" + wi
|
|
2031
|
+
}, [
|
|
2032
|
+
h("span", Object.assign({ className: "dsb-heat-edge", key: "__hel" + wi }, edgeHandlers)),
|
|
2033
|
+
h("span", { className: "dsb-heat-weekrow", key: "__hwr" + wi }, cells),
|
|
2034
|
+
h("span", Object.assign({ className: "dsb-heat-edge", key: "__her" + wi }, edgeHandlers))
|
|
2035
|
+
])
|
|
2036
|
+
]);
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* 标题下面那 13 个段:1–6 月在左半边、7–12 月在右半边,**正中间那一段无色**,代表全年。
|
|
2042
|
+
* 配色规则与右侧月历完全一致(0 = 灰、其余 4 档蓝、当年最高的那个月锁紫)。
|
|
2043
|
+
* 悬停月份段看那个月,悬停中间的无色段看全年;没有用量的月份不弹浮层。
|
|
2044
|
+
*/
|
|
2045
|
+
function yearStrip(year, t, onHover, today) {
|
|
2046
|
+
if (year === null || year === undefined || !Array.isArray(year.months) || year.months.length === 0) return null;
|
|
2047
|
+
const max = Number(year.max) || 0;
|
|
2048
|
+
/* 当前月份:比它大的月份还没到,灰底同样减淡一半 */
|
|
2049
|
+
const todayMonth = typeof today === "string" ? Number(today.slice(5, 7)) || 0 : 0;
|
|
2050
|
+
const scale = heatScale(year.months.map((one) => one.total));
|
|
2051
|
+
const cells = year.months.map((one) => {
|
|
2052
|
+
const total = Number(one.total) || 0;
|
|
2053
|
+
const top = max > 0 && total === max && one.month === year.maxMonth;
|
|
2054
|
+
const month = Number(one.month) || 0;
|
|
2055
|
+
/* 灰底同样只有一档:已经过去的月份与还没到的月份同色(25% 灰) */
|
|
2056
|
+
const shade = total > 0 || todayMonth === 0
|
|
2057
|
+
? ""
|
|
2058
|
+
: month > todayMonth ? " dsb-future" : " dsb-past";
|
|
2059
|
+
const cls = top ? "dsb-heat-top" : "dsb-heat-" + scale(total) + shade;
|
|
2060
|
+
const label = String(year.year) + "-" + (month < 10 ? "0" + month : String(month));
|
|
2061
|
+
return h("span", {
|
|
2062
|
+
className: "dsb-year-cell " + cls,
|
|
2063
|
+
key: "__yc" + month,
|
|
2064
|
+
onMouseEnter: total > 0
|
|
2065
|
+
? (event) => onHover(event, { rows: hoverPanelRows(label, bucketOf(one), Number(one.calls) || 0, Number(one.cost) || 0, t) })
|
|
2066
|
+
: undefined,
|
|
2067
|
+
onMouseLeave: total > 0 ? () => onHover(null) : undefined
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
/* 全年合计:各月相加,挂在中间那段无色方块上 */
|
|
2071
|
+
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
2072
|
+
let calls = 0;
|
|
2073
|
+
let cost = 0;
|
|
2074
|
+
for (const one of year.months) {
|
|
2075
|
+
totals.input += Number(one.input) || 0;
|
|
2076
|
+
totals.cacheRead += Number(one.cacheRead) || 0;
|
|
2077
|
+
totals.cacheWrite += Number(one.cacheWrite) || 0;
|
|
2078
|
+
totals.output += Number(one.output) || 0;
|
|
2079
|
+
calls += Number(one.calls) || 0;
|
|
2080
|
+
cost += Number(one.cost) || 0;
|
|
2081
|
+
}
|
|
2082
|
+
const hasYear = totals.input + totals.cacheRead + totals.cacheWrite + totals.output > 0;
|
|
2083
|
+
const allCell = h("span", {
|
|
2084
|
+
className: "dsb-year-cell dsb-year-all",
|
|
2085
|
+
key: "__yall",
|
|
2086
|
+
onMouseEnter: hasYear
|
|
2087
|
+
? (event) => onHover(event, { rows: hoverPanelRows(String(year.year) + " " + t("hoverYearAll"), totals, calls, cost, t) })
|
|
2088
|
+
: undefined,
|
|
2089
|
+
onMouseLeave: hasYear ? () => onHover(null) : undefined
|
|
2090
|
+
});
|
|
2091
|
+
const half = Math.floor(cells.length / 2);
|
|
2092
|
+
return h("span", { className: "dsb-year", key: "__year" }, cells.slice(0, half).concat([allCell], cells.slice(half)));
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* 活跃总览面板:左边周一→周日 7 根堆叠柱,柱内自下而上是
|
|
2097
|
+
* 输出(蓝) / 输入·未命中(紫) / 输入·命中(灰),配色与令牌面板的三段一致。
|
|
2098
|
+
*
|
|
2099
|
+
* 柱身外面是一层**透明**轨道(只负责定高与贴底),柱身自己按
|
|
2100
|
+
* 「当天总量 / 本周最大值」给高度,所以每根柱子都能各自顶部圆角;
|
|
2101
|
+
* 当天没有用量时柱高为 0,那一格就是纯空白,没有底色留痕。
|
|
2102
|
+
* 柱子下面不标星期 —— 位置本身就是周一→周日。
|
|
2103
|
+
*/
|
|
2104
|
+
function weekBarsPanel(tip, t, pos, ref, onHover, active) {
|
|
2105
|
+
const activeWeek = active === undefined || active === null ? null : active.week;
|
|
2106
|
+
const activeBar = active === undefined || active === null ? null : active.bar;
|
|
2107
|
+
const days = Array.isArray(tip.days) ? tip.days : [];
|
|
2108
|
+
const max = days.reduce((acc, day) => Math.max(acc, Number(day.total) || 0), 0);
|
|
2109
|
+
const columns = days.map((day, index) => {
|
|
2110
|
+
const total = Number(day.total) || 0;
|
|
2111
|
+
const scale = max > 0 ? total / max : 0;
|
|
2112
|
+
/* 段高只按当天构成分配;整根柱子的高度由外层 scale 决定 */
|
|
2113
|
+
const seg = (value) => (total > 0 ? ((Number(value) || 0) / total) * 100 : 0);
|
|
2114
|
+
/* 峰谷各含完整三项,两段按比例共分整条柱子(和 = 当天总量)。
|
|
2115
|
+
低谷段在下、高峰段在上;每一段内部自下而上是 输出(蓝) → 未命中(紫) → 命中(灰),
|
|
2116
|
+
所以未命中与输出在各自那一段的底部。高峰段的命中用 75% 灰区分。 */
|
|
2117
|
+
const peak = day.peak === undefined || day.peak === null ? null : day.peak;
|
|
2118
|
+
const pk = (key) => (peak === null ? 0 : Number(peak[key]) || 0);
|
|
2119
|
+
const hitAll = Number(day.cacheRead) || 0;
|
|
2120
|
+
const missAll = (Number(day.input) || 0) + (Number(day.cacheWrite) || 0);
|
|
2121
|
+
const outAll = Number(day.output) || 0;
|
|
2122
|
+
const pkHit = pk("cacheRead");
|
|
2123
|
+
const pkMiss = pk("input") + pk("cacheWrite");
|
|
2124
|
+
const pkOut = pk("output");
|
|
2125
|
+
const lowHit = Math.max(0, hitAll - pkHit);
|
|
2126
|
+
const lowMiss = Math.max(0, missAll - pkMiss);
|
|
2127
|
+
const lowOut = Math.max(0, outAll - pkOut);
|
|
2128
|
+
const hasPeak = pkHit + pkMiss + pkOut > 0;
|
|
2129
|
+
return h("span", {
|
|
2130
|
+
className: "dsb-day"
|
|
2131
|
+
+ (day.date === tip.today ? " dsb-day-today" : "")
|
|
2132
|
+
+ (activeBar === index ? " dsb-day-active" : ""),
|
|
2133
|
+
key: "__wd" + index,
|
|
2134
|
+
/* 悬停整列都让判定区变灰 —— 没有用量的天也一样;浮层只在有用量时弹 */
|
|
2135
|
+
onMouseEnter: (event) => onHover(event, total > 0
|
|
2136
|
+
? {
|
|
2137
|
+
rows: hoverPanelRows(day.date.slice(5), bucketOf(day), Number(day.calls) || 0, Number(day.cost) || 0, t),
|
|
2138
|
+
bar: index
|
|
2139
|
+
}
|
|
2140
|
+
: { bar: index }),
|
|
2141
|
+
onMouseLeave: () => onHover(null)
|
|
2142
|
+
}, [
|
|
2143
|
+
h("span", { className: "dsb-day-track", key: "__wt" }, [
|
|
2144
|
+
h("span", { className: "dsb-day-stack", key: "__ws", style: { height: scale * 100 + "%" } }, [
|
|
2145
|
+
/* 容器是 column-reverse,先写的在底部。
|
|
2146
|
+
低谷段:输出(蓝) → 未命中(紫) → 命中(灰);
|
|
2147
|
+
高峰段叠在它上面:输出(蓝) → 未命中(紫) → 命中(75% 灰)。 */
|
|
2148
|
+
h("span", { className: "dsb-day-seg", key: "__w3", style: { height: seg(lowOut) + "%", background: WEEK_COLORS.out } }),
|
|
2149
|
+
h("span", { className: "dsb-day-seg", key: "__w2", style: { height: seg(lowMiss) + "%", background: WEEK_COLORS.miss } }),
|
|
2150
|
+
h("span", { className: "dsb-day-seg", key: "__w1", style: { height: seg(lowHit) + "%", background: WEEK_COLORS.hit } }),
|
|
2151
|
+
hasPeak
|
|
2152
|
+
? h("span", { className: "dsb-day-seg", key: "__p3", style: { height: seg(pkOut) + "%", background: WEEK_COLORS.out } })
|
|
2153
|
+
: null,
|
|
2154
|
+
hasPeak
|
|
2155
|
+
? h("span", { className: "dsb-day-seg", key: "__p2", style: { height: seg(pkMiss) + "%", background: WEEK_COLORS.miss } })
|
|
2156
|
+
: null,
|
|
2157
|
+
hasPeak
|
|
2158
|
+
? h("span", { className: "dsb-day-seg dsb-day-peak", key: "__p1", style: { height: seg(pkHit) + "%" } })
|
|
2159
|
+
: null
|
|
2160
|
+
])
|
|
2161
|
+
])
|
|
2162
|
+
]);
|
|
2163
|
+
});
|
|
2164
|
+
/* 结构:标题 → 12 个月方块 → 左边柱状图 + 右边当月活跃度。
|
|
2165
|
+
总量那一行已去掉,具体数字改成悬停在柱、方块或每周底条上看。 */
|
|
2166
|
+
const heat = heatGrid(tip.month, t, onHover, activeWeek, tip.today);
|
|
2167
|
+
const strip = yearStrip(tip.year, t, onHover, tip.today);
|
|
2168
|
+
return h("span", {
|
|
2169
|
+
className: "dsb-tip dsb-meter dsb-week",
|
|
2170
|
+
key: "__tip",
|
|
2171
|
+
ref: ref,
|
|
2172
|
+
style: panelStyle(pos),
|
|
2173
|
+
/* 吃下鼠标事件:不穿透到下面,也不冒泡回去把气泡关掉 */
|
|
2174
|
+
onClick: stopTipClick,
|
|
2175
|
+
onMouseDown: stopTipClick,
|
|
2176
|
+
/* 诊断:定位回调把「模式 + 基准 + 结果」写在这里(不可见),同时会回传给 host 落盘 */
|
|
2177
|
+
"data-dsb-align": pos === null || pos === undefined || pos.align === undefined ? undefined : pos.align
|
|
2178
|
+
}, [
|
|
2179
|
+
h("span", { className: "dsb-meter-head", key: "__wh" }, [
|
|
2180
|
+
h("span", { className: "dsb-meter-headline", key: "__wt" }, tip.title)
|
|
2181
|
+
]),
|
|
2182
|
+
/* 那条分隔线换成了 12 个月方块;只有拿不到年度数据时才退回分隔线 */
|
|
2183
|
+
strip === null ? h("span", { className: "dsb-meter-divider", key: "__wdiv" }) : strip,
|
|
2184
|
+
h("span", { className: "dsb-week-body", key: "__wbody" }, [
|
|
2185
|
+
h("span", { className: "dsb-week-chart", key: "__wc" }, columns),
|
|
2186
|
+
h("span", { className: "dsb-heat", key: "__heat" }, heat)
|
|
2187
|
+
])
|
|
2188
|
+
]);
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
/**
|
|
2192
|
+
* 长列表分列:**每一竖列最多三行**,超过就多开一竖列(列数不设上限,
|
|
2193
|
+
* 放不下时由 CSS 折到下一排,整体高度另有屏幕半高的兜底上限)。
|
|
2194
|
+
*/
|
|
2195
|
+
const LIST_ROWS_PER_COLUMN = 3;
|
|
2196
|
+
function listColumns(rows) {
|
|
2197
|
+
const size = LIST_ROWS_PER_COLUMN;
|
|
2198
|
+
const out = [];
|
|
2199
|
+
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size));
|
|
2200
|
+
return out;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* 两列面板:标题 + 主数值,下面若干「名称 / 数值」行(用时、极值、命中率、余额、会话数据这类段用)。
|
|
2205
|
+
* 行上带 sub 的(会话数据那三行)点一下就把整个气泡换成子视图:标题变成子视图名、内容换成列表,
|
|
2206
|
+
* 子视图沿用主气泡的字号字色(不是二次气泡),点标题回到上一层。
|
|
2207
|
+
* 子视图的列表每列最多三行、超了往右加列;只有名字的行(技能注入)不占数值那一格。
|
|
2208
|
+
*/
|
|
2209
|
+
function rowsPanel(tip, pos, ref, sub) {
|
|
2210
|
+
const rows = tip.rows === undefined ? [] : tip.rows;
|
|
2211
|
+
const openedAt = sub === undefined || sub === null || typeof sub.id !== "number" ? null : sub.id;
|
|
2212
|
+
const opened = openedAt === null || rows[openedAt] === undefined || rows[openedAt].sub === undefined
|
|
2213
|
+
? null
|
|
2214
|
+
: rows[openedAt].sub;
|
|
2215
|
+
/* 一行里的三块:色块(可选,与上面那条色条一一对应)+ 名称 + 数值。
|
|
2216
|
+
没有数值的行(技能注入的子视图)只出名字,不占右边那一格,气泡宽度就是最长那个名字。
|
|
2217
|
+
可点行与普通行共用这一份,别各写一遍。 */
|
|
2218
|
+
const parts = (row, key) => [
|
|
2219
|
+
row.color === undefined || row.color === null
|
|
2220
|
+
? null
|
|
2221
|
+
: h("span", { className: "dsb-meter-swatch", key: key + "c", style: { background: row.color } }),
|
|
2222
|
+
h("span", { className: "dsb-meter-name", key: key + "n" }, row.label),
|
|
2223
|
+
row.value === undefined || row.value === null || row.value === ""
|
|
2224
|
+
? null
|
|
2225
|
+
: h("span", { className: "dsb-meter-value", key: key + "v" }, row.value)
|
|
2226
|
+
];
|
|
2227
|
+
const cell = (row, key) => h("span", { className: "dsb-meter-row", key: key }, parts(row, key));
|
|
2228
|
+
let body = null;
|
|
2229
|
+
/* 分列的子视图比单列宽:面板默认 560px 上限会把三列挤扁,这里放开一档 */
|
|
2230
|
+
let wide = false;
|
|
2231
|
+
if (opened === null) {
|
|
2232
|
+
body = h("span", { className: "dsb-meter-rows dsb-meter-rows-fit", key: "__xrows" },
|
|
2233
|
+
rows.map((row, index) => {
|
|
2234
|
+
if (row.sub === undefined || row.sub === null) return cell(row, "__xr" + index);
|
|
2235
|
+
return h("span", {
|
|
2236
|
+
className: "dsb-meter-row dsb-meter-row-click",
|
|
2237
|
+
key: "__xr" + index,
|
|
2238
|
+
onClick: (event) => {
|
|
2239
|
+
stopTipClick(event);
|
|
2240
|
+
if (sub.open !== undefined) sub.open(index);
|
|
2241
|
+
}
|
|
2242
|
+
}, parts(row, "__xr" + index));
|
|
2243
|
+
}));
|
|
2244
|
+
} else {
|
|
2245
|
+
const columns = listColumns(opened.rows);
|
|
2246
|
+
wide = columns.length > 1;
|
|
2247
|
+
/* 一列时不套外层(与其它面板的行结构完全一致),分列才多一层;
|
|
2248
|
+
子视图带 tight 的(工具调用)另挂一个类,把名字与数值的间隙收紧 */
|
|
2249
|
+
const cls = "dsb-meter-rows dsb-meter-rows-fit"
|
|
2250
|
+
+ (columns.length > 1 ? " dsb-meter-cols" : "")
|
|
2251
|
+
+ (opened.tight === true ? " dsb-meter-tight" : "");
|
|
2252
|
+
body = columns.length > 1
|
|
2253
|
+
? h("span", { className: cls, key: "__xrows" },
|
|
2254
|
+
columns.map((column, at) => h("span", { className: "dsb-meter-col", key: "__col" + at },
|
|
2255
|
+
column.map((row, index) => cell(row, "__c" + at + "r" + index)))))
|
|
2256
|
+
: h("span", { className: cls, key: "__xrows" },
|
|
2257
|
+
(columns[0] === undefined ? [] : columns[0]).map((row, index) => cell(row, "__r" + index)));
|
|
2258
|
+
}
|
|
2259
|
+
const segments = Array.isArray(tip.segments) ? tip.segments : [];
|
|
2260
|
+
return h("span", {
|
|
2261
|
+
className: wide === true ? "dsb-tip dsb-meter dsb-meter-wide" : "dsb-tip dsb-meter",
|
|
2262
|
+
key: "__tip", ref: ref, style: panelStyle(pos), onClick: stopTipClick, onMouseDown: stopTipClick
|
|
1002
2263
|
}, [
|
|
1003
|
-
h("span", { className: "dsb-meter-name", key: "__xn" }, row.label),
|
|
1004
|
-
h("span", { className: "dsb-meter-value", key: "__xv" }, row.value)
|
|
1005
|
-
]));
|
|
1006
|
-
return h("span", { className: "dsb-tip dsb-meter", key: "__tip", ref: ref, style: panelStyle(pos) }, [
|
|
1007
2264
|
h("span", { className: "dsb-meter-head", key: "__xh" }, [
|
|
1008
|
-
|
|
2265
|
+
opened === null
|
|
2266
|
+
? h("span", { className: "dsb-meter-headline", key: "__xt" }, tip.title)
|
|
2267
|
+
: h("span", {
|
|
2268
|
+
/* 点标题回上一层:不加返回箭头,靠光标与悬停变亮表示可点 */
|
|
2269
|
+
className: "dsb-meter-headline dsb-meter-back",
|
|
2270
|
+
key: "__xt",
|
|
2271
|
+
onClick: (event) => {
|
|
2272
|
+
stopTipClick(event);
|
|
2273
|
+
if (sub.back !== undefined) sub.back();
|
|
2274
|
+
}
|
|
2275
|
+
}, opened.title),
|
|
1009
2276
|
tip.head === undefined || tip.head === null
|
|
1010
2277
|
? null
|
|
1011
2278
|
: h("span", { className: "dsb-meter-figures", key: "__xhf" }, tip.head)
|
|
1012
2279
|
]),
|
|
1013
|
-
|
|
1014
|
-
|
|
2280
|
+
/* 有分段数据就画色条(与令牌面板同一条),否则还是那条普通分隔线 */
|
|
2281
|
+
segments.length > 0
|
|
2282
|
+
? h("span", { className: "dsb-bar", key: "__xbar" }, segments.map((one, index) => h("span", {
|
|
2283
|
+
className: "dsb-bar-seg", key: "__xseg" + index,
|
|
2284
|
+
style: { width: one.width + "%", background: one.color }
|
|
2285
|
+
})))
|
|
2286
|
+
: h("span", { className: "dsb-meter-divider", key: "__xdiv" }),
|
|
2287
|
+
body
|
|
1015
2288
|
]);
|
|
1016
2289
|
}
|
|
1017
2290
|
|
|
@@ -1047,6 +2320,23 @@ window.__ModuleLoader__.load({
|
|
|
1047
2320
|
return text === null ? "-" : text;
|
|
1048
2321
|
}
|
|
1049
2322
|
|
|
2323
|
+
/**
|
|
2324
|
+
* 运行用时面板的三段色条:等待用时=灰、模型用时(解码)=紫、工具调用=蓝。
|
|
2325
|
+
* 三者都是 0 时返回空数组,面板退回那条普通分隔线。
|
|
2326
|
+
*/
|
|
2327
|
+
function durationSegments(waitMs, modelMs, toolMs) {
|
|
2328
|
+
const wait = Math.max(0, Number(waitMs) || 0);
|
|
2329
|
+
const model = Math.max(0, Number(modelMs) || 0);
|
|
2330
|
+
const tool = Math.max(0, Number(toolMs) || 0);
|
|
2331
|
+
const total = wait + model + tool;
|
|
2332
|
+
if (total <= 0) return [];
|
|
2333
|
+
return [
|
|
2334
|
+
{ color: DURATION_COLORS.wait, width: (wait / total) * 100 },
|
|
2335
|
+
{ color: DURATION_COLORS.model, width: (model / total) * 100 },
|
|
2336
|
+
{ color: DURATION_COLORS.tool, width: (tool / total) * 100 }
|
|
2337
|
+
].filter((one) => one.width > 0);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1050
2340
|
/** 时长精确到秒:1h23m45s / 23m45s / 45s。底栏那条用 formatDuration(省掉秒,宽度有限)。 */
|
|
1051
2341
|
function formatDurationFine(ms) {
|
|
1052
2342
|
const n = Number(ms) || 0;
|
|
@@ -1058,7 +2348,7 @@ window.__ModuleLoader__.load({
|
|
|
1058
2348
|
return Math.floor(minutes / 60) + "h" + (minutes % 60) + "m" + (whole % 60) + "s";
|
|
1059
2349
|
}
|
|
1060
2350
|
|
|
1061
|
-
/**
|
|
2351
|
+
/** 本轮命中率:这一轮的输入 token 里命中缓存的比例;本轮还没有调用给横杠。
|
|
1062
2352
|
读插件自己的 desktopStatusbarUsage 投影 —— 官方 tokenUsage 只有会话累计值。
|
|
1063
2353
|
注意字段是短名(input / cacheRead / cacheWrite),与官方投影的 *Tokens 不同名。 */
|
|
1064
2354
|
function turnHitRate(sessionUsage) {
|
|
@@ -1069,7 +2359,7 @@ window.__ModuleLoader__.load({
|
|
|
1069
2359
|
return hitRateText(((current.cacheRead || 0) / denominator) * 100);
|
|
1070
2360
|
}
|
|
1071
2361
|
|
|
1072
|
-
/**
|
|
2362
|
+
/** 本轮平均首字(秒,一位小数);本轮还没有样本给横杠。 */
|
|
1073
2363
|
function turnAverageTtft(timing) {
|
|
1074
2364
|
if (timing === null || timing === undefined) return "-";
|
|
1075
2365
|
const samples = timing.turnTtftSamples || 0;
|
|
@@ -1100,6 +2390,131 @@ window.__ModuleLoader__.load({
|
|
|
1100
2390
|
return formatThroughput(mark.value) + t("unitSpeed");
|
|
1101
2391
|
}
|
|
1102
2392
|
|
|
2393
|
+
/** 中文序数里的数字:1 → 一、11 → 十一、21 → 二十一、101 → 一百零一。三位数以外退回阿拉伯数字。 */
|
|
2394
|
+
function chineseNumber(value) {
|
|
2395
|
+
const n = Math.round(Number(value) || 0);
|
|
2396
|
+
if (!(n >= 1 && n <= 999)) return null;
|
|
2397
|
+
const DIGITS = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
|
|
2398
|
+
const under100 = (num) => {
|
|
2399
|
+
if (num < 10) return DIGITS[num];
|
|
2400
|
+
if (num < 20) return "十" + (num % 10 === 0 ? "" : DIGITS[num % 10]);
|
|
2401
|
+
return DIGITS[Math.floor(num / 10)] + "十" + (num % 10 === 0 ? "" : DIGITS[num % 10]);
|
|
2402
|
+
};
|
|
2403
|
+
if (n < 100) return under100(n);
|
|
2404
|
+
const rest = n % 100;
|
|
2405
|
+
const head = DIGITS[Math.floor(n / 100)] + "百";
|
|
2406
|
+
if (rest === 0) return head;
|
|
2407
|
+
return head + (rest < 10 ? "零" + DIGITS[rest] : under100(rest));
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/** 子视图没有内容时给一行横杠:气泡任何情况下都要打得开,也不能是一片空白。 */
|
|
2411
|
+
function emptyList(title) {
|
|
2412
|
+
return { title: title, rows: [{ label: title, value: "-" }] };
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** 技能注入子视图:只列名字,不跟数值(次数在上面那一行已经给了)。
|
|
2416
|
+
全局提示词(AGENTS.md 这类)排在最前,它也是「注入」的一种;
|
|
2417
|
+
其余技能按首次注入的先后,重复注入聚合成一行;气泡宽度就是最长那个名字。 */
|
|
2418
|
+
function skillList(skills, globalInstruction, t) {
|
|
2419
|
+
const title = t("tipCountsSkills");
|
|
2420
|
+
const names = [];
|
|
2421
|
+
if (typeof globalInstruction === "string" && globalInstruction.length > 0) names.push(globalInstruction);
|
|
2422
|
+
if (skills !== null) {
|
|
2423
|
+
skills.forEach((name) => {
|
|
2424
|
+
const key = String(name);
|
|
2425
|
+
if (names.indexOf(key) < 0) names.push(key);
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
if (names.length === 0) return emptyList(title);
|
|
2429
|
+
return { title: title, rows: names.map((name) => ({ label: name })) };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/**
|
|
2433
|
+
* 工具名的中文显示名。工具名本身是会话日志里的键(英文标识),这里换成读者看得懂的词;
|
|
2434
|
+
* **没列到的工具照原样显示** —— 插件工具、MCP 工具随时可能出现,不能因为查不到就留白。
|
|
2435
|
+
* 只在中文界面用;英文界面直接用原名。
|
|
2436
|
+
*/
|
|
2437
|
+
const TOOL_LABELS_ZH = {
|
|
2438
|
+
pwsh: "命令行",
|
|
2439
|
+
bash: "命令行",
|
|
2440
|
+
read: "读取文件",
|
|
2441
|
+
write: "写入文件",
|
|
2442
|
+
edit: "修改文件",
|
|
2443
|
+
glob: "查找文件",
|
|
2444
|
+
grep: "搜索内容",
|
|
2445
|
+
todo_write: "待办清单",
|
|
2446
|
+
skill: "技能",
|
|
2447
|
+
subagent: "子智能体",
|
|
2448
|
+
subagent_fork: "子智能体(继承)",
|
|
2449
|
+
ask_user_question: "询问用户",
|
|
2450
|
+
create_goal: "新建目标",
|
|
2451
|
+
update_goal: "更新目标",
|
|
2452
|
+
get_goal: "查看目标",
|
|
2453
|
+
present: "展示文件",
|
|
2454
|
+
web_search: "联网搜索",
|
|
2455
|
+
web_fetch: "抓取网页",
|
|
2456
|
+
job_list: "后台任务列表",
|
|
2457
|
+
job_output: "后台任务输出",
|
|
2458
|
+
job_kill: "取消后台任务",
|
|
2459
|
+
spawn_teammate: "新增队友",
|
|
2460
|
+
send_message: "发送消息",
|
|
2461
|
+
wait_agent: "等待队友",
|
|
2462
|
+
interrupt_agent: "中断队友",
|
|
2463
|
+
list_agents: "队友列表",
|
|
2464
|
+
team_task_create: "新建任务",
|
|
2465
|
+
team_task_list: "任务列表",
|
|
2466
|
+
team_task_get: "查看任务",
|
|
2467
|
+
team_task_update: "更新任务",
|
|
2468
|
+
exit_plan_mode: "退出计划模式",
|
|
2469
|
+
load_workspace_dependencies: "加载工作区依赖",
|
|
2470
|
+
find_dsh_plugin: "查找插件",
|
|
2471
|
+
workflow: "工作流"
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
/** 工具名 → 显示名:中文界面查表(查不到给原名),英文界面直接用原名。 */
|
|
2475
|
+
function toolLabel(name, t) {
|
|
2476
|
+
if (detectLocale(t) !== "zh") return name;
|
|
2477
|
+
const label = TOOL_LABELS_ZH[name];
|
|
2478
|
+
return label === undefined ? name : label;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/** 「工具调用」子视图:**先按显示名长短(短的在前),同名长短再按调用次数降序**。
|
|
2482
|
+
名字短、列多,标 tight 让名字与次数之间的间隙收到 1ch。 */
|
|
2483
|
+
function toolList(counts, t) {
|
|
2484
|
+
const title = t("tipCountsToolCalls");
|
|
2485
|
+
if (counts === null || typeof counts !== "object") return Object.assign(emptyList(title), { tight: true });
|
|
2486
|
+
const rows = Object.keys(counts)
|
|
2487
|
+
.map((name) => ({ label: toolLabel(name, t), count: Number(counts[name]) || 0 }))
|
|
2488
|
+
.filter((one) => one.count > 0)
|
|
2489
|
+
.sort((a, b) => a.label.length - b.label.length
|
|
2490
|
+
|| b.count - a.count
|
|
2491
|
+
|| (a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)))
|
|
2492
|
+
.map((one) => ({ label: one.label, value: formatTokensExact(one.count) + t("unitCountCalls") }));
|
|
2493
|
+
if (rows.length === 0) return Object.assign(emptyList(title), { tight: true });
|
|
2494
|
+
return { title: title, rows: rows, tight: true };
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/** 「上下文压缩」子视图:逐条列出每次压缩落在第几轮第几步(顺序就是发生的顺序)。 */
|
|
2498
|
+
function compactList(compacts, t) {
|
|
2499
|
+
const title = t("tipCountsCompactAt");
|
|
2500
|
+
if (compacts === null || compacts.length === 0) return emptyList(title);
|
|
2501
|
+
const ordinal = detectLocale(t) === "zh";
|
|
2502
|
+
const rows = compacts.map((one, at) => {
|
|
2503
|
+
const index = at + 1;
|
|
2504
|
+
const word = ordinal === true ? chineseNumber(index) : null;
|
|
2505
|
+
const mark = one === null || one === undefined ? {} : one;
|
|
2506
|
+
const turn = typeof mark.turn === "number" ? mark.turn : null;
|
|
2507
|
+
const step = typeof mark.step === "number" ? mark.step : null;
|
|
2508
|
+
return {
|
|
2509
|
+
label: t("tipCompactItem", { index: word === null ? String(index) : word }),
|
|
2510
|
+
value: turn === null
|
|
2511
|
+
? "-"
|
|
2512
|
+
: (step === null ? t("tipCompactTurnOnly", { turn: turn }) : t("tipCompactWhere", { turn: turn, step: step }))
|
|
2513
|
+
};
|
|
2514
|
+
});
|
|
2515
|
+
return { title: title, rows: rows };
|
|
2516
|
+
}
|
|
2517
|
+
|
|
1103
2518
|
/** 余额接口给的金额字符串;没有这个字段时按 0.00 显示。 */
|
|
1104
2519
|
function balanceAmount(value) {
|
|
1105
2520
|
return typeof value === "string" && value.length > 0 ? value : "0.00";
|
|
@@ -1134,6 +2549,7 @@ window.__ModuleLoader__.load({
|
|
|
1134
2549
|
return { high: hitRateText(high), low: hitRateText(low) };
|
|
1135
2550
|
}
|
|
1136
2551
|
|
|
2552
|
+
/** 毫秒 → 秒(一位小数);没有数据给 null。首字延迟这类不会超过一分钟的指标用它。 */
|
|
1137
2553
|
function formatSeconds(ms) {
|
|
1138
2554
|
const n = Number(ms) || 0;
|
|
1139
2555
|
if (n <= 0) return null;
|
|
@@ -1180,25 +2596,96 @@ window.__ModuleLoader__.load({
|
|
|
1180
2596
|
}
|
|
1181
2597
|
|
|
1182
2598
|
/* ------------------------------------------------------------ 段取值 */
|
|
2599
|
+
/**
|
|
2600
|
+
* 峰谷段的气泡数据:host 扫会话日志得到「本周每天」的 token 用量。
|
|
2601
|
+
* 数据还没回来时也给面板(空柱 + 一句提示),保证气泡任何情况下都点得开。
|
|
2602
|
+
*/
|
|
2603
|
+
function weeklyTip(src, t) {
|
|
2604
|
+
const daily = src.daily;
|
|
2605
|
+
const ok = daily !== null && daily !== undefined && daily.ok === true && Array.isArray(daily.days);
|
|
2606
|
+
const totals = ok && daily.totals !== undefined && daily.totals !== null ? daily.totals : null;
|
|
2607
|
+
return {
|
|
2608
|
+
kind: "bars",
|
|
2609
|
+
title: t("tipWeekTitle"),
|
|
2610
|
+
days: ok ? daily.days : [],
|
|
2611
|
+
total: totals === null ? 0 : Number(totals.total) || 0,
|
|
2612
|
+
calls: totals === null ? 0 : Number(totals.calls) || 0,
|
|
2613
|
+
/* 本周花费由 host 按模型单价 + 当时峰谷价算好(单位:人民币元) */
|
|
2614
|
+
cost: totals === null ? 0 : Number(totals.cost) || 0,
|
|
2615
|
+
/* 当月每日用量:右侧活跃度日历用 */
|
|
2616
|
+
month: ok && daily.month !== undefined && daily.month !== null ? daily.month : null,
|
|
2617
|
+
/* 当年 12 个月聚合:标题下面那 12 个方块用 */
|
|
2618
|
+
year: ok && daily.year !== undefined && daily.year !== null ? daily.year : null,
|
|
2619
|
+
today: dateKeyOf(src.now)
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
|
|
1183
2623
|
function segmentView(id, src, t) {
|
|
1184
2624
|
const stats = src.stats;
|
|
1185
2625
|
const usage = src.usage;
|
|
1186
|
-
|
|
2626
|
+
/* 底栏费用用的币种 = **显示币种**(主模型条目的币种,见 displayCurrencyOf),
|
|
2627
|
+
不再是那个全局的"新增条目默认币种" —— 用户报的正是"没用过的模型也能改底栏币种"。 */
|
|
2628
|
+
const symbolCurrency = normalizeCurrency(src.currency);
|
|
2629
|
+
const symbol = currencySymbol(symbolCurrency);
|
|
1187
2630
|
|
|
1188
2631
|
if (id === "status") {
|
|
1189
|
-
/*
|
|
2632
|
+
/* 拆分后这一段只出峰谷两个字:上下文占用圆环是独立开关,固定挂在栏首。
|
|
2633
|
+
点开看本周每天的用量(host 扫会话日志按天汇总,窗口周一 00:00 起)。 */
|
|
1190
2634
|
const period = src.now === undefined || src.now === null ? "" : (isPeakTime(src.now) ? t("f_peak") : t("f_valley"));
|
|
1191
|
-
return { id: id, text: period };
|
|
2635
|
+
return { id: id, text: period, tip: weeklyTip(src, t) };
|
|
1192
2636
|
}
|
|
1193
2637
|
|
|
1194
2638
|
if (id === "counts") {
|
|
1195
2639
|
if (stats === undefined || stats === null || !(stats.steps > 0)) return null;
|
|
1196
|
-
|
|
2640
|
+
const progress = src.progress === undefined || src.progress === null ? null : src.progress;
|
|
2641
|
+
const skills = progress === null || Array.isArray(progress.skills) !== true ? null : progress.skills;
|
|
2642
|
+
const toolCounts = progress === null || progress.toolCounts === null || typeof progress.toolCounts !== "object"
|
|
2643
|
+
? null
|
|
2644
|
+
: progress.toolCounts;
|
|
2645
|
+
const compacts = progress === null || Array.isArray(progress.compacts) !== true ? null : progress.compacts;
|
|
2646
|
+
/* 全局提示词(AGENTS.md 这类)也算一次注入,所以并进技能注入那个数里 */
|
|
2647
|
+
const globalInstruction = progress === null || typeof progress.globalInstruction !== "string"
|
|
2648
|
+
? null
|
|
2649
|
+
: progress.globalInstruction;
|
|
2650
|
+
const numOf = (key) => progress === null || typeof progress[key] !== "number" ? null : progress[key];
|
|
2651
|
+
/* 全局提示词(AGENTS.md 这类)也算一次注入,所以并进技能注入那个数里 */
|
|
2652
|
+
const skillCount = skills === null && globalInstruction === null
|
|
2653
|
+
? null
|
|
2654
|
+
: (skills === null ? 0 : skills.length) + (globalInstruction === null ? 0 : 1);
|
|
2655
|
+
const toolCallCount = numOf("toolCalls");
|
|
2656
|
+
const compactCount = numOf("compactCount");
|
|
2657
|
+
/* 三行形状一致:标签、数值(没数据给横杠)、子视图。
|
|
2658
|
+
0 和「没数据」都不给子视图 —— 点开也没东西看,那一行连可点样式都不该有(见 rowsPanel)。 */
|
|
2659
|
+
const countRow = (label, count, build) => ({
|
|
2660
|
+
label: label,
|
|
2661
|
+
value: count === null ? "-" : formatTokensExact(count),
|
|
2662
|
+
sub: typeof count === "number" && count > 0 ? build() : null
|
|
2663
|
+
});
|
|
2664
|
+
return {
|
|
2665
|
+
id: id,
|
|
2666
|
+
text: t("f_counts", { turns: stats.turns || 0, steps: stats.steps || 0 }),
|
|
2667
|
+
/* 四个数全由 host 的 desktopStatusbarProgress 折出来(官方 sessionStats 只有 turns / steps 与耗时)。
|
|
2668
|
+
这一层只给个数,不带单位;有内容的那几行带 sub,点开换成子视图。
|
|
2669
|
+
顺序:上下文压缩 → 技能注入 → 工具调用。 */
|
|
2670
|
+
tip: {
|
|
2671
|
+
kind: "rows",
|
|
2672
|
+
title: t("tipCountsTitle"),
|
|
2673
|
+
rows: [
|
|
2674
|
+
countRow(t("tipCountsCompactAt"), compactCount, () => compactList(compacts, t)),
|
|
2675
|
+
countRow(t("tipCountsSkills"), skillCount, () => skillList(skills, globalInstruction, t)),
|
|
2676
|
+
countRow(t("tipCountsToolCalls"), toolCallCount, () => toolList(toolCounts, t))
|
|
2677
|
+
]
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
1197
2680
|
}
|
|
1198
2681
|
|
|
1199
2682
|
if (id === "ttft") {
|
|
1200
|
-
|
|
1201
|
-
|
|
2683
|
+
/* 会话级首字平均 = (本会话 ttftMs + 全部子会话 ttftMs) / (本会话 ttftSteps + 子 ttftSteps):
|
|
2684
|
+
用户要求「首字平均」把子智能体算上。**气泡里那个"本轮平均首字"仍只算本页面**
|
|
2685
|
+
(用户明确要求),所以它照旧读 host 的 timing 投影(那本身就是 per-session 的)。 */
|
|
2686
|
+
const merged = mergedSessionStats(stats, subagentStatsOf(src.subagent));
|
|
2687
|
+
if (!(merged.ttftSteps > 0) || !(merged.ttftMs > 0)) return null;
|
|
2688
|
+
const duration = formatSeconds(merged.ttftMs / merged.ttftSteps);
|
|
1202
2689
|
if (duration === null) return null;
|
|
1203
2690
|
const timing = src.timing === undefined || src.timing === null ? null : src.timing;
|
|
1204
2691
|
return {
|
|
@@ -1218,9 +2705,14 @@ window.__ModuleLoader__.load({
|
|
|
1218
2705
|
|
|
1219
2706
|
if (id === "cacheHit") {
|
|
1220
2707
|
if (usage === undefined || usage === null) return null;
|
|
1221
|
-
|
|
2708
|
+
/* 会话级命中率用**合并后的用量**重算:分母口径与本面板一致
|
|
2709
|
+
(命中 + 未命中,未命中 = 未缓存输入 + 缓存写入),只是把队友那一份也加进来。
|
|
2710
|
+
气泡里"本轮命中率"照旧只算本页面(用户明确要求)。 */
|
|
2711
|
+
const sub = subagentOf(src.subagent, "session");
|
|
2712
|
+
const denominator = billedInputTokens(usage) + sub.cached + sub.missInput;
|
|
1222
2713
|
if (denominator <= 0) return null;
|
|
1223
|
-
const
|
|
2714
|
+
const numerator = (usage.cacheReadTokens || 0) + sub.cached;
|
|
2715
|
+
const percent = Math.min(99.99, (numerator / denominator) * 100).toFixed(2);
|
|
1224
2716
|
const extremes = cacheHitExtremes(src.sessionUsage);
|
|
1225
2717
|
return {
|
|
1226
2718
|
id: id,
|
|
@@ -1239,8 +2731,11 @@ window.__ModuleLoader__.load({
|
|
|
1239
2731
|
}
|
|
1240
2732
|
|
|
1241
2733
|
if (id === "tps") {
|
|
1242
|
-
|
|
1243
|
-
|
|
2734
|
+
/* 会话级综合速度 = (本会话 decodeTokens + 子 decodeTokens) / ((本会话 decodeMs + 子 decodeMs)/1000):
|
|
2735
|
+
用户要求「综合速度」把子智能体算上。气泡里"本轮平均速度"仍只算本页面(明确要求)。 */
|
|
2736
|
+
const merged = mergedSessionStats(stats, subagentStatsOf(src.subagent));
|
|
2737
|
+
if (!(merged.decodeMs > 0) || !(merged.decodeTokens > 0)) return null;
|
|
2738
|
+
const throughput = formatThroughput(merged.decodeTokens / (merged.decodeMs / 1000));
|
|
1244
2739
|
const timing = src.timing === undefined || src.timing === null ? null : src.timing;
|
|
1245
2740
|
return {
|
|
1246
2741
|
id: id,
|
|
@@ -1274,17 +2769,30 @@ window.__ModuleLoader__.load({
|
|
|
1274
2769
|
if (elapsed === null) return null;
|
|
1275
2770
|
const duration = formatDuration(elapsed);
|
|
1276
2771
|
if (duration === null) return null;
|
|
2772
|
+
const sessionStats = stats === undefined || stats === null ? null : stats;
|
|
2773
|
+
const num = (key) => (sessionStats === null ? 0 : Number(sessionStats[key]) || 0);
|
|
2774
|
+
const llmMs = num("llmMs");
|
|
2775
|
+
const waitMs = num("ttftMs");
|
|
2776
|
+
const toolMs = num("toolMs");
|
|
2777
|
+
const decodeMs = num("decodeMs");
|
|
2778
|
+
/* 模型用时 = 解码:优先用官方 decodeMs;老日志没有 timing 时用「模型用时 − 首 token」兜底 */
|
|
2779
|
+
const modelMs = decodeMs > 0 ? decodeMs : Math.max(0, llmMs - waitMs);
|
|
1277
2780
|
return {
|
|
1278
2781
|
id: id,
|
|
1279
2782
|
text: t("f_sessionTime", { duration: duration }),
|
|
1280
|
-
/*
|
|
1281
|
-
|
|
2783
|
+
/* 三个数都来自官方 sessionStats:
|
|
2784
|
+
等待用时 = 首 token 延迟(请求排队 + 网络 + 预填充)
|
|
2785
|
+
模型用时 = 解码(首个 token → 输出结束),官方叫 decode
|
|
2786
|
+
工具调用 = 工具执行墙钟;并行工具各算各的,之和可能大于墙钟总用时(口径不同,不互相比较) */
|
|
1282
2787
|
tip: {
|
|
1283
2788
|
kind: "rows",
|
|
1284
2789
|
title: t("segSessionTime"),
|
|
2790
|
+
/* 分隔线换成三段色条:等待灰 / 模型用时紫 / 工具调用蓝,按各自耗时占比分段 */
|
|
2791
|
+
segments: durationSegments(waitMs, modelMs, toolMs),
|
|
1285
2792
|
rows: [
|
|
1286
|
-
{ label: t("
|
|
1287
|
-
{ label: t("
|
|
2793
|
+
{ label: t("tipWaitTime"), value: tipDuration(waitMs), color: DURATION_COLORS.wait },
|
|
2794
|
+
{ label: t("tipModelTime"), value: tipDuration(modelMs), color: DURATION_COLORS.model },
|
|
2795
|
+
{ label: t("tipToolTime"), value: tipDuration(toolMs), color: DURATION_COLORS.tool }
|
|
1288
2796
|
]
|
|
1289
2797
|
}
|
|
1290
2798
|
};
|
|
@@ -1296,7 +2804,9 @@ window.__ModuleLoader__.load({
|
|
|
1296
2804
|
? []
|
|
1297
2805
|
: sessionUsage.calls;
|
|
1298
2806
|
/* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
|
|
1299
|
-
累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。
|
|
2807
|
+
累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。
|
|
2808
|
+
**每条还要按它自己条目的币种折到显示币种**:会话里 A 是人民币、B 是美元时,
|
|
2809
|
+
底栏给的是一个数字(折到主模型那种币),不是两种货币相加。 */
|
|
1300
2810
|
const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
|
|
1301
2811
|
let total = 0;
|
|
1302
2812
|
let priced = false;
|
|
@@ -1305,14 +2815,21 @@ window.__ModuleLoader__.load({
|
|
|
1305
2815
|
const one = costOf(calls[i], callModel, calls[i].at);
|
|
1306
2816
|
if (one === null) continue;
|
|
1307
2817
|
priced = true;
|
|
1308
|
-
total += one;
|
|
2818
|
+
total += toDisplayCurrency(one, src.models, callModel, symbolCurrency, src.defaultCurrency);
|
|
1309
2819
|
}
|
|
2820
|
+
/* 队友(子智能体)是**独立会话**,它的用量不在上面这份本会话调用表里,
|
|
2821
|
+
host 按 parentSession 算好单独给回来;这里直接把数字并进「总计」,
|
|
2822
|
+
不另开一行也不加任何提示字 —— 用户报的正是"总计没算队友"。
|
|
2823
|
+
两段数据(整会话 / 本轮窗口)各自拿到才用,没拿到的那段当 0。 */
|
|
2824
|
+
const sub = subagentOf(src.subagent, "session");
|
|
2825
|
+
const subCost = subagentCostOf(src.subagent, "session", symbolCurrency);
|
|
2826
|
+
const grand = total + subCost;
|
|
1310
2827
|
return {
|
|
1311
2828
|
id: id,
|
|
1312
|
-
/*
|
|
1313
|
-
text: t("f_cost", { symbol: symbol, cost: priced ? costText(
|
|
1314
|
-
/* token
|
|
1315
|
-
tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
|
|
2829
|
+
/* 算不出价(本会话与队友都没价)时给横杠:与"这笔真的是 0"区分开 */
|
|
2830
|
+
text: t("f_cost", { symbol: symbol, cost: priced === true || subCost > 0 ? costText(grand) : "-" }),
|
|
2831
|
+
/* token 计数并进总计:本会话的 + 队友的,同一个数字相加,不新增行 */
|
|
2832
|
+
tip: tokensTip(mergeTokenParts(sessionTokenParts(src.usage), sub), t("f_tokensTotal"), t)
|
|
1316
2833
|
};
|
|
1317
2834
|
}
|
|
1318
2835
|
|
|
@@ -1340,13 +2857,39 @@ window.__ModuleLoader__.load({
|
|
|
1340
2857
|
turnParts.output += turnList[i].output || 0;
|
|
1341
2858
|
if (one === null) continue;
|
|
1342
2859
|
turnPriced = true;
|
|
1343
|
-
|
|
2860
|
+
/* 同样按每条自己的条目币种折到显示币种(见「总计」那段的注释) */
|
|
2861
|
+
turnTotal += toDisplayCurrency(Number(one), src.models, callModel, symbolCurrency, src.defaultCurrency);
|
|
1344
2862
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
2863
|
+
/* 本轮也要含队友,但**只取"本轮窗口"那一份**(host 按客户端传的起点、
|
|
2864
|
+
或它自己从父会话日志推的起点算出来的)。两处起点都没有时 host 返回全 0 ⇒
|
|
2865
|
+
本轮一点队友都不并:用户明确要求「本轮的数值只能小不能大」,宁可少算,
|
|
2866
|
+
也绝不能把队友的整会话花费当成"本轮"塞进来。总计那一项不受影响 ——
|
|
2867
|
+
它自己那一问显式带 since=0,按整会话口径并队友。 */
|
|
2868
|
+
const subTurn = subagentOf(src.subagent, "turn");
|
|
2869
|
+
/* 队友这一段折到显示币种后的金额(逐桶折算,见 subagentDisplayCost) */
|
|
2870
|
+
const subTurnCost = subagentCostOf(src.subagent, "turn", symbolCurrency);
|
|
2871
|
+
const turnTip = tokensTip(mergeTokenParts(billableParts(turnParts), subTurn), t("f_tokensTurn"), t);
|
|
2872
|
+
/* 「本轮」同轮内只增不减(用户实测要求):把这次的金额过一遍粘性下限。
|
|
2873
|
+
窗口起点在一轮内可能移动(父会话日志多一条 user/message、折叠出的最早时间戳变化、
|
|
2874
|
+
host 收敛起点),窗口一变数字就会小一截;这里把已经显示过的最大值兜住,
|
|
2875
|
+
换轮/换会话则作废重算(见 applyTurnFloor)。下限只作用于**金额**,
|
|
2876
|
+
气泡里的 token 明细照实显示本次真正拿到的那份。 */
|
|
2877
|
+
const holdBox = src.lastCostHold;
|
|
2878
|
+
const holdKey = src.lastCostTurn === undefined ? null : src.lastCostTurn;
|
|
2879
|
+
const settleAmount = (amount) => {
|
|
2880
|
+
const next = applyTurnFloor(holdBox, src.sessionId, holdKey, amount);
|
|
2881
|
+
if (holdBox !== null && holdBox !== undefined && typeof holdBox === "object") {
|
|
2882
|
+
holdBox.sessionId = next.hold.sessionId;
|
|
2883
|
+
holdBox.turnKey = next.hold.turnKey;
|
|
2884
|
+
holdBox.value = next.hold.value;
|
|
2885
|
+
}
|
|
2886
|
+
return next.value;
|
|
2887
|
+
};
|
|
2888
|
+
const amountText = (amount) => (amount === null || amount === undefined ? "-" : costText(amount));
|
|
2889
|
+
if (turnPriced === true || subTurnCost > 0) {
|
|
1347
2890
|
return {
|
|
1348
2891
|
id: id,
|
|
1349
|
-
text: t("f_lastCost", { symbol: symbol, cost:
|
|
2892
|
+
text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(turnTotal + subTurnCost)) }),
|
|
1350
2893
|
/* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
|
|
1351
2894
|
tip: turnTip
|
|
1352
2895
|
};
|
|
@@ -1366,39 +2909,66 @@ window.__ModuleLoader__.load({
|
|
|
1366
2909
|
if (active === true && bucket !== null && sameTurn === true) {
|
|
1367
2910
|
const runningCost = costOf(bucket, fallbackModel, src.now);
|
|
1368
2911
|
if (runningCost !== null) {
|
|
1369
|
-
return { id: id, text: t("f_lastCost", { symbol: symbol, cost:
|
|
2912
|
+
return { id: id, text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(toDisplayCurrency(runningCost, src.models, fallbackModel, symbolCurrency, src.defaultCurrency) + subTurnCost)) }), tip: turnTip };
|
|
1370
2913
|
}
|
|
1371
2914
|
}
|
|
1372
2915
|
/* 新一轮已经开跑、但还没有属于它的调用:必须归零,不能退回上一轮
|
|
1373
2916
|
—— 节点折叠曾错位一轮,底栏显示成上上轮的金额,这条就是为堵它留的。
|
|
1374
|
-
一次模型调用都还没完成过(第一轮)时没有"归零"
|
|
1375
|
-
|
|
2917
|
+
一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠;
|
|
2918
|
+
但队友已经在本轮里花了钱的话,就把那一份显示出来。
|
|
2919
|
+
等这一轮真的产生了调用,上面那条精确口径就接管了。
|
|
2920
|
+
「粘性下限」在这里也生效:同轮内刚才显示过更大的数字,就不因为这一拍拿不到
|
|
2921
|
+
本轮数据而跳回 0(换轮时 holdKey 变了,下限自然作废)。 */
|
|
1376
2922
|
if (active === true) {
|
|
1377
2923
|
const settled = src.sessionUsage !== undefined && src.sessionUsage !== null
|
|
1378
2924
|
&& Array.isArray(src.sessionUsage.calls) === true
|
|
1379
2925
|
&& src.sessionUsage.calls.length > 0;
|
|
2926
|
+
const live = settled === true || subTurnCost > 0 ? subTurnCost : null;
|
|
1380
2927
|
return {
|
|
1381
2928
|
id: id,
|
|
1382
|
-
text: t("f_lastCost", { symbol: symbol, cost:
|
|
2929
|
+
text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(live)) }),
|
|
1383
2930
|
tip: turnTip
|
|
1384
2931
|
};
|
|
1385
2932
|
}
|
|
1386
2933
|
/* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额。
|
|
1387
2934
|
没有可用数据时也给 ¥0.00:本轮不整段消失,底栏长度稳定,
|
|
1388
|
-
也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。
|
|
2935
|
+
也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。
|
|
2936
|
+
队友那一份只有"本轮窗口"(拿不到起点就是 0),所以这里的数字只会
|
|
2937
|
+
比"只算本会话"时更大一点点、绝不会变成整会话的花费。 */
|
|
1389
2938
|
const cost = bucket === null ? null : costOf(bucket, fallbackModel, src.now);
|
|
2939
|
+
const idleCost = cost === null ? null : toDisplayCurrency(cost, src.models, fallbackModel, symbolCurrency, src.defaultCurrency);
|
|
2940
|
+
const idleValue = idleCost === null && subTurnCost === 0 ? null : (idleCost === null ? 0 : idleCost) + subTurnCost;
|
|
1390
2941
|
return {
|
|
1391
2942
|
id: id,
|
|
1392
|
-
text: t("f_lastCost", { symbol: symbol, cost:
|
|
2943
|
+
text: t("f_lastCost", { symbol: symbol, cost: amountText(settleAmount(idleValue)) }),
|
|
1393
2944
|
tip: turnTip
|
|
1394
2945
|
};
|
|
1395
2946
|
}
|
|
1396
2947
|
|
|
1397
2948
|
if (id === "balance") {
|
|
1398
|
-
/*
|
|
1399
|
-
|
|
2949
|
+
/* 这个会话的余额是不是「积分」口径(WorkBuddy 等):积分不带货币符号,
|
|
2950
|
+
气泡里也只给总数、不列构成。成功响应自己带 isCredits;拿不到响应时
|
|
2951
|
+
(查询中 / 查询失败)按会话 provider 判断,免得积分会话在失败态下又冒出个 ¥。 */
|
|
2952
|
+
const balanceIsCredits = (value) => {
|
|
2953
|
+
if (value !== null && value !== undefined && value.isCredits === true) return true;
|
|
2954
|
+
const model = src.sessionModel;
|
|
2955
|
+
const provider = model === null || model === undefined || model.provider === undefined || model.provider === null
|
|
2956
|
+
? ""
|
|
2957
|
+
: String(model.provider).toLowerCase();
|
|
2958
|
+
return provider.includes("workbuddy") || provider.includes("codebuddy");
|
|
2959
|
+
};
|
|
2960
|
+
/* 查不到余额(还没查到 / 该平台没配 key / 查询失败)时用同一套两行结构,值都是横杠:
|
|
2961
|
+
气泡里仍然是「充值余额 / 赠金余额」两行,只是没有数字。
|
|
2962
|
+
积分口径没有构成可分,这两行不给 —— 而且它整个气泡都不该出现(见下)。 */
|
|
1400
2963
|
const dashBalance = () => {
|
|
1401
|
-
const
|
|
2964
|
+
const credits = balanceIsCredits(src.balance);
|
|
2965
|
+
const dashSymbol = credits ? "" : currencySymbol(src.currency);
|
|
2966
|
+
if (credits === true) {
|
|
2967
|
+
/* 积分口径不给 tip:段没有 tip 就不会变成可点击项(见渲染处的 tip !== null),
|
|
2968
|
+
点不出、悬停也不弹。积分只有一个总数、没有可展开的内容,
|
|
2969
|
+
弹一个只有标题的空气泡只会让人以为能点。 */
|
|
2970
|
+
return { id: id, text: t("f_balanceDash", { symbol: dashSymbol }) };
|
|
2971
|
+
}
|
|
1402
2972
|
return {
|
|
1403
2973
|
id: id,
|
|
1404
2974
|
text: t("f_balanceDash", { symbol: dashSymbol }),
|
|
@@ -1412,31 +2982,69 @@ window.__ModuleLoader__.load({
|
|
|
1412
2982
|
}
|
|
1413
2983
|
};
|
|
1414
2984
|
};
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
? ""
|
|
1418
|
-
: String(sessionModelNow.provider === undefined ? "" : sessionModelNow.provider)
|
|
1419
|
-
+ " " + String(sessionModelNow.model === undefined ? "" : sessionModelNow.model);
|
|
1420
|
-
if (modelText.toLowerCase().includes("deepseek") !== true) return dashBalance();
|
|
2985
|
+
/* 显示哪一家的余额由 host 按「会话实际用的模型/平台」决定,这里只看结果:
|
|
2986
|
+
查到了就显示,没查到(该平台没配 key / 请求失败)才是横杠。 */
|
|
1421
2987
|
const balance = src.balance;
|
|
1422
2988
|
if (balance === null || balance === undefined) return dashBalance();
|
|
1423
2989
|
if (balance.ok !== true) return dashBalance();
|
|
1424
2990
|
if (typeof balance.total !== "string") return dashBalance();
|
|
1425
2991
|
/* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
|
|
1426
|
-
/* 百分比口径(GLM
|
|
1427
|
-
|
|
1428
|
-
|
|
2992
|
+
/* 百分比口径(GLM 配额)与积分口径(WorkBuddy)都不带货币符号,
|
|
2993
|
+
否则会显示成 ¥37.50% / ¥177 这种把积分当钱的写法 */
|
|
2994
|
+
const isCredits = balanceIsCredits(balance);
|
|
2995
|
+
const balSymbol = balance.isPercent === true || isCredits === true
|
|
2996
|
+
? ""
|
|
2997
|
+
: currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
|
|
2998
|
+
/* 积分口径:只有一个总积分,没有「充值 / 赠金」构成可展开 ⇒ 不给 tip。
|
|
2999
|
+
段没有 tip 就不会被渲染成可点击项(渲染处按 tip !== null 决定 className
|
|
3000
|
+
与 onClick),所以气泡点不开、悬停也不弹 —— 这正是要的:
|
|
3001
|
+
弹一个只有标题、没有任何内容的空气泡只会让人以为还能点。 */
|
|
3002
|
+
if (isCredits === true) {
|
|
3003
|
+
return { id: id, text: t("f_balance", { symbol: balSymbol, amount: balance.total }) };
|
|
3004
|
+
}
|
|
3005
|
+
/* 分隔线换成色条,两种情况各一套:
|
|
3006
|
+
金额余额(DeepSeek 等)= 充值 : 赠金,按金额占比分两段(紫 / 蓝);
|
|
3007
|
+
百分比配额(GLM 等)= 已用 : 剩余,未用那段用「系统提示词」的同款灰。
|
|
3008
|
+
余额为负或拿不到数字时算不出占比,就不给 segments,面板退回普通分隔线。 */
|
|
3009
|
+
const isQuota = balance.isPercent === true;
|
|
3010
|
+
const topUp = Number(balance.toppedUp) || 0;
|
|
3011
|
+
const granted = Number(balance.granted) || 0;
|
|
3012
|
+
const balanceSum = Math.max(0, topUp) + Math.max(0, granted);
|
|
3013
|
+
const quotaUsed = isQuota ? Math.max(0, Math.min(100, Number(String(balance.total).replace("%", "")) || 0)) : 0;
|
|
3014
|
+
const quotaLeft = isQuota ? Math.max(0, 100 - quotaUsed) : 0;
|
|
3015
|
+
/* 百分比配额(GLM 等):只有「未用」那段有颜色,且它**从最左边开始**画;
|
|
3016
|
+
右边已用的位置由一个透明段顶着(露出轨道底色)。
|
|
3017
|
+
为什么不用单段 + 右侧留空:气泡是 width:max-content,条又是 flex 容器,
|
|
3018
|
+
单个百分比段在"容器宽度由谁决定"上循环,实际渲染出来比例会偏;
|
|
3019
|
+
两段显式百分比之和为 100%,宽度就由浏览器按比例切,不会漂。
|
|
3020
|
+
行只列「剩余配额」一行并带同款灰图例;已用那边不列行。 */
|
|
3021
|
+
const balanceSegments = isQuota
|
|
3022
|
+
? [
|
|
3023
|
+
{ color: BALANCE_COLORS.left, width: quotaLeft },
|
|
3024
|
+
{ color: "transparent", width: quotaUsed }
|
|
3025
|
+
].filter((one) => one.width > 0)
|
|
3026
|
+
: balanceSum > 0
|
|
3027
|
+
? [
|
|
3028
|
+
{ color: BALANCE_COLORS.topUp, width: (Math.max(0, topUp) / balanceSum) * 100 },
|
|
3029
|
+
{ color: BALANCE_COLORS.granted, width: (Math.max(0, granted) / balanceSum) * 100 }
|
|
3030
|
+
].filter((one) => one.width > 0)
|
|
3031
|
+
: [];
|
|
3032
|
+
const balanceRows = isQuota
|
|
3033
|
+
? [{ label: t("tipQuotaLeft"), value: quotaLeft.toFixed(2) + "%", color: BALANCE_COLORS.left }]
|
|
3034
|
+
: [
|
|
3035
|
+
{ label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp), color: BALANCE_COLORS.topUp },
|
|
3036
|
+
{ label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted), color: BALANCE_COLORS.granted }
|
|
3037
|
+
];
|
|
1429
3038
|
const tip = {
|
|
1430
3039
|
kind: "rows",
|
|
1431
3040
|
title: t("segBalance"),
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
{ label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) },
|
|
3041
|
+
segments: balanceSegments,
|
|
3042
|
+
rows: balanceRows.concat([
|
|
1435
3043
|
/* 累计消费只有部分平台能给(智谱有,DeepSeek 的接口没有),没有就不显示这一行 */
|
|
1436
3044
|
balance.spent === undefined || balance.spent === null
|
|
1437
3045
|
? null
|
|
1438
3046
|
: { label: t("tipSpent"), value: balSymbol + balanceAmount(balance.spent) }
|
|
1439
|
-
].filter((one) => one !== null)
|
|
3047
|
+
].filter((one) => one !== null))
|
|
1440
3048
|
};
|
|
1441
3049
|
/* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
|
|
1442
3050
|
if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
|
|
@@ -1453,21 +3061,23 @@ window.__ModuleLoader__.load({
|
|
|
1453
3061
|
function detectLocale(t) {
|
|
1454
3062
|
return t("segCounts") === zh.segCounts ? "zh" : "en";
|
|
1455
3063
|
}
|
|
1456
|
-
function segmentText(id, view, t) {
|
|
3064
|
+
function segmentText(id, view, t, currency) {
|
|
1457
3065
|
if (view !== null && view !== undefined) return view.text;
|
|
1458
3066
|
const segment = SEGMENTS.filter((s) => s.id === id)[0];
|
|
1459
3067
|
if (segment === undefined || segment.short === undefined) return "";
|
|
1460
3068
|
const locale = detectLocale(t);
|
|
1461
3069
|
const label = segment.short[locale] !== undefined ? segment.short[locale] : segment.short.zh;
|
|
1462
3070
|
const dash = "-";
|
|
3071
|
+
/* 横杠占位也用**显示币种**的符号(与有数据时一致);没传就退回默认币种 */
|
|
3072
|
+
const symbol = currencySymbol(normalizeCurrency(currency === undefined || currency === null ? config.currency : currency));
|
|
1463
3073
|
if (id === "counts") return dash + t("unitCountTurns") + " " + dash + t("unitCountSteps");
|
|
1464
3074
|
if (id === "cacheHit") return label + " " + dash + " %";
|
|
1465
3075
|
if (id === "ttft") return label + " " + dash + " s";
|
|
1466
3076
|
/* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
|
|
1467
3077
|
if (id === "tps") return label + " " + dash;
|
|
1468
3078
|
if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
|
|
1469
|
-
if (id === "cost" || id === "lastCost") return label + " " +
|
|
1470
|
-
if (id === "balance") return t("f_balanceDash", { symbol:
|
|
3079
|
+
if (id === "cost" || id === "lastCost") return label + " " + symbol + dash;
|
|
3080
|
+
if (id === "balance") return t("f_balanceDash", { symbol: symbol });
|
|
1471
3081
|
return dash;
|
|
1472
3082
|
}
|
|
1473
3083
|
/* ---------------------------------------------------------------- css */
|
|
@@ -1495,30 +3105,129 @@ window.__ModuleLoader__.load({
|
|
|
1495
3105
|
'.dsb-seg-click:hover,.dsb-seg-click[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary)}',
|
|
1496
3106
|
".dsb-ring-track{fill:none;stroke:var(--dsw-alias-border-l3,rgba(127,127,127,.25));stroke-width:2px}",
|
|
1497
3107
|
".dsb-ring-fill{fill:none;stroke:var(--dsw-alias-label-tertiary);stroke-width:2px;stroke-linecap:round}",
|
|
1498
|
-
|
|
3108
|
+
/* 运行时:绿色那段在淡灰圆圈里匀速转,像加载圈。
|
|
3109
|
+
-90° 必须写进关键帧:circle 上的 SVG transform="rotate(-90 7 7)"(把弧的起点挪到 12 点)
|
|
3110
|
+
会被 CSS transform 整个覆盖,不写回来就会从 3 点起、看起来跑偏。
|
|
3111
|
+
transform-box:view-box + origin:center 让旋转中心稳稳落在 viewBox 中心 (7,7)。 */
|
|
3112
|
+
"@keyframes dsb-ring-spin{from{transform:rotate(-90deg)}to{transform:rotate(270deg)}}",
|
|
3113
|
+
".dsb-ring-running .dsb-ring-fill{stroke:#22c55e;transform-box:view-box;transform-origin:center;animation:dsb-ring-spin 1.4s linear infinite}",
|
|
3114
|
+
"@media (prefers-reduced-motion: reduce){.dsb-ring-running .dsb-ring-fill{animation:none}}",
|
|
1499
3115
|
".dsb-ring-error .dsb-ring-fill{stroke:#ef4444}",
|
|
1500
3116
|
".dsb-ring-approval .dsb-ring-fill{stroke:#f59e0b}",
|
|
1501
3117
|
".dsb-bar{display:flex;gap:1px;height:4px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15));overflow:hidden;margin:6px 0 2px}",
|
|
1502
3118
|
".dsb-bar-seg{flex:none;min-width:2px;height:100%;border-radius:1px;background:var(--dsw-alias-label-tertiary)}",
|
|
1503
3119
|
/* 宽度按内容自适应:width:max-content 让气泡收到"最长那一行"的宽度,
|
|
1504
|
-
* 行内标签与数值之间至少留
|
|
3120
|
+
* 行内标签与数值之间至少留 5ch(.dsb-meter-value 的 padding-left),所以不会出现大片留白;
|
|
1505
3121
|
* 内容超过 max-width 时退回换行(white-space:normal)。 */
|
|
1506
3122
|
".dsb-tip.dsb-meter{width:max-content;max-width:min(560px,100vw - 24px);white-space:normal;font-size:12px;line-height:18px}",
|
|
1507
3123
|
".dsb-meter-head{display:flex;align-items:center;gap:6px}",
|
|
1508
3124
|
".dsb-meter-headline{color:var(--dsw-alias-label-primary)}",
|
|
1509
3125
|
".dsb-meter-percent{color:var(--dsw-alias-label-primary);font-weight:500}",
|
|
1510
|
-
/* 标签与数值之间至少留
|
|
1511
|
-
* "最长那一行的标签 +
|
|
1512
|
-
|
|
3126
|
+
/* 标签与数值之间至少留 5ch:配合上面的 width:max-content,气泡宽度就是
|
|
3127
|
+
* "最长那一行的标签 + 五个空格 + 数值",其余行在这个宽度里右对齐(间隙更大)。
|
|
3128
|
+
* 悬停浮层那 6 行也是这个类(hoverRow 拼的是 dsb-meter-figures),
|
|
3129
|
+
* 它另有一条更专指的覆盖在下面(.dsb-tip.dsb-hovercard)。 */
|
|
3130
|
+
".dsb-meter-figures{margin-left:auto;padding-left:5ch;color:var(--dsw-alias-label-primary);font-weight:500;font-variant-numeric:tabular-nums}",
|
|
1513
3131
|
/* 复刻官方「会话统计」面板:标题下面那条细线(有进度条的面板不加,条本身就分隔了) */
|
|
1514
3132
|
".dsb-meter-divider{display:block;height:1px;margin:5px 0 0;background:var(--dsw-alias-separator-primary,rgba(127,127,127,.3))}",
|
|
1515
3133
|
".dsb-meter-rows{display:flex;flex-direction:column;margin-top:4px}",
|
|
1516
3134
|
/* 两列气泡(标题下带分隔线的那几个)线到第一行再收 3px */
|
|
1517
3135
|
".dsb-meter-rows-fit{margin-top:1px}",
|
|
1518
|
-
|
|
3136
|
+
/* 行的上下内边距故意不对称(上 1 下 3,总高不变):12px 字在 18px 行盒里,
|
|
3137
|
+
字体度量本身就让字形视觉中心比行盒中心低 1px(msyh/segoeui 的 ascent 12.70 / descent 3.14,
|
|
3138
|
+
基线落在 13.78)。补这 1px 之后悬停底色的上下留白才是均等的 —— 别"修正"回 2px/2px。 */
|
|
3139
|
+
".dsb-meter-row{display:flex;align-items:center;gap:6px;padding:1px 0 3px}",
|
|
1519
3140
|
".dsb-meter-swatch{flex:none;width:8px;height:8px;border-radius:2px}",
|
|
1520
3141
|
".dsb-meter-name{color:var(--dsw-alias-label-secondary)}",
|
|
1521
|
-
|
|
3142
|
+
/* 两列面板里的行:标签与数值之间至少留 5ch(工具调用那个子视图另有一条更紧的覆盖,
|
|
3143
|
+
见 .dsb-meter-tight),其余行在这个宽度里右对齐。 */
|
|
3144
|
+
".dsb-meter-value{margin-left:auto;padding-left:5ch;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
|
|
3145
|
+
/* 工具调用子视图:名字短、列多,5ch 太占宽度,收到 1ch */
|
|
3146
|
+
".dsb-meter-tight .dsb-meter-value{padding-left:1ch}",
|
|
3147
|
+
/* 可点行(会话数据那几行):光标 + 悬停底色,跟设置页那些可点项同一套反馈。
|
|
3148
|
+
底色两端做成半圆(胶囊),行尾不加箭头 —— 只靠底色与光标表示可点。
|
|
3149
|
+
上下内边距跟 .dsb-meter-row 保持一致(1/3),可点与不可点的行文字才不会错位。 */
|
|
3150
|
+
".dsb-meter-row-click{cursor:pointer;margin:0 -4px;padding:1px 4px 3px;border-radius:999px}",
|
|
3151
|
+
".dsb-meter-row-click:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
|
|
3152
|
+
/* 子视图标题就是返回入口:不加返回箭头,悬停变亮 */
|
|
3153
|
+
".dsb-meter-back{cursor:pointer}",
|
|
3154
|
+
".dsb-meter-back:hover{color:var(--dsw-alias-label-secondary)}",
|
|
3155
|
+
/* 子视图列表分列:每列最多三行(由 listColumns 决定),每列自己两列对齐。
|
|
3156
|
+
列多到一排放不下就折到下一排;兜底再压一道高度上限,不越过屏幕可用高度的一半。 */
|
|
3157
|
+
".dsb-meter-cols{flex-direction:row;flex-wrap:wrap;align-content:flex-start;align-items:flex-start;gap:6px 16px;max-height:calc(50vh - 46px);overflow-y:auto;overscroll-behavior:contain}",
|
|
3158
|
+
".dsb-meter-col{display:flex;flex-direction:column}",
|
|
3159
|
+
".dsb-tip.dsb-meter-wide{max-width:min(880px,100vw - 24px)}",
|
|
3160
|
+
/* 活跃总览:标题 → 分隔线 → 数值行 → 左边柱状图 + 右边当月活跃度 */
|
|
3161
|
+
".dsb-week{display:flex;flex-direction:column;gap:4px}",
|
|
3162
|
+
/* 分隔线在 flex 列里由 gap 管间距,自身的 margin 去掉,免得标题与线之间被撑开 */
|
|
3163
|
+
".dsb-week .dsb-meter-divider{margin:0}",
|
|
3164
|
+
/* 左右两块:左边柱状图、右边当月活跃度日历。
|
|
3165
|
+
宽度账(加起来正好是 169px,与上面的月份条等宽):
|
|
3166
|
+
柱区 65 + 行间距 6 + 月历 98 = 169。行间距从 12 收到 6,柱区就多出 6px 往右延展,
|
|
3167
|
+
右侧离月历的周反馈条还剩一点空隙,不会贴上。 */
|
|
3168
|
+
".dsb-week-body{display:flex;align-items:flex-end;justify-content:space-between;gap:6px}",
|
|
3169
|
+
/* 柱身对齐「总计用量」那条横条的纤细感:5px 宽、最高 65px,细长而不是方块。
|
|
3170
|
+
柱区给足 65px 并用 space-between 摊开,柱间距约 5px(4px 是最小值)。 */
|
|
3171
|
+
".dsb-week-chart{display:flex;align-items:flex-end;justify-content:space-between;gap:4px;width:65px}",
|
|
3172
|
+
/* 月份条:1–6 月 + 中间无色的全年段 + 7–12 月,共 13 段平分这一行。
|
|
3173
|
+
高矮取「总计用量」那条色条(4px);169px 是下面那行「柱图 + 月历」的实际宽度。 */
|
|
3174
|
+
".dsb-year{display:flex;gap:2px;width:169px;margin:2px 0}",
|
|
3175
|
+
".dsb-year-cell{flex:1 1 0;height:4px;border-radius:2px}",
|
|
3176
|
+
/* 中间那段无色:不表示任何月份,只作为「全年」的悬停入口;
|
|
3177
|
+
悬停时变红 #fa8b8b —— 与紫 #a78bfa 严格同饱和度同明度:
|
|
3178
|
+
#a78bfa = hsl(255, 91.7%, 76.3%) → #fa8b8b = hsl(0, 91.7%, 76.3%) */
|
|
3179
|
+
".dsb-year-all{background:transparent}",
|
|
3180
|
+
".dsb-year-all:hover{background:#fa8b8b}",
|
|
3181
|
+
/* 活跃度日历:按周分块,每块是一行 7 格(周一→周日),外面套一层淡灰底板 */
|
|
3182
|
+
/* 高度锁 65px = 柱区的高度,行间距用 space-between 自动分配(5 周时约 3.75px、6 周时约 1px),
|
|
3183
|
+
这样月历的上下边界与柱区严格对齐,不会因为当月跨 5 周还是 6 周而错开。 */
|
|
3184
|
+
".dsb-heat{display:flex;flex-direction:column;justify-content:space-between;height:65px}",
|
|
3185
|
+
".dsb-heat-week{display:flex;flex-direction:column}",
|
|
3186
|
+
/* 底板平时完全透明,只有悬停到"能读周数据"的区域时才由 active 类浮出淡灰:
|
|
3187
|
+
左右各留一块 8px 的露出区,中间是 7 个方块;两端半圆,上下与方块齐平。 */
|
|
3188
|
+
".dsb-heat-pad{display:flex;align-items:center;border-radius:999px}",
|
|
3189
|
+
".dsb-heat-edge{display:block;position:relative;width:8px;align-self:stretch}",
|
|
3190
|
+
/* 热区比露出的那块再宽一点:左右各 4px、上下各 3px(上下正好等于行距,不压到邻行) */
|
|
3191
|
+
".dsb-heat-edge::before{content:\"\";position:absolute;left:-4px;right:-4px;top:-3px;bottom:-3px}",
|
|
3192
|
+
/* 悬停在"能读周数据"的区域时底板加深:左右露出区 + 跨月天留下的空位格,方块不算 */
|
|
3193
|
+
/* 悬停时这条底板浮出:用与格子同一套灰的 25% */
|
|
3194
|
+
".dsb-heat-pad-active{background-color:color-mix(in srgb,var(--dsw-static-neutral-bluish-400) 25%,transparent)}",
|
|
3195
|
+
".dsb-heat-weekrow{display:flex;gap:2px}",
|
|
3196
|
+
".dsb-heat-cell{width:10px;height:10px;border-radius:2px}",
|
|
3197
|
+
".dsb-heat-empty{background:transparent}",
|
|
3198
|
+
/* 0 = 当天没用量,用「系统提示词」那一项的灰占位;今天这一格是基准(整灰) */
|
|
3199
|
+
".dsb-heat-0{background:var(--dsw-static-neutral-bluish-400)}",
|
|
3200
|
+
/* 已经过去但没用量的日子 / 还没到的日子:都算「没有数据」,统一成整灰的四分之一。
|
|
3201
|
+
日块与月条共用这两个类,所以这一条同时管两处。 */
|
|
3202
|
+
".dsb-past,.dsb-future{background:color-mix(in srgb,var(--dsw-static-neutral-bluish-400) 25%,transparent)}",
|
|
3203
|
+
/* 四档蓝:25 / 50 / 75 / 100 */
|
|
3204
|
+
".dsb-heat-1{background:color-mix(in srgb,var(--dsw-static-blue-450,#3b82f6) 25%,transparent)}",
|
|
3205
|
+
".dsb-heat-2{background:color-mix(in srgb,var(--dsw-static-blue-450,#3b82f6) 50%,transparent)}",
|
|
3206
|
+
".dsb-heat-3{background:color-mix(in srgb,var(--dsw-static-blue-450,#3b82f6) 75%,transparent)}",
|
|
3207
|
+
".dsb-heat-4{background:var(--dsw-static-blue-450,#3b82f6)}",
|
|
3208
|
+
/* 月内用量最高的那一天固定紫,取「工具调用」那一项的颜色 */
|
|
3209
|
+
".dsb-heat-top{background:#a78bfa}",
|
|
3210
|
+
/* 悬停浮层:与主气泡同一套质感,但字号比标题(12px)更小、行距更紧、数值不加粗。
|
|
3211
|
+
选择器必须带上 .dsb-tip.dsb-meter 这一段,否则压不过主气泡那两条高优先级的字号声明。 */
|
|
3212
|
+
".dsb-tip.dsb-hovercard{font-size:10px;line-height:14px}",
|
|
3213
|
+
".dsb-tip.dsb-hovercard .dsb-meter-rows{margin-top:2px}",
|
|
3214
|
+
".dsb-tip.dsb-hovercard .dsb-meter-row{padding:0;gap:4px}",
|
|
3215
|
+
".dsb-tip.dsb-hovercard .dsb-meter-figures{font-weight:400;padding-left:5ch}",
|
|
3216
|
+
/* 柱的悬停:::before 只负责把热区左右各撑 2px(透明、看不见),
|
|
3217
|
+
::after 才是变灰反馈,left/right 都是 0 —— 严格不超出柱身那 5px。
|
|
3218
|
+
同样由 JS 状态驱动,用 :hover 的话划过柱间缝隙会一闪一闪。 */
|
|
3219
|
+
".dsb-day{position:relative;display:flex;flex-direction:column;align-items:center;gap:3px;width:5px}",
|
|
3220
|
+
/* 热区:左右各撑 2px,透明看不见(让鼠标不会掉进柱间 4px 的缝) */
|
|
3221
|
+
".dsb-day::before{content:\"\";position:absolute;left:-2px;right:-2px;top:0;bottom:0;border-radius:3px}",
|
|
3222
|
+
/* 反馈条平时完全透明(只负责把基准高度撑住、让柱身贴底),
|
|
3223
|
+
只有悬停那一列才由 active 类浮出淡灰;高度与月历一致,见下面 .dsb-heat。 */
|
|
3224
|
+
".dsb-day-track{display:flex;align-items:flex-end;width:5px;height:65px;border-radius:3px}",
|
|
3225
|
+
".dsb-day-active .dsb-day-track{background:color-mix(in srgb,var(--dsw-static-neutral-bluish-400) 25%,transparent)}",
|
|
3226
|
+
/* 柱身高度由内联 style 按占比给;顶部做成半圆(999px 会被宽度限制成半径 2.5px),底部平贴基线 */
|
|
3227
|
+
".dsb-day-stack{display:flex;flex-direction:column-reverse;width:5px;border-radius:999px 999px 0 0;overflow:hidden}",
|
|
3228
|
+
".dsb-day-seg{display:block;width:100%}",
|
|
3229
|
+
/* 柱子顶部那一截:高峰时段用掉的命中缓存,用与格子同一套灰的 75% */
|
|
3230
|
+
".dsb-day-peak{background:color-mix(in srgb,var(--dsw-static-neutral-bluish-400) 75%,transparent)}",
|
|
1522
3231
|
".dsb-seg{white-space:nowrap}",
|
|
1523
3232
|
/* 气泡相对状态栏定位 */
|
|
1524
3233
|
".dsb-root{position:relative}",
|
|
@@ -1532,9 +3241,11 @@ window.__ModuleLoader__.load({
|
|
|
1532
3241
|
* (rgb(from …) 是 CSS 相对颜色语法,Chromium 119+ 支持;不支持时上一行的官方原值仍然生效)。 */
|
|
1533
3242
|
/* 柔光阴影去掉:只留 .5px 描边,浮层看起来更利落 */
|
|
1534
3243
|
/* box-sizing:border-box 必须留着:内联宽度要含 padding,否则面板右边缘会比对齐点偏出 22px */
|
|
1535
|
-
".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:40;pointer-events:
|
|
3244
|
+
".dsb-tip{position:fixed;box-sizing:border-box;padding:6px 10px;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f));background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));white-space:nowrap;z-index:40;pointer-events:auto}",
|
|
1536
3245
|
".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
|
|
1537
3246
|
/* 设置页 */
|
|
3247
|
+
/* 这里的 14px 是面板内所有文字的继承来源;挂到 body 的跟手浮层继承不到它,
|
|
3248
|
+
那边(.dsb-drag-card)自己写死了一份 —— 改这里要同步那边。 */
|
|
1538
3249
|
".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
|
|
1539
3250
|
/* 页头:与官方 Agent 预设设置页同款(18px/600,标题与说明间距 12px) */
|
|
1540
3251
|
".dsb-settings .dsb-head{display:flex;flex-direction:column;gap:12px}",
|
|
@@ -1559,8 +3270,29 @@ window.__ModuleLoader__.load({
|
|
|
1559
3270
|
".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
|
|
1560
3271
|
/* 被拿起来的那一行:原位留个淡影,跟手的是下面那张卡 */
|
|
1561
3272
|
".dsb-settings .dsb-row.dsb-dragging{opacity:.32}",
|
|
1562
|
-
/*
|
|
1563
|
-
|
|
3273
|
+
/* 跟手浮层:portal 到 body 之后就不再是 .dsb-settings 的后代,
|
|
3274
|
+
所以样式挂它自己的类(.dsb-drag-card),不再写后代选择器。
|
|
3275
|
+
z-index 取 1100:DSH 里面板类浮层(stat-dialog 等)就是这个量级,
|
|
3276
|
+
必须高过设置遮罩的 1000,否则浮层会沉到设置面板背后。
|
|
3277
|
+
背景色用 var(--dsw-specific-menu) 的兜底链:portal 到 body 后依然继承得到
|
|
3278
|
+
:root 上的主题变量,玻璃材质与气泡保持一致。
|
|
3279
|
+
**字号必须自己写死 14px**:面板那 14px 是设在 .dsb-settings 容器上的,
|
|
3280
|
+
浮层挂到 body 后就继承不到,会掉回 body 的 16px —— 一按拖动手柄字就变大
|
|
3281
|
+
(实测 2026-10-09)。user-select 同理:面板是 .dsb-row 上关掉的。
|
|
3282
|
+
改 .dsb-settings 的字号时,这里要同步。 */
|
|
3283
|
+
".dsb-drag-card{position:fixed;z-index:1100;pointer-events:none;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:var(--dsw-radius-md,12px);font-size:14px;user-select:none;-webkit-user-select:none;background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
|
|
3284
|
+
/* 浮层被 portal 到 body 之后就不是 .dsb-settings 的后代了,
|
|
3285
|
+
它内部那几件(勾选框 / 名称 / 描述 / 握把点阵)得再写一份挂在自己名下,
|
|
3286
|
+
否则浮层里的行会退化成无样式的裸文本。规则内容与上面那组逐字一致。 */
|
|
3287
|
+
".dsb-drag-card .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
|
|
3288
|
+
".dsb-drag-card .dsb-check input{flex:none;margin:2px 0 0}",
|
|
3289
|
+
".dsb-drag-card input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
|
|
3290
|
+
".dsb-drag-card .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
|
|
3291
|
+
".dsb-drag-card .dsb-name{color:var(--dsw-alias-label-primary)}",
|
|
3292
|
+
".dsb-drag-card .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
|
|
3293
|
+
".dsb-drag-card .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
|
|
3294
|
+
'.dsb-drag-card .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
|
|
3295
|
+
".dsb-drag-card .dsb-handle-off{opacity:.25}",
|
|
1564
3296
|
".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
|
|
1565
3297
|
'.dsb-settings .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
|
|
1566
3298
|
".dsb-settings .dsb-handle:active{cursor:grabbing}",
|
|
@@ -1568,14 +3300,17 @@ window.__ModuleLoader__.load({
|
|
|
1568
3300
|
".dsb-settings .dsb-handle-off{opacity:.25}",
|
|
1569
3301
|
/* 价格库 */
|
|
1570
3302
|
".dsb-settings .dsb-card{border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.25));border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:10px}",
|
|
1571
|
-
".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
|
|
3303
|
+
".dsb-settings .dsb-current{display:flex;align-items:center;gap:12px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
|
|
1572
3304
|
".dsb-settings .dsb-current b{color:var(--dsw-alias-label-primary);font-weight:600}",
|
|
3305
|
+
/* 「更新节假峰谷」+ 状态提示整体贴到这一行的右端,与下面的添加/修改按钮同一条右边界;
|
|
3306
|
+
提示在按钮前面,出现时按钮的右边界不动 */
|
|
3307
|
+
".dsb-settings .dsb-current-act{display:flex;align-items:center;gap:8px;margin-left:auto}",
|
|
1573
3308
|
".dsb-settings .dsb-models{display:flex;flex-direction:column}",
|
|
1574
3309
|
".dsb-settings .dsb-model{position:relative;display:flex;flex-direction:column;gap:8px;padding:6px 0;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.18))}",
|
|
1575
3310
|
/* 编辑区:毛玻璃浮层,从卡片头部下方展开,盖在下面的内容上 */
|
|
1576
3311
|
".dsb-settings .dsb-edit-pop{position:absolute;top:calc(100% - 2px);left:0;right:0;z-index:55;display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-radius:var(--dsw-radius-lg,16px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
|
|
1577
3312
|
".dsb-settings .dsb-tier{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
1578
|
-
".dsb-settings .dsb-tier-name{flex:none;width:48px;font-size:12px;color:var(--dsw-alias-label-secondary)}",
|
|
3313
|
+
".dsb-settings .dsb-tier-name{flex:none;width:48px;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}",
|
|
1579
3314
|
".dsb-settings .dsb-model:first-child{border-top:0;padding-top:0}",
|
|
1580
3315
|
".dsb-settings .dsb-model:last-child{padding-bottom:0}",
|
|
1581
3316
|
".dsb-settings .dsb-model-name{color:var(--dsw-alias-label-primary);font-weight:600;font-size:13px}",
|
|
@@ -1585,19 +3320,29 @@ window.__ModuleLoader__.load({
|
|
|
1585
3320
|
".dsb-settings .dsb-inline-check{display:flex;align-self:flex-start;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}",
|
|
1586
3321
|
/* 勾选在左、计价单位在右 */
|
|
1587
3322
|
".dsb-settings .dsb-tier-head{display:flex;align-items:center;justify-content:space-between;gap:8px}",
|
|
3323
|
+
/* 币种只有条目这一个下拉:CSS 给它的位置留白(条目级选择器与"峰谷计价"同一行) */
|
|
3324
|
+
".dsb-settings .dsb-currency-pick{display:flex;align-items:center;gap:8px}",
|
|
1588
3325
|
".dsb-settings .dsb-select{position:relative}",
|
|
1589
3326
|
/* 就一个窄框,字号跟旁边的「峰谷计价」一致,不带下拉箭头 */
|
|
1590
3327
|
".dsb-settings .dsb-select-btn{display:inline-flex;align-items:center;height:22px;padding:0 8px;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:6px;cursor:pointer}",
|
|
1591
3328
|
/* 下拉复刻底栏气泡那套材质:半透明菜单底 + 毛玻璃 + 大圆角 + .5px 描边柔光 */
|
|
1592
|
-
|
|
3329
|
+
/* 宽度贴合文字:原先写死 min-width:92px,只有三个字母的菜单右边空出一大截。
|
|
3330
|
+
材质保持毛玻璃,只把不透明度从 .382 提到 .618 —— 原来太透,底下价格卡上的数字会透上来。 */
|
|
3331
|
+
".dsb-settings .dsb-select-menu{position:absolute;top:calc(100% + 4px);right:0;padding:4px;border-radius:var(--dsw-radius-lg,16px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .618);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3));z-index:60}",
|
|
1593
3332
|
".dsb-settings .dsb-select-item{display:block;width:100%;padding:6px 10px;border:0;border-radius:var(--dsw-radius-sm,8px);background:transparent;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer}",
|
|
1594
3333
|
".dsb-settings .dsb-select-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}",
|
|
1595
3334
|
".dsb-settings .dsb-select-item-on{color:var(--dsw-alias-brand-primary,#4d6bfe)}",
|
|
1596
|
-
/*
|
|
3335
|
+
/* 三组字段整体贴右边缘:左边留给时段名,右边一列对齐。
|
|
3336
|
+
未勾选「峰谷计价」时那行也走同一套 —— 左边是「全时段」标签,
|
|
3337
|
+
不再用"单档那行没有档位名"时的那条双边对齐规则(用户嫌它跟勾选后的两行不一致)。 */
|
|
1597
3338
|
".dsb-settings .dsb-fields{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}",
|
|
1598
3339
|
".dsb-settings .dsb-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
1599
3340
|
/* 够放 xx.xx 就行,输入框不再拉一大条 */
|
|
1600
|
-
|
|
3341
|
+
/* 价格框:只有它 24px。选择器必须带 input[type=...] —— 通用规则
|
|
3342
|
+
`.dsb-settings input[type=number]{height:28px}` 的权重与 `.dsb-field input` 相等、
|
|
3343
|
+
又排在后面,会把高度覆盖回去(实测踩过:用户看到"又变高了")。
|
|
3344
|
+
自定义模型的名称框走通用规则,保持 28px。 */
|
|
3345
|
+
".dsb-settings .dsb-field input[type=text],.dsb-settings .dsb-field input[type=number]{width:52px;height:24px;text-align:right}",
|
|
1601
3346
|
".dsb-settings input[type=number]{text-align:right}",
|
|
1602
3347
|
".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
|
|
1603
3348
|
".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
|
|
@@ -1609,10 +3354,23 @@ window.__ModuleLoader__.load({
|
|
|
1609
3354
|
".dsb-settings .dsb-action{box-sizing:border-box;height:28px;padding:0 12px;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:6px;cursor:pointer;white-space:nowrap}",
|
|
1610
3355
|
".dsb-settings .dsb-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
|
|
1611
3356
|
".dsb-settings .dsb-action-sm{height:24px;padding:0 8px;font-size:12px}",
|
|
3357
|
+
/* 「正在更新中」后面那三个点:依次亮起,表示还在跑 */
|
|
3358
|
+
".dsb-dots{margin-left:1px}",
|
|
3359
|
+
".dsb-dots span{animation:dsb-dot 1.2s ease-in-out infinite}",
|
|
3360
|
+
".dsb-dots span:nth-child(2){animation-delay:.2s}",
|
|
3361
|
+
".dsb-dots span:nth-child(3){animation-delay:.4s}",
|
|
3362
|
+
"@keyframes dsb-dot{0%,60%,100%{opacity:.25}30%{opacity:1}}",
|
|
3363
|
+
"@media (prefers-reduced-motion: reduce){.dsb-dots span{animation:none;opacity:1}}",
|
|
1612
3364
|
".dsb-settings .dsb-foot{display:flex;align-items:center;gap:12px;flex-wrap:wrap}",
|
|
1613
3365
|
/* 版本号贴右侧、与按钮底边对齐 */
|
|
1614
3366
|
".dsb-settings .dsb-version{margin-left:auto;align-self:flex-end;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);opacity:.75}",
|
|
1615
|
-
|
|
3367
|
+
/* 「GitHub 支持」的悬停提示:外层只定位,气泡本身沿用 .dsb-tip 的毛玻璃材质;
|
|
3368
|
+
左边界与按钮左边界对齐(left:0),浮在按钮正上方 */
|
|
3369
|
+
".dsb-settings .dsb-star-wrap{position:relative;display:inline-flex}",
|
|
3370
|
+
".dsb-settings .dsb-star-tip{position:absolute;left:0;bottom:100%;margin-bottom:6px;font-size:12px;visibility:hidden;opacity:0;transition:opacity .12s ease}",
|
|
3371
|
+
".dsb-settings .dsb-star-wrap:hover .dsb-star-tip,.dsb-settings .dsb-star-wrap:focus-within .dsb-star-tip{visibility:visible;opacity:1}",
|
|
3372
|
+
/* 导航项后面的「发现新版本」胶囊:细描边圆角胶囊 + 次级文字色,字号刻意比导航文字小一档 */
|
|
3373
|
+
".dsb-nav-pill{box-sizing:border-box;margin-left:4px;padding:0 5px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:999px;font-size:8px;line-height:1.4;font-weight:400;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
|
|
1616
3374
|
].join("");
|
|
1617
3375
|
|
|
1618
3376
|
/* 导航图标使用外部传入的 gauge(24x24 lucide,stroke=currentColor,随主题变色) */
|
|
@@ -1622,14 +3380,110 @@ window.__ModuleLoader__.load({
|
|
|
1622
3380
|
* 把设置导航里本插件那一项的前导图标换成 gauge。
|
|
1623
3381
|
* 定位方式是按导航文案匹配(DSH 没给导航项留 id 属性),匹配不到就什么都不做。
|
|
1624
3382
|
*/
|
|
1625
|
-
|
|
3383
|
+
/* 导航项里挂「发现新版本」胶囊用:标记本插件那一项,避免胶囊文字混进文案后匹配不上。 */
|
|
3384
|
+
const NAV_MARK = "data-dsb-nav";
|
|
3385
|
+
|
|
3386
|
+
/* 宿主上下文(apply 时存下来):一键更新要用它的 remote 命名空间。 */
|
|
3387
|
+
let pluginCtx = null;
|
|
3388
|
+
|
|
3389
|
+
/**
|
|
3390
|
+
* 版本自检结果 —— 导航胶囊和设置页那颗「更新」按钮共用这一份,两边同进同出。
|
|
3391
|
+
* 默认什么都没有:只有 registry 上确实更新才显示,查不到就安静地不显示。
|
|
3392
|
+
* note 是更新动作的结果:null / "done" / "failed" / "no-manager"。
|
|
3393
|
+
*/
|
|
3394
|
+
let updateState = { hasUpdate: false, latest: null, busy: false, note: null, reason: null };
|
|
3395
|
+
const updateListeners = new Set();
|
|
3396
|
+
function subscribeUpdate(fn) {
|
|
3397
|
+
updateListeners.add(fn);
|
|
3398
|
+
return () => { updateListeners.delete(fn); };
|
|
3399
|
+
}
|
|
3400
|
+
function snapshotUpdate() { return updateState; }
|
|
3401
|
+
function setUpdateState(patch) {
|
|
3402
|
+
updateState = Object.assign({}, updateState, patch);
|
|
3403
|
+
updateListeners.forEach((fn) => { try { fn(); } catch (error) { /* 单个订阅者失败不影响其余 */ } });
|
|
3404
|
+
}
|
|
3405
|
+
function useUpdateState() {
|
|
3406
|
+
return react.useSyncExternalStore(subscribeUpdate, snapshotUpdate, snapshotUpdate);
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/** 挂/撤导航项后面的胶囊;同一个单元格只留一个。 */
|
|
3410
|
+
function syncNavPill(cell, text) {
|
|
3411
|
+
const has = typeof cell.querySelector === "function" ? cell.querySelector(".dsb-nav-pill") : null;
|
|
3412
|
+
if (updateState.hasUpdate !== true) {
|
|
3413
|
+
if (has !== null && typeof has.remove === "function") has.remove();
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
if (has !== null) { has.textContent = text; return; }
|
|
3417
|
+
const pill = document.createElement("span");
|
|
3418
|
+
pill.className = "dsb-nav-pill";
|
|
3419
|
+
pill.textContent = text;
|
|
3420
|
+
if (typeof cell.appendChild === "function") cell.appendChild(pill);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/**
|
|
3424
|
+
* 问宿主端 registry 上有没有更新版(宿主启动时自检一次,结果挂在 /api/version 上)。
|
|
3425
|
+
* 查不到(离线、被墙、自检还没跑完)返回 null,界面保持不显示 —— 宁可少显示,
|
|
3426
|
+
* 也别挂一个点不开的假提示。
|
|
3427
|
+
*/
|
|
3428
|
+
function loadUpdateFlag() {
|
|
3429
|
+
/* 宿主是拿 ?current= 里的版本来比大小的;不带就等于没版本可比,会被当成「没有新版」。 */
|
|
3430
|
+
return window.fetch(VERSION_URL + "?current=" + encodeURIComponent(VERSION), { cache: "no-store" })
|
|
3431
|
+
.then((response) => (response.ok === true ? response.json() : null))
|
|
3432
|
+
.then((payload) => payload === null || payload === undefined ? null : {
|
|
3433
|
+
hasUpdate: payload.hasUpdate === true,
|
|
3434
|
+
latest: typeof payload.latest === "string" && payload.latest.length > 0 ? payload.latest : null
|
|
3435
|
+
})
|
|
3436
|
+
.catch(() => null);
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
/** 从安装结果里抠出失败原因;拿不到就给空串。 */
|
|
3440
|
+
function reasonOf(result) {
|
|
3441
|
+
const raw = result === null || result === undefined ? null : result.reason;
|
|
3442
|
+
return typeof raw === "string" && raw.length > 0 ? raw : "";
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
/**
|
|
3446
|
+
* 一键更新:调 DSH 官方的插件安装接口(和插件管理页同一条路,内部跑 pnpm)。
|
|
3447
|
+
* 装完由 DSH 自己接管生效;客户端这半边是旧代码,得刷新页面才是新的。
|
|
3448
|
+
*/
|
|
3449
|
+
function startPluginUpdate() {
|
|
3450
|
+
const remote = pluginCtx === null || pluginCtx === undefined ? null : pluginCtx.remote;
|
|
3451
|
+
const manager = remote === null || remote === undefined ? null : remote.pluginManager;
|
|
3452
|
+
/* 官方桌面端一定带插件管理器;真取不到就按失败处理,不另开一个用户看不懂的分支 */
|
|
3453
|
+
if (manager === null || manager === undefined || typeof manager.installBundle !== "function") {
|
|
3454
|
+
setUpdateState({ busy: false, note: "failed", reason: null });
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
if (updateState.latest === null || updateState.busy === true) return;
|
|
3458
|
+
const target = updateState.latest;
|
|
3459
|
+
setUpdateState({ busy: true, note: null, reason: null });
|
|
3460
|
+
Promise.resolve(manager.installBundle(
|
|
3461
|
+
"dsh-desktop-statusbar@" + target,
|
|
3462
|
+
{ requestId: "dsh-desktop-statusbar-" + String(Date.now()) }
|
|
3463
|
+
)).then((result) => {
|
|
3464
|
+
const ok = result !== null && result !== undefined && result.ok === true;
|
|
3465
|
+
setUpdateState({ busy: false, note: ok ? "done" : "failed", reason: ok ? null : reasonOf(result) });
|
|
3466
|
+
}).catch(() => {
|
|
3467
|
+
setUpdateState({ busy: false, note: "failed", reason: null });
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/**
|
|
3472
|
+
* 把设置导航里本插件那一项的前导图标换成 gauge。
|
|
3473
|
+
* 定位方式是按导航文案匹配(DSH 没给导航项留 id 属性),匹配不到就什么都不做。
|
|
3474
|
+
* 顺带在这项后面挂「发现新版本」胶囊 —— 加了胶囊后文案就不再等于标签,所以首次命中打个标记。
|
|
3475
|
+
*/
|
|
3476
|
+
function installNavIcon(getLabel, getPill) {
|
|
1626
3477
|
const apply = () => {
|
|
1627
3478
|
const label = getLabel();
|
|
1628
3479
|
if (typeof label !== "string" || label.length === 0) return;
|
|
1629
3480
|
const cells = document.querySelectorAll("nav button");
|
|
1630
3481
|
for (let i = 0; i < cells.length; i += 1) {
|
|
1631
3482
|
const cell = cells[i];
|
|
1632
|
-
|
|
3483
|
+
const marked = typeof cell.getAttribute === "function" && cell.getAttribute(NAV_MARK) === "1";
|
|
3484
|
+
if (marked !== true && String(cell.textContent).trim() !== label) continue;
|
|
3485
|
+
if (marked !== true && typeof cell.setAttribute === "function") cell.setAttribute(NAV_MARK, "1");
|
|
3486
|
+
if (typeof getPill === "function") syncNavPill(cell, String(getPill()));
|
|
1633
3487
|
if (cell.querySelector(".lucide-gauge") !== null) continue; /* 已经换过 */
|
|
1634
3488
|
const first = cell.firstElementChild;
|
|
1635
3489
|
if (first === null || typeof first.tagName !== "string" || first.tagName.toLowerCase() !== "svg") continue;
|
|
@@ -1649,7 +3503,19 @@ window.__ModuleLoader__.load({
|
|
|
1649
3503
|
apply();
|
|
1650
3504
|
const observer = new MutationObserver(schedule);
|
|
1651
3505
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
1652
|
-
|
|
3506
|
+
/* 版本状态一变就重挂一次胶囊:查完发现没新版(撤掉)、或有新版(挂上) */
|
|
3507
|
+
const stopWatch = subscribeUpdate(apply);
|
|
3508
|
+
/* 启动自检一次。卸载之后结果才到的话直接丢弃,别再往 DOM 里塞东西。 */
|
|
3509
|
+
let alive = true;
|
|
3510
|
+
loadUpdateFlag().then((result) => {
|
|
3511
|
+
if (alive !== true || result === null) return;
|
|
3512
|
+
setUpdateState(result);
|
|
3513
|
+
});
|
|
3514
|
+
return () => {
|
|
3515
|
+
alive = false;
|
|
3516
|
+
stopWatch();
|
|
3517
|
+
observer.disconnect();
|
|
3518
|
+
};
|
|
1653
3519
|
}
|
|
1654
3520
|
|
|
1655
3521
|
function installStyles() {
|
|
@@ -1694,6 +3560,10 @@ window.__ModuleLoader__.load({
|
|
|
1694
3560
|
/* 上下文占用直接读 DSH 自带的两个投影,跟官方那一行同源同口径 */
|
|
1695
3561
|
const pressure = useProjection("contextPressure");
|
|
1696
3562
|
const breakdown = useProjection("contextBreakdown");
|
|
3563
|
+
/* 压缩点的两个入参(窗口 + 本次请求输出上限)由本插件从请求事件里折出来 */
|
|
3564
|
+
const capacity = useProjection("desktopStatusbarCapacity");
|
|
3565
|
+
/* 轮次项气泡那三行(上下文压缩 / 技能注入 / 工具调用)同样由本插件折 */
|
|
3566
|
+
const progress = useProjection("desktopStatusbarProgress");
|
|
1697
3567
|
/* 设置页拿不到会话投影,把当前模型上报给 host,供设置页读取 */
|
|
1698
3568
|
const reportedModel = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.model === "string" && sessionModel.model.length > 0
|
|
1699
3569
|
? sessionModel.model
|
|
@@ -1708,6 +3578,27 @@ window.__ModuleLoader__.load({
|
|
|
1708
3578
|
}).catch(() => { /* 上报失败不影响底栏 */ });
|
|
1709
3579
|
}, [reportedModel, reportedProvider]);
|
|
1710
3580
|
|
|
3581
|
+
/* 价格库上报给 host:活跃总览的 cost 是 host 记账时算的,
|
|
3582
|
+
它拿不到 localStorage,必须由这里把当前价格库送过去,
|
|
3583
|
+
否则用户在设置页加/改的单价只对底栏生效(实测 2026-10-07)。
|
|
3584
|
+
models 一变就重报;host 侧比对指纹后只结算"欠账"——
|
|
3585
|
+
即当时没有单价、还没算钱的那部分用量,已经算好的历史费用不动。 */
|
|
3586
|
+
const reportedPrices = react.useMemo(() => JSON.stringify(cfg.models === undefined || cfg.models === null ? {} : cfg.models), [cfg.models]);
|
|
3587
|
+
react.useEffect(() => {
|
|
3588
|
+
let models = null;
|
|
3589
|
+
try {
|
|
3590
|
+
models = JSON.parse(reportedPrices);
|
|
3591
|
+
} catch (error) {
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
if (models === null || Object.keys(models).length === 0) return;
|
|
3595
|
+
window.fetch(PRICES_URL, {
|
|
3596
|
+
method: "POST",
|
|
3597
|
+
headers: { "content-type": "application/json" },
|
|
3598
|
+
body: JSON.stringify({ models: models })
|
|
3599
|
+
}).catch(() => { /* 上报失败不影响底栏 */ });
|
|
3600
|
+
}, [reportedPrices]);
|
|
3601
|
+
|
|
1711
3602
|
const running = pick(useSession, (s) => s.running, undefined);
|
|
1712
3603
|
const partial = pick(useSession, (s) => s.partial, undefined);
|
|
1713
3604
|
const runningCalls = pick(useSession, (s) => s.runningCalls, undefined);
|
|
@@ -1718,16 +3609,73 @@ window.__ModuleLoader__.load({
|
|
|
1718
3609
|
|
|
1719
3610
|
const [now, setNow] = react.useState(() => Date.now());
|
|
1720
3611
|
const [balance, setBalance] = react.useState(null);
|
|
3612
|
+
const [daily, setDaily] = react.useState(null);
|
|
3613
|
+
/* 子智能体(Agent Teams 队友)的用量汇总:host 按 parentSession 现算,异步取。
|
|
3614
|
+
状态里带着"这份值属于哪个会话、哪一轮"(`nextSubagentState` 的契约,见那里的注释):
|
|
3615
|
+
同一轮里某次请求失败就沿用上一次成功的值,换了轮/换了会话一律清空重算,
|
|
3616
|
+
界面上不出现任何提示文字。段落取值用 `subagentState.sessionPayload / turnPayload`。 */
|
|
3617
|
+
const [subagentState, setSubagentState] = react.useState(SUBAGENT_STATE_EMPTY);
|
|
3618
|
+
/* 「本轮」显示值的粘性下限盒子(同轮内只增不减,换轮作废):一个可变盒子,
|
|
3619
|
+
由 lastCost 段在渲染时更新,跨渲染靠这个 ref 留住(见 applyTurnFloor)。 */
|
|
3620
|
+
const lastCostHoldRef = react.useRef({ sessionId: null, turnKey: null, value: 0 });
|
|
3621
|
+
/* 点开「总计」气泡时 +1:强制重拉一次队友用量(别的触发条件都带 3 秒节流) */
|
|
3622
|
+
const [subagentTick, setSubagentTick] = react.useState(0);
|
|
1721
3623
|
const [tipId, setTipId] = react.useState(null);
|
|
1722
3624
|
const [tipPos, setTipPos] = react.useState(null);
|
|
3625
|
+
/* 气泡里的下钻层:null = 根视图,数字 = 点开了第几行(会话数据那三行的子视图)。
|
|
3626
|
+
换字段、收起气泡都要清掉,否则下次打开会停在上次的子视图上。 */
|
|
3627
|
+
const [tipSub, setTipSub] = react.useState(null);
|
|
3628
|
+
/* 柱 / 方块 / 每周底条的悬停浮层:内容 + 触发元素的位置(自己算 fixed 坐标) */
|
|
3629
|
+
const [hoverCard, setHoverCard] = react.useState(null);
|
|
3630
|
+
const [hoverPos, setHoverPos] = react.useState(null);
|
|
3631
|
+
/* 当前悬停的是哪一周(用来给那条底板加深);悬停方块/柱子时为 null */
|
|
3632
|
+
const [hoverWeek, setHoverWeek] = react.useState(null);
|
|
3633
|
+
/* 当前悬停的是哪一根柱(那一列的判定区变灰);不在柱上时为 null */
|
|
3634
|
+
const [hoverBar, setHoverBar] = react.useState(null);
|
|
3635
|
+
const hoverRef = react.useRef(null);
|
|
3636
|
+
const hoverTimerRef = react.useRef(null);
|
|
3637
|
+
/* 收起延后一小会儿:鼠标在相邻的空位格之间横向移动会先 leave 再 enter,
|
|
3638
|
+
当场清空就会一闪一闪;紧接着的新悬停会把定时器取消掉,等于没关过。 */
|
|
3639
|
+
const closeHoverSoon = react.useCallback(() => {
|
|
3640
|
+
if (hoverTimerRef.current !== null) clearTimeout(hoverTimerRef.current);
|
|
3641
|
+
hoverTimerRef.current = setTimeout(() => {
|
|
3642
|
+
hoverTimerRef.current = null;
|
|
3643
|
+
setHoverCard(null);
|
|
3644
|
+
setHoverPos(null);
|
|
3645
|
+
setHoverWeek(null);
|
|
3646
|
+
setHoverBar(null);
|
|
3647
|
+
}, 110);
|
|
3648
|
+
}, []);
|
|
3649
|
+
/* payload 为对象:rows 有值时弹浮层,week 有值时那条底板加深,bar 有值时那根柱的判定区变灰。
|
|
3650
|
+
传空表示离开判定区(延后收起,避免在相邻区域间横向移动时闪烁)。 */
|
|
3651
|
+
const onHover = react.useCallback((event, payload) => {
|
|
3652
|
+
if (payload === null || payload === undefined) {
|
|
3653
|
+
closeHoverSoon();
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
if (hoverTimerRef.current !== null) {
|
|
3657
|
+
clearTimeout(hoverTimerRef.current);
|
|
3658
|
+
hoverTimerRef.current = null;
|
|
3659
|
+
}
|
|
3660
|
+
setHoverPos(null);
|
|
3661
|
+
setHoverCard(payload.rows === undefined ? null : payload.rows);
|
|
3662
|
+
setHoverWeek(typeof payload.week === "number" ? payload.week : null);
|
|
3663
|
+
setHoverBar(typeof payload.bar === "number" ? payload.bar : null);
|
|
3664
|
+
}, [closeHoverSoon]);
|
|
3665
|
+
react.useEffect(() => () => {
|
|
3666
|
+
if (hoverTimerRef.current !== null) clearTimeout(hoverTimerRef.current);
|
|
3667
|
+
}, []);
|
|
1723
3668
|
/* 气泡只由点击展开(圆环或各字段),再点一次或点别处收起 */
|
|
1724
3669
|
const rootRef = react.useRef(null);
|
|
1725
|
-
/* 气泡定位:触发项位置 +
|
|
3670
|
+
/* 气泡定位:触发项位置 + 面板节点(用来量宽度)。水平位置一律以触发项自身居中,
|
|
3671
|
+
左右再夹到底栏范围内,所以不需要记对齐方式。 */
|
|
1726
3672
|
const anchorRef = react.useRef(null);
|
|
1727
|
-
const tipModeRef = react.useRef("center");
|
|
1728
3673
|
const tipRef = react.useRef(null);
|
|
1729
|
-
/*
|
|
1730
|
-
const
|
|
3674
|
+
/* 上一次回传给 host 的定位诊断串:只在坐标真变了时才回传,别把日志刷满 */
|
|
3675
|
+
const tipDiagRef = react.useRef(null);
|
|
3676
|
+
/* 窗口缩放时 +1:定位 effect 没有依赖数组(每次渲染都重算),
|
|
3677
|
+
但 resize 本身不会引起渲染,得靠它推一次 */
|
|
3678
|
+
const [, setTipTick] = react.useState(0);
|
|
1731
3679
|
|
|
1732
3680
|
/* 本轮费用兜底:官方全量 tokenUsage 差分 */
|
|
1733
3681
|
const turnBaseRef = react.useRef(null);
|
|
@@ -1812,12 +3760,144 @@ window.__ModuleLoader__.load({
|
|
|
1812
3760
|
};
|
|
1813
3761
|
}, [wantsBalance, balanceProvider, balanceModel]);
|
|
1814
3762
|
|
|
3763
|
+
/* 活跃总览(峰谷段的气泡):不轮询。挂载时取一次(走 host 缓存),
|
|
3764
|
+
之后每次点开气泡都带 force=1 —— force 只绕过 host 那 30 秒缓存;
|
|
3765
|
+
没变动的日志文件按 mtime+size 跳过,历史数字也不会被重算,
|
|
3766
|
+
跨天、跨月靠"关掉再点开"自然跟上。 */
|
|
3767
|
+
const wantsWeekly = isSegmentOn(cfg, "status");
|
|
3768
|
+
const dailyReloadRef = react.useRef(null);
|
|
3769
|
+
react.useEffect(() => {
|
|
3770
|
+
if (!wantsWeekly) return undefined;
|
|
3771
|
+
let alive = true;
|
|
3772
|
+
const load = (force) => {
|
|
3773
|
+
const url = DAILY_URL + (force === true ? "?force=1" : "");
|
|
3774
|
+
window.fetch(url, { cache: "no-store" })
|
|
3775
|
+
.then((response) => response.json())
|
|
3776
|
+
.then((data) => { if (alive) setDaily(data); })
|
|
3777
|
+
.catch(() => { if (alive) setDaily({ ok: false, reason: "fetch-failed" }); });
|
|
3778
|
+
};
|
|
3779
|
+
dailyReloadRef.current = () => load(true);
|
|
3780
|
+
load(false);
|
|
3781
|
+
return () => {
|
|
3782
|
+
alive = false;
|
|
3783
|
+
dailyReloadRef.current = null;
|
|
3784
|
+
};
|
|
3785
|
+
}, [wantsWeekly]);
|
|
3786
|
+
|
|
3787
|
+
/* 队友用量(「总计」「本轮」都要算上它):换会话、对话新增了节点、本轮换了、
|
|
3788
|
+
或距上次超过 3 秒(活跃时)才重拉;点开气泡再强制拉一次。
|
|
3789
|
+
失败 / 超时一律静默:拿不到就退回"只算本会话",界面不该因为队友数据出问题而报错或卡住。
|
|
3790
|
+
分三个 effect:一个算本轮起点、一个决定"该不该拉"(跟着每秒的 now 数那 3 秒)、
|
|
3791
|
+
一个只负责拉(只在真的决定要拉时才换 query)。合成一个的话,时钟每跳一下
|
|
3792
|
+
就会把在途请求的 alive 清掉,结果永远落不了地。 */
|
|
3793
|
+
const foldedTurnNow = foldTurnUsage(chatNodes);
|
|
3794
|
+
/* 本轮起点 = 当前轮第一条记录的时间;节点不带时间戳、或折叠还停在上轮时为 null。
|
|
3795
|
+
为 null 时本轮那一问**照发**(不带 since),由 host 读父会话日志代推(host 那边还会
|
|
3796
|
+
按父会话日志再收敛一道);两边都拿不到就返回全 0 ⇒ 本轮只显示本会话值(只能小不能大)。 */
|
|
3797
|
+
const foldedTurnNum = foldedTurnNow !== null ? foldedTurnNow.turn : null;
|
|
3798
|
+
/* host 投影说的当前轮号(与下面 lastCost 用的是同一个来源)。折叠出来的轮号跟它对不上,
|
|
3799
|
+
说明新一轮已经开跑、而对话节点还没跟上 —— 这时折叠出的起点是**上一轮的**,
|
|
3800
|
+
拿它当 since 会把上一轮的队友花费算进本轮(偏大),所以直接当拿不到起点。 */
|
|
3801
|
+
const hostTurn = sessionUsage !== undefined && sessionUsage !== null && typeof sessionUsage.turn === "number"
|
|
3802
|
+
? sessionUsage.turn
|
|
3803
|
+
: null;
|
|
3804
|
+
const turnIsCurrent = foldedTurnNum === null || hostTurn === null || foldedTurnNum === hostTurn;
|
|
3805
|
+
const turnStartMs = turnIsCurrent === true && foldedTurnNow !== null && typeof foldedTurnNow.at === "number"
|
|
3806
|
+
? foldedTurnNow.at
|
|
3807
|
+
: null;
|
|
3808
|
+
/* 「同一轮」的轮标识(给队友状态机与"本轮只增不减"用)。两个来源都只会在真的换轮时前进:
|
|
3809
|
+
host 投影的 turn 随用量事件走,节点折叠的轮号随助手节点走。取**较大者**是为了尽早认定
|
|
3810
|
+
"换轮了"——两者谁先看到新一轮,就从那一刻起归零重算;认不出轮次(两个都拿不到)时为 null,
|
|
3811
|
+
此时既不沿用旧值也不做粘性,避免把两轮的数字混在一起。 */
|
|
3812
|
+
const subagentTurnKey = hostTurn !== null && foldedTurnNum !== null
|
|
3813
|
+
? Math.max(hostTurn, foldedTurnNum)
|
|
3814
|
+
: (hostTurn !== null ? hostTurn : foldedTurnNum);
|
|
3815
|
+
const chatNodeCount = Array.isArray(chatNodes) ? chatNodes.length : -1;
|
|
3816
|
+
const subagentReqRef = react.useRef({ sessionId: null, nodes: -1, since: null, at: 0 });
|
|
3817
|
+
const subagentForceRef = react.useRef(false);
|
|
3818
|
+
const subagentReloadRef = react.useRef(null);
|
|
3819
|
+
/* 一次"要拉取"的凭据:nonce 变一次就拉一次(sessionId / since 跟着它一起走,读的时候不会串会话) */
|
|
3820
|
+
const [subagentQuery, setSubagentQuery] = react.useState(null);
|
|
3821
|
+
react.useEffect(() => {
|
|
3822
|
+
subagentReloadRef.current = () => {
|
|
3823
|
+
subagentForceRef.current = true;
|
|
3824
|
+
setSubagentTick((n) => n + 1);
|
|
3825
|
+
};
|
|
3826
|
+
return () => { subagentReloadRef.current = null; };
|
|
3827
|
+
}, []);
|
|
3828
|
+
react.useEffect(() => {
|
|
3829
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
3830
|
+
const last = subagentReqRef.current;
|
|
3831
|
+
const switched = last.sessionId !== sessionId;
|
|
3832
|
+
const forced = subagentForceRef.current;
|
|
3833
|
+
subagentForceRef.current = false;
|
|
3834
|
+
const due = switched === true || last.nodes !== chatNodeCount || last.since !== turnStartMs
|
|
3835
|
+
|| Date.now() - last.at >= SUBAGENT_REFRESH_MS;
|
|
3836
|
+
if (forced !== true && due !== true) return undefined;
|
|
3837
|
+
subagentReqRef.current = { sessionId: sessionId, nodes: chatNodeCount, since: turnStartMs, at: Date.now() };
|
|
3838
|
+
/* 换了会话:上一个会话的队友数字不属于这里,先清掉,新的回来再填 */
|
|
3839
|
+
if (switched === true) setSubagentState(SUBAGENT_STATE_EMPTY);
|
|
3840
|
+
setSubagentQuery((prev) => ({
|
|
3841
|
+
sessionId: sessionId,
|
|
3842
|
+
since: turnStartMs,
|
|
3843
|
+
turnKey: subagentTurnKey,
|
|
3844
|
+
nonce: prev === null ? 1 : prev.nonce + 1
|
|
3845
|
+
}));
|
|
3846
|
+
return undefined;
|
|
3847
|
+
}, [sessionId, chatNodeCount, turnStartMs, now, subagentTick, subagentTurnKey]);
|
|
3848
|
+
react.useEffect(() => {
|
|
3849
|
+
if (subagentQuery === null) return undefined;
|
|
3850
|
+
if (typeof window === "undefined" || typeof window.fetch !== "function") return undefined;
|
|
3851
|
+
let alive = true;
|
|
3852
|
+
/* 超时自己兜:后端卡住时不能把「总计」一直吊着。用 AbortSignal.timeout 而不是
|
|
3853
|
+
自己挂 setTimeout —— 底栏本该只有"时钟"那一个定时器(已有测试盯着),
|
|
3854
|
+
多一个窗口定时器会打乱它的计数;环境不支持就算了,失败照样被 catch 静默。 */
|
|
3855
|
+
const timeoutSignal = typeof AbortSignal === "object" && AbortSignal !== null
|
|
3856
|
+
&& typeof AbortSignal.timeout === "function"
|
|
3857
|
+
? AbortSignal.timeout(SUBAGENT_TIMEOUT_MS)
|
|
3858
|
+
: undefined;
|
|
3859
|
+
/* 两条请求:整会话那一份显式带 `since=0`(表示"从最开始",即总计口径),
|
|
3860
|
+
本轮那一份优先用客户端自己的起点;**自己拿不到起点也照发**(不带 since),
|
|
3861
|
+
让 host 读父会话日志代推 —— host 推不出来时它会返回 sinceSource='none'
|
|
3862
|
+
与全 0,本轮因此只显示本会话值(只能小不能大)。 */
|
|
3863
|
+
const load = (since) => {
|
|
3864
|
+
const url = SUBAGENT_URL + "?sessionId=" + encodeURIComponent(subagentQuery.sessionId)
|
|
3865
|
+
+ (since === null ? "" : "&since=" + encodeURIComponent(String(since)));
|
|
3866
|
+
return window.fetch(url, { cache: "no-store", signal: timeoutSignal })
|
|
3867
|
+
.then((response) => response.json())
|
|
3868
|
+
.then((data) => (data !== null && typeof data === "object" && data.ok === true ? data : null))
|
|
3869
|
+
.catch(() => null);
|
|
3870
|
+
};
|
|
3871
|
+
Promise.all([
|
|
3872
|
+
load(0),
|
|
3873
|
+
subagentQuery.since === null ? load(null) : load(subagentQuery.since)
|
|
3874
|
+
]).then((both) => {
|
|
3875
|
+
if (alive !== true) return;
|
|
3876
|
+
/* 两段各自过一遍状态机(`nextSubagentState`,规则见那里的注释):
|
|
3877
|
+
这一次没拿到(网络失败 / 非 200 / HTTP 200 但 ok:false / 形状异常 —— 上面 `load`
|
|
3878
|
+
已经把后三种统一折算成 null)时,**同轮内沿用上一次成功的值**;从没成功过就保持
|
|
3879
|
+
空(界面按"仅本会话"显示);轮次变了则以 action 里的 turnKey 为准整体清空重算。 */
|
|
3880
|
+
const action = (slice, value) => ({
|
|
3881
|
+
sessionId: subagentQuery.sessionId,
|
|
3882
|
+
turnKey: subagentQuery.turnKey,
|
|
3883
|
+
slice: slice,
|
|
3884
|
+
ok: value !== null && value !== undefined,
|
|
3885
|
+
value: value
|
|
3886
|
+
});
|
|
3887
|
+
setSubagentState((prev) => nextSubagentState(
|
|
3888
|
+
nextSubagentState(prev, action("session", both[0])),
|
|
3889
|
+
action("turn", both[1])
|
|
3890
|
+
));
|
|
3891
|
+
});
|
|
3892
|
+
return () => { alive = false; };
|
|
3893
|
+
}, [subagentQuery]);
|
|
3894
|
+
|
|
1815
3895
|
/* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
|
|
1816
3896
|
不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
|
|
1817
3897
|
跟着 scroll 收起的话,气泡会在这些时刻莫名其妙消失。 */
|
|
1818
3898
|
react.useEffect(() => {
|
|
1819
3899
|
if (tipId === null) return undefined;
|
|
1820
|
-
const close = () => { setTipId(null); setTipPos(null); };
|
|
3900
|
+
const close = () => { setTipId(null); setTipPos(null); setTipSub(null); };
|
|
1821
3901
|
const onDocClick = (event) => {
|
|
1822
3902
|
if (rootRef.current !== null && rootRef.current.contains(event.target)) return;
|
|
1823
3903
|
close();
|
|
@@ -1836,6 +3916,12 @@ window.__ModuleLoader__.load({
|
|
|
1836
3916
|
最左、最右那两项贴边对齐,其余跟该字段居中对齐,最后按视口左右夹住。
|
|
1837
3917
|
用 layout effect 是为了在浏览器绘制前就落位,不会先闪一下再跳。
|
|
1838
3918
|
anchorRef 存的是触发项节点本身,每次都重新量 —— 窗口缩放后位置才是对的。 */
|
|
3919
|
+
/* 气泡坐标:宽度由内容决定(CSS 里 width:max-content),所以只能等面板挂上再量。
|
|
3920
|
+
**不设依赖数组**:下钻换子视图、数字位数变化都会改宽度,坐标必须跟着重算 ——
|
|
3921
|
+
只在 tipId 变时算的话,换子视图后会沿用上一个宽度,气泡就偏到一边去。
|
|
3922
|
+
每次都重新量,算出来的值没变就返回同一个对象,React 会跳过这次更新,不会转圈。
|
|
3923
|
+
水平位置:一律以触发项自身居中,再夹在「上下文占用圆圈灰底的左边」与视口右边之间。
|
|
3924
|
+
用 layout effect 是为了在浏览器绘制前就落位,不会先闪一下再跳。 */
|
|
1839
3925
|
useTipLayout(() => {
|
|
1840
3926
|
if (tipId === null) return;
|
|
1841
3927
|
const target = anchorRef.current;
|
|
@@ -1850,14 +3936,88 @@ window.__ModuleLoader__.load({
|
|
|
1850
3936
|
const width = node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
|
|
1851
3937
|
? node.getBoundingClientRect().width
|
|
1852
3938
|
: 0;
|
|
1853
|
-
const mode = tipModeRef.current;
|
|
1854
3939
|
const centered = anchor.left + anchor.width / 2 - width / 2;
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
3940
|
+
/* 左右两条硬约束,任何气泡都适用,而且是**对齐**不是仅仅不越界:
|
|
3941
|
+
左 —— 可见左边缘压在上下文占用那个**圆圈灰底**的左边。灰底是圆环按钮自己画的
|
|
3942
|
+
(`.dsb-ring-btn[aria-expanded=true]`,padding 3px + border-radius 50%),
|
|
3943
|
+
所以基准要取**按钮的盒子**,不能取里面的 `.dsb-ring` SVG —— SVG 的盒子比灰底
|
|
3944
|
+
窄 3px,拿它对齐气泡会压在灰圈里面(2026-10-05 量用户截图差约 2px)。
|
|
3945
|
+
也不能取状态栏本体的左边缘(含圆环按钮的外边距,更靠左)。
|
|
3946
|
+
右 —— 可见右边缘压在底栏最右那一项灰底的右边(末尾项的气泡因此自然右对齐到自己身上)。
|
|
3947
|
+
两侧再各补 TIP_OUTLINE_PX:气泡自己有一圈 0.5px 描边(`.dsb-tip` 的 box-shadow
|
|
3948
|
+
spread),可见边缘比 border-box 再往外 0.5px。 */
|
|
3949
|
+
const root = rootRef.current;
|
|
3950
|
+
const ring = root !== null && root !== undefined && typeof root.querySelector === "function"
|
|
3951
|
+
? root.querySelector(".dsb-ring-btn")
|
|
3952
|
+
: null;
|
|
3953
|
+
const ringBox = ring !== null && ring !== undefined && typeof ring.getBoundingClientRect === "function"
|
|
3954
|
+
? ring.getBoundingClientRect()
|
|
3955
|
+
: null;
|
|
3956
|
+
const barLeft = (ringBox === null ? anchor.left : ringBox.left) + TIP_OUTLINE_PX;
|
|
3957
|
+
const segs = root !== null && root !== undefined && typeof root.querySelectorAll === "function"
|
|
3958
|
+
? root.querySelectorAll(".dsb-seg")
|
|
3959
|
+
: null;
|
|
3960
|
+
const lastSeg = segs === null || segs.length === 0 ? null : segs[segs.length - 1];
|
|
3961
|
+
const barRight = lastSeg !== null && lastSeg !== undefined && typeof lastSeg.getBoundingClientRect === "function"
|
|
3962
|
+
? lastSeg.getBoundingClientRect().right
|
|
3963
|
+
: anchor.right;
|
|
3964
|
+
const barRightIn = barRight - TIP_OUTLINE_PX;
|
|
3965
|
+
/* 气泡比底栏还宽时(工具调用那种多列面板),先把宽度压到底栏跨度以内,
|
|
3966
|
+
多出来的列由 flex-wrap 折到下一排 —— 否则右边界怎么挪都会顶出去。 */
|
|
3967
|
+
const span = barRightIn - barLeft;
|
|
3968
|
+
const maxWidth = span > 0 ? Math.min(span, window.innerWidth - 24) : null;
|
|
3969
|
+
const maxLeft = Math.min(barRightIn - width, window.innerWidth - width - 8);
|
|
3970
|
+
const left = Math.max(barLeft, Math.min(centered, maxLeft));
|
|
3971
|
+
const bottom = Math.max(8, window.innerHeight - anchor.top + 6);
|
|
3972
|
+
/* 诊断:这几个数会写到气泡的 data-dsb-align 上(也作为悬停提示) */
|
|
3973
|
+
const align = "center left=" + left.toFixed(1) + " ring=" + barLeft.toFixed(1)
|
|
3974
|
+
+ " bar=" + barRight.toFixed(1) + " anchor=" + Math.round(anchor.left) + " w=" + Math.round(width);
|
|
3975
|
+
setTipPos((prev) => (prev !== null && prev.left === left && prev.bottom === bottom
|
|
3976
|
+
&& prev.maxWidth === maxWidth && prev.align === align
|
|
3977
|
+
? prev
|
|
3978
|
+
: { left: left, bottom: bottom, maxWidth: maxWidth, align: align }));
|
|
3979
|
+
/* 诊断回传:本机打不开控制台也能核对,host 会落到 <DSH_HOME>/dsh-status-bar/tip-diag.log。
|
|
3980
|
+
只在坐标真的变了时才回传,不然每次渲染都发一遍会把日志刷满。 */
|
|
3981
|
+
if (tipDiagRef.current !== align) {
|
|
3982
|
+
tipDiagRef.current = align;
|
|
3983
|
+
reportTipDiag({
|
|
3984
|
+
tip: tipId,
|
|
3985
|
+
mode: "center",
|
|
3986
|
+
left: left,
|
|
3987
|
+
ring: barLeft,
|
|
3988
|
+
ringFound: ring !== null && ring !== undefined,
|
|
3989
|
+
/* 宿主日志里有 bar 这一列,之前一直没发过去,那列永远是 null */
|
|
3990
|
+
bar: barRight,
|
|
3991
|
+
anchor: anchor.left,
|
|
3992
|
+
width: width,
|
|
3993
|
+
viewport: window.innerWidth
|
|
3994
|
+
});
|
|
3995
|
+
}
|
|
3996
|
+
});
|
|
3997
|
+
|
|
3998
|
+
/* 悬停浮层定位:固定贴在活跃总览气泡的正上方,且左右边界与它对齐(同宽),不遮挡它 */
|
|
3999
|
+
useTipLayout(() => {
|
|
4000
|
+
if (hoverCard === null) return;
|
|
4001
|
+
const node = hoverRef.current;
|
|
4002
|
+
const panel = tipRef.current;
|
|
4003
|
+
if (node === null || node === undefined || panel === null || panel === undefined) return;
|
|
4004
|
+
if (typeof node.getBoundingClientRect !== "function" || typeof panel.getBoundingClientRect !== "function") return;
|
|
4005
|
+
const size = node.getBoundingClientRect();
|
|
4006
|
+
const box = panel.getBoundingClientRect();
|
|
4007
|
+
setHoverPos({
|
|
4008
|
+
left: Math.max(8, box.left),
|
|
4009
|
+
top: Math.max(8, box.top - size.height - 6),
|
|
4010
|
+
width: box.width
|
|
1859
4011
|
});
|
|
1860
|
-
}, [
|
|
4012
|
+
}, [hoverCard]);
|
|
4013
|
+
|
|
4014
|
+
/* 主气泡收起时顺手把悬停浮层也收掉,避免它孤零零留在屏幕上 */
|
|
4015
|
+
react.useEffect(() => {
|
|
4016
|
+
if (tipId === null) {
|
|
4017
|
+
setHoverCard(null);
|
|
4018
|
+
setHoverPos(null);
|
|
4019
|
+
}
|
|
4020
|
+
}, [tipId]);
|
|
1861
4021
|
|
|
1862
4022
|
/* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
|
|
1863
4023
|
/* 开关状态同步到 <html>:CSS 里那两条"藏官方底栏"的规则挂在 html.dsb-on 上。 */
|
|
@@ -1869,9 +4029,9 @@ window.__ModuleLoader__.load({
|
|
|
1869
4029
|
return () => { root.classList.remove("dsb-on"); };
|
|
1870
4030
|
}, []);
|
|
1871
4031
|
|
|
1872
|
-
/*
|
|
1873
|
-
|
|
1874
|
-
const foldedTurn =
|
|
4032
|
+
/* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底。
|
|
4033
|
+
这份在上面算队友的「本轮」窗口时已经折过一次(foldedTurnNow),这里直接复用。 */
|
|
4034
|
+
const foldedTurn = foldedTurnNow;
|
|
1875
4035
|
const turnUsage = foldedTurn !== null ? foldedTurn.parts : turnLastRef.current;
|
|
1876
4036
|
/* 这份用量属于哪一轮(节点折叠才有;差分兜底没有轮号) */
|
|
1877
4037
|
const turnUsageTurn = foldedTurn !== null ? foldedTurn.turn : null;
|
|
@@ -1879,14 +4039,30 @@ window.__ModuleLoader__.load({
|
|
|
1879
4039
|
const currentTurn = sessionUsage !== undefined && sessionUsage !== null && typeof sessionUsage.turn === "number"
|
|
1880
4040
|
? sessionUsage.turn
|
|
1881
4041
|
: null;
|
|
4042
|
+
/* 段的入参形状照旧:{ session, turn } 各自可能是 null(null = 这段没有可用的值 → 当 0)。
|
|
4043
|
+
`session` 由状态机管着"失败沿用上一次成功的值"(整会话与轮无关,跨轮也留着);
|
|
4044
|
+
`turn` 必须**确认这份窗口属于当前这一轮**才用:换轮那一拍新数据还没回来时,
|
|
4045
|
+
状态里还是上一轮的值,直接拿来加会把上一轮的队友钱算进本轮(实测会让本轮虚高
|
|
4046
|
+
并被粘性下限锁住)。轮次认不出来(turnKey 为 null)时同样不用它 —— 宁少不多。 */
|
|
4047
|
+
const turnSliceUsable = subagentTurnKey !== null && subagentState.turnKey === subagentTurnKey;
|
|
4048
|
+
const subagent = {
|
|
4049
|
+
session: subagentState.sessionPayload,
|
|
4050
|
+
turn: turnSliceUsable === true ? subagentState.turnPayload : null
|
|
4051
|
+
};
|
|
4052
|
+
/* 底栏币种:主模型那个条目的币种(消耗最多的模型),认不出主模型才退回默认币种。
|
|
4053
|
+
段里的「总计 / 本轮 / 余额」都读这一个值。 */
|
|
4054
|
+
const mainModel = mainModelOf(sessionUsage);
|
|
4055
|
+
const displayCurrency = displayCurrencyOf(config.models, config.currency, sessionUsage);
|
|
1882
4056
|
|
|
1883
4057
|
const debugSrc = {
|
|
1884
4058
|
stats: stats, usage: usage,
|
|
1885
4059
|
sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRangeNow,
|
|
1886
4060
|
running: running, partial: partial, runningCalls: runningCalls,
|
|
1887
4061
|
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
|
|
1888
|
-
now: now, balance: balance, turnUsage: turnUsage, currency:
|
|
1889
|
-
|
|
4062
|
+
now: now, balance: balance, turnUsage: turnUsage, currency: displayCurrency,
|
|
4063
|
+
models: config.models, defaultCurrency: config.currency,
|
|
4064
|
+
timing: timing, turnUsageTurn: turnUsageTurn, currentTurn: currentTurn,
|
|
4065
|
+
progress: progress, subagent: subagent
|
|
1890
4066
|
};
|
|
1891
4067
|
window.__dsbDebug = {
|
|
1892
4068
|
now: now, running: running, clockActive: clockActive, timeRange: timeRangeNow,
|
|
@@ -1894,6 +4070,11 @@ window.__ModuleLoader__.load({
|
|
|
1894
4070
|
sessionModel: sessionModel === undefined ? null : sessionModel,
|
|
1895
4071
|
priceBook: config.models,
|
|
1896
4072
|
peakNow: isPeakTime(now),
|
|
4073
|
+
/* 币种相关:底栏符号的实际来源(测试成员照这三个字段取) */
|
|
4074
|
+
displayCurrency: displayCurrency,
|
|
4075
|
+
defaultCurrency: config.currency,
|
|
4076
|
+
mainModel: mainModel,
|
|
4077
|
+
referenceRate: REFERENCE_RATE,
|
|
1897
4078
|
calls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.length : 0,
|
|
1898
4079
|
lastCalls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.slice(-3) : [],
|
|
1899
4080
|
modelsAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.models,
|
|
@@ -1903,9 +4084,9 @@ window.__ModuleLoader__.load({
|
|
|
1903
4084
|
turnUsageTurn: turnUsageTurn,
|
|
1904
4085
|
currentTurn: currentTurn,
|
|
1905
4086
|
timing: timing,
|
|
1906
|
-
costText: segmentText("cost", segmentView("cost", debugSrc, t), t),
|
|
1907
|
-
lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t),
|
|
1908
|
-
sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t)
|
|
4087
|
+
costText: segmentText("cost", segmentView("cost", debugSrc, t), t, displayCurrency),
|
|
4088
|
+
lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t, displayCurrency),
|
|
4089
|
+
sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t, displayCurrency)
|
|
1909
4090
|
};
|
|
1910
4091
|
|
|
1911
4092
|
const src = {
|
|
@@ -1913,9 +4094,15 @@ window.__ModuleLoader__.load({
|
|
|
1913
4094
|
sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRange,
|
|
1914
4095
|
running: running, partial: partial, runningCalls: runningCalls,
|
|
1915
4096
|
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
|
|
1916
|
-
now: now, balance: balance, turnUsage: turnUsage, currency:
|
|
4097
|
+
now: now, balance: balance, turnUsage: turnUsage, currency: displayCurrency,
|
|
4098
|
+
models: config.models, defaultCurrency: config.currency,
|
|
1917
4099
|
pressure: pressure, breakdown: breakdown, timing: timing,
|
|
1918
|
-
|
|
4100
|
+
daily: daily,
|
|
4101
|
+
turnUsageTurn: turnUsageTurn, currentTurn: currentTurn,
|
|
4102
|
+
progress: progress, subagent: subagent,
|
|
4103
|
+
/* 「本轮」同轮粘性下限的载体:sessionId/turnKey 说明"这份下限属于谁",
|
|
4104
|
+
lastCostHold 是个可变盒子(ref 里那个对象),段里渲染时按需更新它 —— 见 applyTurnFloor。 */
|
|
4105
|
+
sessionId: sessionId, lastCostTurn: subagentTurnKey, lastCostHold: lastCostHoldRef.current
|
|
1919
4106
|
};
|
|
1920
4107
|
|
|
1921
4108
|
const views = [];
|
|
@@ -1924,7 +4111,7 @@ window.__ModuleLoader__.load({
|
|
|
1924
4111
|
/* 保留 view 上的 state(状态点靠它上色),只把缺数据的段换成占位文本 */
|
|
1925
4112
|
views.push(view !== null && view !== undefined
|
|
1926
4113
|
? view
|
|
1927
|
-
: { id: id, text: segmentText(id, view, t) });
|
|
4114
|
+
: { id: id, text: segmentText(id, view, t, displayCurrency) });
|
|
1928
4115
|
});
|
|
1929
4116
|
|
|
1930
4117
|
/* 运行状态:只用来给上下文占用圆环上色(空闲灰 / 运行绿 / 错误红 / 待审批黄) */
|
|
@@ -1936,37 +4123,30 @@ window.__ModuleLoader__.load({
|
|
|
1936
4123
|
&& (Array.isArray(approvals) ? approvals.length > 0 : true);
|
|
1937
4124
|
const agentFailed = !agentRunning && src.lastAgentError !== undefined && src.lastAgentError !== null;
|
|
1938
4125
|
const agentState = agentRunning ? "running" : (agentFailed ? "error" : (approvalWaiting ? "approval" : "idle"));
|
|
1939
|
-
/*
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
if (view.text === "") return;
|
|
1946
|
-
const one = view.tip === undefined ? null : view.tip;
|
|
1947
|
-
if (one !== null) tipKeys.push(view.id);
|
|
1948
|
-
});
|
|
1949
|
-
const modeOf = (key) => {
|
|
1950
|
-
const at = tipKeys.indexOf(key);
|
|
1951
|
-
if (at < 0) return "center";
|
|
1952
|
-
if (at === 0) return "left";
|
|
1953
|
-
/* 只有余额贴右边(它固定在栏尾)。其余一律居中、装不下时再夹 ——
|
|
1954
|
-
否则某一段没有气泡时会被当成"最右项"改成右对齐,看起来就不居中了。 */
|
|
1955
|
-
return key === "balance" ? "right" : "center";
|
|
1956
|
-
};
|
|
4126
|
+
/* 上下文占用是独立开关:固定在栏首、不参与排序。
|
|
4127
|
+
拿不到占用数据时给**空环 + 横杠**,绝不整块消失 —— 会话刚发出就被中断时
|
|
4128
|
+
provider 还没报过压力,一整块没掉,底栏就少一项,看着像插件坏了(实测 2026-10-09)。 */
|
|
4129
|
+
const contextMeter = cfg.contextMeter === true
|
|
4130
|
+
? (meterView(pressure, breakdown, capacity, t) || dashMeter())
|
|
4131
|
+
: null;
|
|
1957
4132
|
/* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
|
|
1958
|
-
const toggleTip = (id, event
|
|
4133
|
+
const toggleTip = (id, event) => {
|
|
4134
|
+
/* 换字段(含收起)一律回到根视图,别把上一个字段的下钻层带过来 */
|
|
4135
|
+
setTipSub(null);
|
|
1959
4136
|
if (tipId === id) {
|
|
1960
4137
|
setTipId(null);
|
|
1961
4138
|
setTipPos(null);
|
|
1962
4139
|
return;
|
|
1963
4140
|
}
|
|
1964
|
-
/*
|
|
4141
|
+
/* 点开余额 / 峰谷用量:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
|
|
1965
4142
|
if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
|
|
4143
|
+
if (id === "status" && dailyReloadRef.current !== null) dailyReloadRef.current();
|
|
4144
|
+
/* 总计:队友的数字也要重取一次 —— 3 秒节流靠的是那条每秒时钟,而会话完全空闲时
|
|
4145
|
+
它不跳(睡到下一个峰谷边界),光靠它会让数字停在旧值 */
|
|
4146
|
+
if (id === "cost" && subagentReloadRef.current !== null) subagentReloadRef.current();
|
|
1966
4147
|
const target = event !== undefined && event !== null ? event.currentTarget : null;
|
|
1967
4148
|
/* 存节点而不是当次的矩形:窗口缩放后要重新量 */
|
|
1968
4149
|
anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
|
|
1969
|
-
tipModeRef.current = mode === undefined || mode === null ? "center" : mode;
|
|
1970
4150
|
setTipId(id);
|
|
1971
4151
|
setTipPos(null);
|
|
1972
4152
|
};
|
|
@@ -1975,7 +4155,8 @@ window.__ModuleLoader__.load({
|
|
|
1975
4155
|
if (tip === undefined || tip === null) return null;
|
|
1976
4156
|
if (tip.kind === "tokens") return tokenPanel(tip.panel, tip.title, tipPos, tipRef);
|
|
1977
4157
|
if (tip.kind === "meter") return meterPanel(tip.meter, t, tipPos, tipRef);
|
|
1978
|
-
return
|
|
4158
|
+
if (tip.kind === "bars") return weekBarsPanel(tip, t, tipPos, tipRef, onHover, { week: hoverWeek, bar: hoverBar });
|
|
4159
|
+
return rowsPanel(tip, tipPos, tipRef, { id: tipSub, open: setTipSub, back: () => setTipSub(null) });
|
|
1979
4160
|
};
|
|
1980
4161
|
/* 圆环自己就是触发项:点开上下文占用面板;峰谷那两个字不带气泡 */
|
|
1981
4162
|
const contextTip = contextMeter === null ? null : { kind: "meter", meter: contextMeter };
|
|
@@ -1986,9 +4167,9 @@ window.__ModuleLoader__.load({
|
|
|
1986
4167
|
className: "dsb-ring-btn",
|
|
1987
4168
|
key: "__ringbtn",
|
|
1988
4169
|
"aria-expanded": tipId === "context" ? "true" : "false",
|
|
1989
|
-
onClick: (event) => toggleTip("context", event
|
|
4170
|
+
onClick: (event) => toggleTip("context", event)
|
|
1990
4171
|
}, [
|
|
1991
|
-
contextRing(contextMeter.percent, agentState),
|
|
4172
|
+
contextRing(contextMeter.percent, agentState, contextMeter.compact),
|
|
1992
4173
|
tipId === "context" ? tipPanelOf(contextTip) : null
|
|
1993
4174
|
]));
|
|
1994
4175
|
}
|
|
@@ -2004,11 +4185,24 @@ window.__ModuleLoader__.load({
|
|
|
2004
4185
|
className: tip !== null ? "dsb-seg dsb-seg-click" : "dsb-seg",
|
|
2005
4186
|
key: "__seg" + view.id,
|
|
2006
4187
|
"aria-expanded": tip !== null ? (tipId === view.id ? "true" : "false") : undefined,
|
|
2007
|
-
onClick: tip !== null ? (event) => toggleTip(view.id, event
|
|
4188
|
+
onClick: tip !== null ? (event) => toggleTip(view.id, event) : undefined
|
|
2008
4189
|
}, tip !== null
|
|
2009
4190
|
? [h("span", { key: "__txt" }, view.text), tipId === view.id ? tipPanelOf(tip) : null]
|
|
2010
4191
|
: view.text));
|
|
2011
4192
|
});
|
|
4193
|
+
/* 悬停浮层:与主气泡同一套质感,fixed 定位在触发元素上方。
|
|
4194
|
+
必须先挂上节点(此时 pos 还是 null、不给 left/bottom),
|
|
4195
|
+
layout effect 才量得到宽度并把坐标算出来 —— 否则会永远卡在"没有坐标"而不显示。 */
|
|
4196
|
+
if (hoverCard !== null) {
|
|
4197
|
+
children.push(h("span", {
|
|
4198
|
+
className: "dsb-tip dsb-meter dsb-hovercard",
|
|
4199
|
+
key: "__hovercard",
|
|
4200
|
+
ref: hoverRef,
|
|
4201
|
+
style: hoverPos === null ? undefined : { left: hoverPos.left + "px", top: hoverPos.top + "px", width: hoverPos.width + "px" },
|
|
4202
|
+
onClick: stopTipClick,
|
|
4203
|
+
onMouseDown: stopTipClick
|
|
4204
|
+
}, hoverCard));
|
|
4205
|
+
}
|
|
2012
4206
|
return h("span", {
|
|
2013
4207
|
ref: rootRef,
|
|
2014
4208
|
className: "dsb-root",
|
|
@@ -2017,9 +4211,54 @@ window.__ModuleLoader__.load({
|
|
|
2017
4211
|
}
|
|
2018
4212
|
|
|
2019
4213
|
/* -------------------------------------------------------------- 设置页 */
|
|
4214
|
+
|
|
4215
|
+
/* 编辑浮层滚进视野时底部留的余量(像素):贴着容器底边太紧,看着像还被裁着 */
|
|
4216
|
+
const REVEAL_MARGIN = 12;
|
|
4217
|
+
|
|
4218
|
+
/**
|
|
4219
|
+
* 找一个元素"最近的可滚动祖先":沿 parent 链往上,第一个 overflow-y 为
|
|
4220
|
+
* auto/scroll 且内容确实比自身高(scrollHeight > clientHeight)的元素。
|
|
4221
|
+
* 找不到就退回 `document.scrollingElement`(整页滚动的情况);再没有就返回 null。
|
|
4222
|
+
*
|
|
4223
|
+
* 为什么要这么找:设置面板不一定由 window 在滚 —— 面板内部可能就是个可滚容器,
|
|
4224
|
+
* 那样 window.scrollY 一动不动,把浮层底部带进来的唯一办法是改那个容器的 scrollTop。
|
|
4225
|
+
*/
|
|
4226
|
+
function scrollParentOf(node) {
|
|
4227
|
+
let one = node === null || node === undefined ? null : node.parentElement;
|
|
4228
|
+
while (one !== null && one !== undefined) {
|
|
4229
|
+
const style = typeof window.getComputedStyle === "function" ? window.getComputedStyle(one) : null;
|
|
4230
|
+
const overflowY = style === null || style === undefined ? "" : String(style.overflowY);
|
|
4231
|
+
if ((overflowY === "auto" || overflowY === "scroll")
|
|
4232
|
+
&& typeof one.scrollHeight === "number" && typeof one.clientHeight === "number"
|
|
4233
|
+
&& one.scrollHeight > one.clientHeight) return one;
|
|
4234
|
+
one = one.parentElement;
|
|
4235
|
+
}
|
|
4236
|
+
if (document !== undefined && document !== null && document.scrollingElement !== undefined) {
|
|
4237
|
+
return document.scrollingElement;
|
|
4238
|
+
}
|
|
4239
|
+
return null;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
/** 把 node 的**底边**带进滚动容器的可视区:只补"被裁掉的那一段 + 余量",没被裁就不动。 */
|
|
4243
|
+
function revealBottom(node) {
|
|
4244
|
+
if (node === null || node === undefined || typeof node.getBoundingClientRect !== "function") return;
|
|
4245
|
+
const box = node.getBoundingClientRect();
|
|
4246
|
+
if (box.height <= 0) return;
|
|
4247
|
+
const scroller = scrollParentOf(node);
|
|
4248
|
+
if (scroller === null || scroller === undefined) return;
|
|
4249
|
+
const rect = typeof scroller.getBoundingClientRect === "function" ? scroller.getBoundingClientRect() : null;
|
|
4250
|
+
const limit = rect === null || rect.bottom <= 0
|
|
4251
|
+
? (typeof scroller.clientHeight === "number" ? scroller.clientHeight : 0)
|
|
4252
|
+
: rect.bottom;
|
|
4253
|
+
const overflow = box.bottom + REVEAL_MARGIN - limit;
|
|
4254
|
+
if (overflow <= 0) return; /* 本来就在视野里:一动不动(旧版 block:"end" 会平白上滚) */
|
|
4255
|
+
scroller.scrollTop = (typeof scroller.scrollTop === "number" ? scroller.scrollTop : 0) + overflow;
|
|
4256
|
+
}
|
|
4257
|
+
|
|
2020
4258
|
function SettingsSection(props) {
|
|
2021
4259
|
const t = props.t;
|
|
2022
4260
|
const cfg = useConfig();
|
|
4261
|
+
const update = useUpdateState();
|
|
2023
4262
|
|
|
2024
4263
|
/* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
|
|
2025
4264
|
原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
|
|
@@ -2033,15 +4272,98 @@ window.__ModuleLoader__.load({
|
|
|
2033
4272
|
const [currencyOpen, setCurrencyOpen] = react.useState(false);
|
|
2034
4273
|
const currencyRef = react.useRef(null);
|
|
2035
4274
|
const editPopRef = react.useRef(null);
|
|
4275
|
+
/* 币种下拉的**菜单节点**(不是那个按钮):展开后要把它滚进视野 */
|
|
4276
|
+
const currencyMenuRef = react.useRef(null);
|
|
2036
4277
|
|
|
2037
|
-
/*
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
4278
|
+
/* 设置页里会"向下展开"的浮层,统一走这一个钩子把底边带进视野。
|
|
4279
|
+
旧实现用浏览器自带的"滚进视野"接口(block:nearest),而 ① 浮层自身比可视区高时
|
|
4280
|
+
它只保证顶部对齐、底部永远进不来;② 设置面板外层是个可滚容器,滚 window 没用。
|
|
4281
|
+
所以改成:找最近的可滚祖先 → 按"底边 + 12px 余量 − 容器可视底边"的差值加 scrollTop
|
|
4282
|
+
(没被裁就一动不动)。CSS/字体度量可能让高度到下一帧才稳定,所以再用一帧复测补滚
|
|
4283
|
+
一次(rAF,不是定时器 —— 底栏的定时器数量有断言盯着)。
|
|
4284
|
+
参数:`ref` 指向浮层节点本身;`active` 为 true 才起作用;`extraKey` 是"会让这个
|
|
4285
|
+
浮层高度变化"的附加信号(例如勾上峰谷计价会多出一档)。 */
|
|
4286
|
+
const useRevealBottom = (ref, active, extraKey) => {
|
|
4287
|
+
react.useEffect(() => {
|
|
4288
|
+
if (active !== true) return undefined;
|
|
4289
|
+
const retry = () => revealBottom(ref.current);
|
|
4290
|
+
retry();
|
|
4291
|
+
let frame = 0;
|
|
4292
|
+
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
4293
|
+
frame = window.requestAnimationFrame(retry);
|
|
4294
|
+
}
|
|
4295
|
+
return () => {
|
|
4296
|
+
if (frame !== 0 && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
|
4297
|
+
window.cancelAnimationFrame(frame);
|
|
4298
|
+
}
|
|
4299
|
+
};
|
|
4300
|
+
}, [active, extraKey]);
|
|
4301
|
+
};
|
|
4302
|
+
|
|
4303
|
+
/* 编辑浮层打开后把它的**底边**带进视野(不是顶部对齐,也不是平白上滚)。
|
|
4304
|
+
**实测根因**(本轮修的):旧实现用浏览器自带的"滚进视野"接口(block:nearest),
|
|
4305
|
+
而 ① 浮层自身比可视区高时它只保证顶部对齐,底部永远进不来;② 设置面板外层
|
|
4306
|
+
是个可滚容器,滚窗口没用。所以勾上「峰谷计价」多出一档、浮层变高之后,
|
|
4307
|
+
底部就落在可视区外,用户还得手动往下滚一下(截图箭头处)。
|
|
4308
|
+
现在:找最近的可滚祖先 → 按"底边 + 12px 余量 − 容器可视底边"的差值加 scrollTop。
|
|
4309
|
+
**依赖里带 draft.tiered**:勾选会撑高浮层,必须重算一次。
|
|
4310
|
+
CSS/字体度量可能让高度到下一帧才稳定,所以再用一帧复测补滚一次
|
|
4311
|
+
(rAF,不是定时器 —— 底栏的定时器数量有断言盯着)。 */
|
|
4312
|
+
/* 价格编辑浮层:打开时、以及勾上「峰谷计价」把它撑高之后各滚一次 */
|
|
4313
|
+
useRevealBottom(editPopRef, editing !== null, draft === null ? null : draft.tiered);
|
|
4314
|
+
/* 币种下拉菜单(CNY/USD):展开后同样按底部溢出量滚进视野。
|
|
4315
|
+
用户实测反馈"还要手动滚一下"的就是它 —— 上一轮只接到了价格编辑浮层。 */
|
|
4316
|
+
useRevealBottom(currencyMenuRef, currencyOpen === true, null);
|
|
4317
|
+
/**
|
|
4318
|
+
* 币种下拉控件(**只在条目的编辑浮层里用一处**:与三个价格框同一处、无文字标签)。
|
|
4319
|
+
*
|
|
4320
|
+
* 切换时只改**这一条**:`setModelCurrency` 按"官方内置 → 官方另一套定死值 /
|
|
4321
|
+
* 其它 → 数值不动只换标记"处理。草稿分两种接法:
|
|
4322
|
+
* · 官方内置且没被改过 → 按新币种的官方数字**重建草稿**(框里要显示新币种那一套);
|
|
4323
|
+
* · 其余(自建条目、或用户已改过价)→ 数值一个字没动,**只换草稿的币种标记**,
|
|
4324
|
+
* 用户已经填好、还没点保存的输入必须原样留在框里
|
|
4325
|
+
* (实测反馈:切一下币种,刚填的价格就被清空了)。
|
|
4326
|
+
*/
|
|
4327
|
+
const currencySelect = (key, draftNow, open, setOpen, menuRef, boxRef) => {
|
|
4328
|
+
const value = normalizeCurrency(draftNow === null || draftNow === undefined ? cfg.currency : draftNow.currency);
|
|
4329
|
+
const pick = (code) => {
|
|
4330
|
+
setOpen(false);
|
|
4331
|
+
if (editing === null || draftNow === null || draftNow === undefined) return;
|
|
4332
|
+
const changed = setModelCurrency(editing, code, draftNow);
|
|
4333
|
+
if (changed === null) return;
|
|
4334
|
+
if (changed.replaced === true) {
|
|
4335
|
+
startEdit(editing, changed.entry);
|
|
4336
|
+
return;
|
|
4337
|
+
}
|
|
4338
|
+
/* 整份重建会连用户没保存的输入一起丢掉 —— 这里只换币种标记,三个价格框的值保持不变 */
|
|
4339
|
+
setDraft({
|
|
4340
|
+
tiered: draftNow.tiered,
|
|
4341
|
+
currency: normalizeCurrency(code),
|
|
4342
|
+
peak: draftNow.peak,
|
|
4343
|
+
offPeak: draftNow.offPeak
|
|
4344
|
+
});
|
|
4345
|
+
};
|
|
4346
|
+
return h("div", { className: "dsb-select", key: key, ref: boxRef }, [
|
|
4347
|
+
h("button", {
|
|
4348
|
+
type: "button",
|
|
4349
|
+
className: "dsb-select-btn",
|
|
4350
|
+
"aria-haspopup": "listbox",
|
|
4351
|
+
"aria-expanded": open === true ? "true" : "false",
|
|
4352
|
+
onClick: () => setOpen(open !== true)
|
|
4353
|
+
}, value),
|
|
4354
|
+
open === true
|
|
4355
|
+
? h("div", { className: "dsb-select-menu", role: "listbox", ref: menuRef },
|
|
4356
|
+
["CNY", "USD"].map((code) => h("button", {
|
|
4357
|
+
type: "button",
|
|
4358
|
+
key: code,
|
|
4359
|
+
role: "option",
|
|
4360
|
+
"aria-selected": value === code ? "true" : "false",
|
|
4361
|
+
className: value === code ? "dsb-select-item dsb-select-item-on" : "dsb-select-item",
|
|
4362
|
+
onClick: () => pick(code)
|
|
4363
|
+
}, code)))
|
|
4364
|
+
: null
|
|
4365
|
+
]);
|
|
4366
|
+
};
|
|
2045
4367
|
|
|
2046
4368
|
/* 编辑浮层:点别处收起并丢弃草稿。判断"里面"上三重保险 —— 浮层节点、价格卡节点,
|
|
2047
4369
|
以及鼠标坐标是否落在卡片或币种下拉的矩形内。切币种时点在卡片内的控件上,
|
|
@@ -2083,22 +4405,19 @@ window.__ModuleLoader__.load({
|
|
|
2083
4405
|
return () => document.removeEventListener("click", onDocClick);
|
|
2084
4406
|
}, [currencyOpen]);
|
|
2085
4407
|
|
|
2086
|
-
/*
|
|
4408
|
+
/* 当前会话模型:底栏在模型一变时就 POST 给 host(那个 effect 依赖 sessionModel),
|
|
4409
|
+
所以 host 的投影始终最新;设置页进来读一次就够,不需要轮询。 */
|
|
2087
4410
|
react.useEffect(() => {
|
|
2088
4411
|
let alive = true;
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
};
|
|
2099
|
-
pull();
|
|
2100
|
-
const timer = window.setInterval(pull, 10000);
|
|
2101
|
-
return () => { alive = false; window.clearInterval(timer); };
|
|
4412
|
+
window.fetch(ACTIVE_MODEL_URL, { cache: "no-store" })
|
|
4413
|
+
.then((response) => response.json())
|
|
4414
|
+
.then((data) => {
|
|
4415
|
+
if (alive !== true) return;
|
|
4416
|
+
const model = data !== null && data !== undefined && typeof data.model === "string" && data.model.length > 0 ? data.model : null;
|
|
4417
|
+
setHostModel(model);
|
|
4418
|
+
})
|
|
4419
|
+
.catch(() => { /* 拿不到就保持未识别 */ });
|
|
4420
|
+
return () => { alive = false; };
|
|
2102
4421
|
}, []);
|
|
2103
4422
|
|
|
2104
4423
|
/* ---- 价格库操作:先改草稿,点保存才写入配置 ---- */
|
|
@@ -2110,13 +4429,22 @@ window.__ModuleLoader__.load({
|
|
|
2110
4429
|
const name = newModel.trim();
|
|
2111
4430
|
if (name.length === 0 || cfg.models[name] !== undefined) return;
|
|
2112
4431
|
const next = Object.assign({}, cfg.models);
|
|
2113
|
-
|
|
4432
|
+
/* 新增条目的币种一律 **CNY**:界面上已经没有全局币种选择器了(用户要求去掉),
|
|
4433
|
+
所以"新增时用哪种币"这件事不再由界面决定。`config.currency` 只留作**内部默认值**
|
|
4434
|
+
(老配置迁移时给条目落初值、以及认不出主模型时底栏的后备显示币种),界面不暴露它;
|
|
4435
|
+
用户要按美元记某个模型,就在该条目的编辑浮层里把币种选成 USD。 */
|
|
4436
|
+
next[name] = {
|
|
4437
|
+
tiered: false,
|
|
4438
|
+
currency: "CNY",
|
|
4439
|
+
peak: Object.assign({}, EMPTY_TIER),
|
|
4440
|
+
offPeak: Object.assign({}, EMPTY_TIER)
|
|
4441
|
+
};
|
|
2114
4442
|
setNewModel("");
|
|
2115
4443
|
setConfig({
|
|
2116
4444
|
models: next,
|
|
2117
4445
|
modelOrder: (Array.isArray(cfg.modelOrder) ? cfg.modelOrder : Object.keys(cfg.models)).concat([name])
|
|
2118
4446
|
});
|
|
2119
|
-
startEdit(name, next[name]); /*
|
|
4447
|
+
startEdit(name, next[name], true); /* 新加的模型直接展开待填:框留空,不预填 0 */
|
|
2120
4448
|
}
|
|
2121
4449
|
function removeModel(name) {
|
|
2122
4450
|
const next = Object.assign({}, cfg.models);
|
|
@@ -2127,18 +4455,28 @@ window.__ModuleLoader__.load({
|
|
|
2127
4455
|
});
|
|
2128
4456
|
if (editing === name) cancelEdit();
|
|
2129
4457
|
}
|
|
2130
|
-
/**
|
|
2131
|
-
|
|
4458
|
+
/**
|
|
4459
|
+
* 打开编辑:把价格抄进草稿,**没填的格子留空**(连 0 都不显示),别拿 0 冒充"已经有价"。
|
|
4460
|
+
* 判定逐格做:值是 `null` 就是没填过 → 空框;数字(含用户明确填的 0)→ 照实显示。
|
|
4461
|
+
* 只有两种情况让整栏都空:① 刚新增还没保存(blank=true);
|
|
4462
|
+
* ② 旧版本遗留的"全 0 条目"—— 那时没有 null,只能整体按没填过处理。
|
|
4463
|
+
*/
|
|
4464
|
+
function startEdit(name, preset, blank) {
|
|
2132
4465
|
const stored = preset === undefined ? cfg.models[name] : preset;
|
|
2133
4466
|
const entry = normalizePrice(stored);
|
|
4467
|
+
const untouched = blank === true || isLegacyZeroEntry(stored);
|
|
2134
4468
|
const asText = (tier) => ({
|
|
2135
|
-
input: String(tier.input),
|
|
2136
|
-
cacheRead: String(tier.cacheRead),
|
|
2137
|
-
cacheWrite: String(tier.cacheWrite),
|
|
2138
|
-
output: String(tier.output)
|
|
4469
|
+
input: untouched === true || tier.input === null || tier.input === undefined ? "" : String(tier.input),
|
|
4470
|
+
cacheRead: untouched === true || tier.cacheRead === null || tier.cacheRead === undefined ? "" : String(tier.cacheRead),
|
|
4471
|
+
cacheWrite: untouched === true || tier.cacheWrite === null || tier.cacheWrite === undefined ? "" : String(tier.cacheWrite),
|
|
4472
|
+
output: untouched === true || tier.output === null || tier.output === undefined ? "" : String(tier.output)
|
|
2139
4473
|
});
|
|
2140
4474
|
setDraft({
|
|
2141
4475
|
tiered: isTiered(stored),
|
|
4476
|
+
/* 条目的币种也进草稿:它在浮层里与三个价格框并排可改(改哪条只动哪条) */
|
|
4477
|
+
currency: stored !== null && stored !== undefined && stored.currency !== undefined && stored.currency !== null
|
|
4478
|
+
? normalizeCurrency(stored.currency)
|
|
4479
|
+
: normalizeCurrency(cfg.currency),
|
|
2142
4480
|
peak: asText(entry === null ? EMPTY_TIER : entry.peak),
|
|
2143
4481
|
offPeak: asText(entry === null ? EMPTY_TIER : entry.offPeak)
|
|
2144
4482
|
});
|
|
@@ -2152,35 +4490,75 @@ window.__ModuleLoader__.load({
|
|
|
2152
4490
|
function setDraftValue(tierKey, field, value) {
|
|
2153
4491
|
setDraft((prev) => {
|
|
2154
4492
|
if (prev === null) return prev;
|
|
2155
|
-
|
|
2156
|
-
const next = { tiered: prev.tiered, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
|
|
4493
|
+
const next = { tiered: prev.tiered, currency: prev.currency, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
|
|
2157
4494
|
next[tierKey][field] = value;
|
|
4495
|
+
/* 勾了「峰谷计价」就按官方比例联动:填高峰 → 空闲 ÷2;填空闲 → 高峰 ×2,
|
|
4496
|
+
用户只需要填一档。没勾(全天同价)时两档没有关系,另一档不动。 */
|
|
4497
|
+
if (prev.tiered === true) {
|
|
4498
|
+
const other = tierKey === "peak" ? "offPeak" : "peak";
|
|
4499
|
+
next[other][field] = linkedTierValue(value, tierKey === "peak");
|
|
4500
|
+
}
|
|
2158
4501
|
return next;
|
|
2159
4502
|
});
|
|
2160
4503
|
}
|
|
2161
4504
|
function setDraftTiered(on) {
|
|
2162
4505
|
setDraft((prev) => {
|
|
2163
4506
|
if (prev === null) return prev;
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
4507
|
+
if (prev.tiered === on) return prev;
|
|
4508
|
+
/* **只在勾上时清空**(用户要求:"勾选后全部留空、等重新填")。
|
|
4509
|
+
为什么取消勾选不清:单档显示与计价用的就是 `peak` 那一行 —— 取消勾选是
|
|
4510
|
+
`peak → peak`,同一行、不存在串档;会串档的只有"单档 → 高峰行"这一次,
|
|
4511
|
+
而勾上时清空已经把它挡住了。双向清空会把用户**没动过**的数据静默抹掉
|
|
4512
|
+
(实测:`peak.cacheWrite` 从 0 变 null、`peak.output` 从 8 变空),
|
|
4513
|
+
那是用户可见的数据丢失,代价远大于"切换模式要重填"的收益。
|
|
4514
|
+
`cacheWrite` 连勾上时也不清:界面从来不渲染这一格(历史遗留字段),
|
|
4515
|
+
清掉等于改用户看不到的数据。 */
|
|
4516
|
+
const cleared = (tier) => (on === true
|
|
4517
|
+
? { input: "", cacheRead: "", output: "", cacheWrite: tier.cacheWrite }
|
|
4518
|
+
: tier);
|
|
4519
|
+
return { tiered: on, currency: prev.currency, peak: cleared(prev.peak), offPeak: cleared(prev.offPeak) };
|
|
2170
4520
|
});
|
|
2171
4521
|
}
|
|
2172
4522
|
function saveDraft() {
|
|
2173
4523
|
if (editing === null || draft === null) return;
|
|
2174
|
-
|
|
2175
|
-
|
|
4524
|
+
/* 逐格转换:空字符串(没填)保留成 `null`,填了的值按数字存 —— 用户明确填的 0 就是 0。
|
|
4525
|
+
这样"只填一格"保存后,其余格子仍然是空的,不会再被写成 0。 */
|
|
4526
|
+
/* 空字符串 = 没填 → null;非法输入(粘贴进字母)也当没填,不要压成 0 ——
|
|
4527
|
+
压成 0 会被当成"用户明确把价定成了 0",第二轮匹配就会拿它算钱 */
|
|
4528
|
+
const asValue = (raw) => {
|
|
4529
|
+
if (raw === "") return null;
|
|
4530
|
+
const n = Number(raw);
|
|
4531
|
+
return Number.isFinite(n) === true ? n : null;
|
|
4532
|
+
};
|
|
4533
|
+
const asTier = (tier) => ({
|
|
4534
|
+
input: asValue(tier.input),
|
|
4535
|
+
cacheRead: asValue(tier.cacheRead),
|
|
4536
|
+
cacheWrite: asValue(tier.cacheWrite),
|
|
4537
|
+
output: asValue(tier.output)
|
|
4538
|
+
});
|
|
4539
|
+
const peak = asTier(draft.peak);
|
|
4540
|
+
const offPeak = asTier(draft.offPeak);
|
|
4541
|
+
/* 一个数都没填也**照常写入**(用户实测反馈:新建的自定义模型价格先空着,
|
|
4542
|
+
点「保存」不该把整条删掉)。空条目不会把费用静默算成 0 —— 计价匹配层
|
|
4543
|
+
`hasAnyPriceValue` 已把八格全空的条目挡在两轮之外(见那里的注释)。
|
|
4544
|
+
真要取消添加,用卡片上的「删除」。 */
|
|
4545
|
+
const hasValue = (tier) => tier.input !== null || tier.cacheRead !== null
|
|
4546
|
+
|| tier.cacheWrite !== null || tier.output !== null;
|
|
2176
4547
|
const next = Object.assign({}, cfg.models);
|
|
2177
4548
|
next[editing] = {
|
|
2178
4549
|
tiered: draft.tiered === true,
|
|
2179
|
-
|
|
4550
|
+
/* 条目自己的币种:界面上的换法见 currencySelect 那处(换完按新币种重建草稿) */
|
|
4551
|
+
currency: normalizeCurrency(draft.currency),
|
|
4552
|
+
peak: peak,
|
|
2180
4553
|
/* 两档都照实存:计价用哪一档看 tiered,取消勾选不该把空闲价抹掉 */
|
|
2181
|
-
offPeak:
|
|
4554
|
+
offPeak: offPeak
|
|
2182
4555
|
};
|
|
2183
|
-
|
|
4556
|
+
/* 只有真填了数字才算"配置过价格":一条全空的条目不该把"请填写价格"的提示顶掉 */
|
|
4557
|
+
if (hasValue(peak) === true || hasValue(offPeak) === true) {
|
|
4558
|
+
setConfig({ models: next, priceConfigured: true });
|
|
4559
|
+
} else {
|
|
4560
|
+
setConfig({ models: next });
|
|
4561
|
+
}
|
|
2184
4562
|
setEditing(null);
|
|
2185
4563
|
setDraft(null);
|
|
2186
4564
|
}
|
|
@@ -2425,8 +4803,16 @@ window.__ModuleLoader__.load({
|
|
|
2425
4803
|
}, rowParts(segment, on));
|
|
2426
4804
|
});
|
|
2427
4805
|
|
|
2428
|
-
/* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy
|
|
2429
|
-
|
|
4806
|
+
/* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy 走。
|
|
4807
|
+
*
|
|
4808
|
+
* **必须 portal 到 body**(2026-10-07 实测):壁纸引擎给设置面板挂了
|
|
4809
|
+
* `backdrop-filter`(body[data-we-glass-window] [role="dialog"]:has([data-slot="settings.section"])),
|
|
4810
|
+
* 而非 none 的 backdrop-filter 会让该元素成为 position:fixed 后代的**包含块**,
|
|
4811
|
+
* 于是这张本该相对视口定位的卡改成相对设置面板定位 —— 实测偏移 dx=220/dy=80;
|
|
4812
|
+
* 设置面板又自带 overflow:hidden(.wCInkW_panel),浮层随即被裁掉一截。
|
|
4813
|
+
* 挂到 body 下就没有这个祖先了,fixed 恢复视口锚定,也不受面板裁剪。
|
|
4814
|
+
* 取不到 createPortal 时退回就地渲染:功能照旧,只是那层玻璃在场时会偏。 */
|
|
4815
|
+
const dragCardNode = drag === null || drag.at === null || drag.at === undefined ? null : h("div", {
|
|
2430
4816
|
className: "dsb-drag-card",
|
|
2431
4817
|
key: "__dragcard",
|
|
2432
4818
|
style: {
|
|
@@ -2436,6 +4822,11 @@ window.__ModuleLoader__.load({
|
|
|
2436
4822
|
height: drag.at.height + "px"
|
|
2437
4823
|
}
|
|
2438
4824
|
}, rowParts(byId[drag.id], isSegmentOn(cfg, drag.id)));
|
|
4825
|
+
const dragCard = dragCardNode === null
|
|
4826
|
+
? null
|
|
4827
|
+
: (canPortal === true && typeof document !== "undefined" && document !== null && document.body !== undefined && document.body !== null
|
|
4828
|
+
? reactDom.createPortal(dragCardNode, document.body)
|
|
4829
|
+
: dragCardNode);
|
|
2439
4830
|
|
|
2440
4831
|
sections.push(h("div", { className: "dsb-section", key: "__segments" },
|
|
2441
4832
|
h("div", { className: "dsb-sectitle" }, t("secSegments")),
|
|
@@ -2447,17 +4838,64 @@ window.__ModuleLoader__.load({
|
|
|
2447
4838
|
|
|
2448
4839
|
/* 自定义模型价格 */
|
|
2449
4840
|
const priceCards = [];
|
|
2450
|
-
priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint"
|
|
4841
|
+
priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint")));
|
|
4842
|
+
/* 价格区顶部**没有**全局币种选择器(用户明确要求去掉那个残留):
|
|
4843
|
+
币种只在每个条目的编辑浮层里选,改哪条只动哪条。 */
|
|
4844
|
+
/* 「更新节假峰谷」+ 它的状态提示:按钮右边只在点过之后出现状态 ——
|
|
4845
|
+
搜索中 / 已写入概况,平时不占位。抓取失败的原因挪到按钮的悬停提示里 */
|
|
4846
|
+
const holidayStatus = holidaySummary(Date.now());
|
|
4847
|
+
/* 抓取「下一年」的安排有两种收场:
|
|
4848
|
+
① 官方还没发布(not-found / parse-empty)—— 不算出错,照常显示已写入的本年数量;
|
|
4849
|
+
② 真出问题(后台旧版没这个路由、或 HTTP 报错)—— 把原因显示出来,别让人以为写成功了。 */
|
|
4850
|
+
const holidayQuiet = holidayNote !== null && holidayNote.ok !== true
|
|
4851
|
+
&& (holidayNote.reason === "not-found" || holidayNote.reason === "parse-empty");
|
|
4852
|
+
const holidayFailure = holidayNote === null || holidayNote.ok === true || holidayQuiet
|
|
4853
|
+
? null
|
|
4854
|
+
: (holidayNote.reason === "no-route"
|
|
4855
|
+
? t("holidayUpdateNoRoute")
|
|
4856
|
+
: t("holidayUpdateFail", { reason: holidayNote.reason }));
|
|
4857
|
+
/* 状态提示排在按钮**前面**:它出现或变长时按钮的右边界不动(按钮钉在卡右端)。
|
|
4858
|
+
按钮不挂 title —— 悬停不弹气泡,失败原因直接写进左边那句状态里。 */
|
|
4859
|
+
const holidayActions = [];
|
|
4860
|
+
if (holidayBusy === true || holidayNote !== null) {
|
|
4861
|
+
holidayActions.push(h("span", { className: "dsb-hint", key: "__holidaystatus" },
|
|
4862
|
+
holidayBusy === true
|
|
4863
|
+
? t("holidayUpdateBusy")
|
|
4864
|
+
: (holidayFailure === null
|
|
4865
|
+
? t("holidayUpdateOk", { year: holidayStatus.year, count: holidayStatus.count })
|
|
4866
|
+
: holidayFailure)));
|
|
4867
|
+
}
|
|
4868
|
+
holidayActions.push(h("button", {
|
|
4869
|
+
type: "button",
|
|
4870
|
+
className: "dsb-action",
|
|
4871
|
+
key: "__holiday",
|
|
4872
|
+
disabled: holidayBusy === true,
|
|
4873
|
+
onClick: () => {
|
|
4874
|
+
const year = nextHolidayYear(Date.now());
|
|
4875
|
+
setHolidayBusy(true);
|
|
4876
|
+
setHolidayNote(null);
|
|
4877
|
+
updateHolidays(year).then((result) => {
|
|
4878
|
+
setHolidayBusy(false);
|
|
4879
|
+
setHolidayNote(result);
|
|
4880
|
+
});
|
|
4881
|
+
}
|
|
4882
|
+
}, t("holidayUpdate")));
|
|
2451
4883
|
/* "当前会话使用"提示:设置页是否能拿到投影由宿主决定,拿不到就显示占位 */
|
|
2452
4884
|
const settingsModel = typeof props.useProjection === "function" ? props.useProjection("desktopStatusbarModel") : undefined;
|
|
2453
4885
|
const currentModelName = settingsModel !== null && settingsModel !== undefined && typeof settingsModel.model === "string"
|
|
2454
4886
|
? settingsModel.model
|
|
2455
4887
|
: null;
|
|
2456
4888
|
const shownModel = currentModelName !== null ? currentModelName : hostModel;
|
|
2457
|
-
priceCards.push(h("div", { className: "dsb-current", key: "__current" },
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
4889
|
+
priceCards.push(h("div", { className: "dsb-current", key: "__current" }, [
|
|
4890
|
+
h("span", { key: "__currentname" }, [
|
|
4891
|
+
t("priceCurrent") + " ",
|
|
4892
|
+
h("b", null, shownModel === null ? t("modelUnknown") : displayModelName(shownModel))
|
|
4893
|
+
]),
|
|
4894
|
+
/* 「更新节假峰谷」贴这一行的右端(与下面的添加/修改按钮同一条右边界);
|
|
4895
|
+
状态提示排在按钮左边,免得把按钮从右边界挤开 */
|
|
4896
|
+
h("span", { className: "dsb-current-act", key: "__currentact" }, holidayActions)
|
|
4897
|
+
]));
|
|
4898
|
+
if (cfg.priceConfigured !== true) {
|
|
2461
4899
|
priceCards.push(h("div", { className: "dsb-hint", key: "__pricesuggest" }, t("priceSuggestedHint")));
|
|
2462
4900
|
}
|
|
2463
4901
|
/* 新增模型:夹在说明与模型列表之间 */
|
|
@@ -2515,7 +4953,8 @@ window.__ModuleLoader__.load({
|
|
|
2515
4953
|
/* 编辑区做成毛玻璃浮层(与底栏气泡、计价单位下拉同一套材质),从卡片头部下方展开 */
|
|
2516
4954
|
const body = open === true
|
|
2517
4955
|
? h("div", { className: "dsb-edit-pop", key: "__edit", ref: editPopRef }, [
|
|
2518
|
-
/*
|
|
4956
|
+
/* 这一行左边是「峰谷计价」勾选,右边挂**本条目的**币种下拉:
|
|
4957
|
+
改哪条只动哪条(换算也只换算这一条的数值),与三个价格框同一处。 */
|
|
2519
4958
|
h("div", { className: "dsb-tier-head", key: "__tiered" },
|
|
2520
4959
|
h("label", { className: "dsb-inline-check" },
|
|
2521
4960
|
h("input", {
|
|
@@ -2525,33 +4964,13 @@ window.__ModuleLoader__.load({
|
|
|
2525
4964
|
}),
|
|
2526
4965
|
h("span", null, t("priceTiered"))
|
|
2527
4966
|
),
|
|
2528
|
-
h("div", { className: "dsb-
|
|
2529
|
-
|
|
2530
|
-
type: "button",
|
|
2531
|
-
className: "dsb-select-btn",
|
|
2532
|
-
"aria-haspopup": "listbox",
|
|
2533
|
-
"aria-expanded": currencyOpen === true ? "true" : "false",
|
|
2534
|
-
onClick: () => setCurrencyOpen(currencyOpen !== true)
|
|
2535
|
-
}, cfg.currency),
|
|
2536
|
-
currencyOpen === true
|
|
2537
|
-
? h("div", { className: "dsb-select-menu", role: "listbox" },
|
|
2538
|
-
["CNY", "USD"].map((code) => h("button", {
|
|
2539
|
-
type: "button",
|
|
2540
|
-
key: code,
|
|
2541
|
-
role: "option",
|
|
2542
|
-
"aria-selected": cfg.currency === code ? "true" : "false",
|
|
2543
|
-
className: cfg.currency === code ? "dsb-select-item dsb-select-item-on" : "dsb-select-item",
|
|
2544
|
-
onClick: () => {
|
|
2545
|
-
const switched = switchCurrency(code);
|
|
2546
|
-
setCurrencyOpen(false);
|
|
2547
|
-
/* 草稿跟着换成新币种的价格:不然框里还留着旧币种的数字 */
|
|
2548
|
-
if (switched !== null && editing !== null) startEdit(editing, switched.models[editing]);
|
|
2549
|
-
}
|
|
2550
|
-
}, code)))
|
|
2551
|
-
: null
|
|
4967
|
+
h("div", { className: "dsb-currency-pick", key: "__curpick" },
|
|
4968
|
+
currencySelect("__entrycur", draft, currencyOpen, setCurrencyOpen, currencyMenuRef, currencyRef)
|
|
2552
4969
|
)
|
|
2553
4970
|
),
|
|
2554
|
-
|
|
4971
|
+
/* 未勾选时那行也有档位名(「全时段」),与勾选后的高峰/空闲两行结构一致,
|
|
4972
|
+
布局自然一致 —— 取代原先靠 CSS 做的双边对齐 */
|
|
4973
|
+
draft.tiered === true ? tierRow("offPeak", t("priceTierOffPeak")) : tierRow("peak", t("priceTierAllDay")),
|
|
2555
4974
|
draft.tiered === true ? tierRow("peak", t("priceTierPeak")) : null
|
|
2556
4975
|
])
|
|
2557
4976
|
: null;
|
|
@@ -2565,55 +4984,68 @@ window.__ModuleLoader__.load({
|
|
|
2565
4984
|
h("div", { className: "dsb-card" }, priceCards)
|
|
2566
4985
|
));
|
|
2567
4986
|
|
|
2568
|
-
/*
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
:
|
|
2578
|
-
|
|
4987
|
+
/* 收尾:GitHub 支持 + 恢复默认;版本标记贴右。
|
|
4988
|
+
「更新节假峰谷」已挪到上面价格卡那一行的右端。 */
|
|
4989
|
+
sections.push(h("div", { className: "dsb-foot", key: "__foot" }, [
|
|
4990
|
+
/* 外层只负责定位:悬停提示的毛玻璃沿用主气泡那套 .dsb-tip 材质 */
|
|
4991
|
+
h("span", { className: "dsb-star-wrap", key: "__starbox" }, [
|
|
4992
|
+
h("span", { className: "dsb-tip dsb-star-tip", key: "__startip" }, t("starTip")),
|
|
4993
|
+
h("button", {
|
|
4994
|
+
type: "button",
|
|
4995
|
+
className: "dsb-action",
|
|
4996
|
+
key: "__star",
|
|
4997
|
+
/* window.open 会被主窗口的 setWindowOpenHandler 接住,转 shell.openExternal → 默认浏览器;
|
|
4998
|
+
noopener 只是不给新窗口留 window.opener(这里窗口本来就被拒了) */
|
|
4999
|
+
onClick: () => window.open(REPO_URL, "_blank", "noopener")
|
|
5000
|
+
}, t("starRepo"))
|
|
5001
|
+
]),
|
|
2579
5002
|
h("button", {
|
|
2580
5003
|
type: "button",
|
|
2581
5004
|
className: "dsb-action",
|
|
2582
|
-
key: "
|
|
2583
|
-
|
|
2584
|
-
|
|
5005
|
+
key: "__reset",
|
|
5006
|
+
/* 「恢复默认设置」= 界面配置回出厂 + 价格库**分两类**重置:
|
|
5007
|
+
· **官方内置条目**(键命中 `DEFAULT_PRICES`)→ 恢复成官方默认值(逐字段等于
|
|
5008
|
+
官方表,`tiered` 与两档一起、连官方写的 0/null 也照抄),币种回到 **CNY**;
|
|
5009
|
+
被删掉的官方条目会**加回来**;顺序按官方表顺序排在**前面**。
|
|
5010
|
+
**取舍**:用户改过某个官方模型的价格,恢复默认会把它覆盖回官方价 ——
|
|
5011
|
+
这正是"恢复默认"的语义(想留住自定义价就别用官方键)。
|
|
5012
|
+
· **用户自建条目**(键不在官方表里)→ **原样保留**(数值/档位/`currency`
|
|
5013
|
+
一个字节都不动),顺序跟在官方条目后面。
|
|
5014
|
+
其余键(`priceVersion` / `priceConfigured` / `holidayDays` 等)靠 `setConfig`
|
|
5015
|
+
的**合并**语义原样留着。台账与历史数字一概不动(这里只写配置)。 */
|
|
2585
5016
|
onClick: () => {
|
|
2586
|
-
const
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
5017
|
+
const reset = resetPriceBook();
|
|
5018
|
+
setConfig({
|
|
5019
|
+
contextMeter: true,
|
|
5020
|
+
segments: DEFAULT_SEGMENTS.slice(),
|
|
5021
|
+
hidden: [],
|
|
5022
|
+
/* 内部默认币种(界面已不暴露它,只用于老配置迁移与底栏后备显示) */
|
|
5023
|
+
currency: "CNY",
|
|
5024
|
+
models: reset.models,
|
|
5025
|
+
modelOrder: reset.modelOrder
|
|
2592
5026
|
});
|
|
2593
5027
|
}
|
|
2594
|
-
}, t("holidayUpdate"))
|
|
2595
|
-
];
|
|
2596
|
-
if (holidayBusy === true || holidayNote !== null) {
|
|
2597
|
-
holidayActions.push(h("span", { className: "dsb-hint", key: "__holidaystatus" },
|
|
2598
|
-
holidayBusy === true
|
|
2599
|
-
? t("holidayUpdateBusy")
|
|
2600
|
-
: t("holidayUpdateOk", { year: holidayStatus.year, count: holidayStatus.count })));
|
|
2601
|
-
}
|
|
2602
|
-
sections.push(h("div", { className: "dsb-foot", key: "__foot" }, [
|
|
2603
|
-
h("button", {
|
|
2604
|
-
type: "button",
|
|
2605
|
-
className: "dsb-action",
|
|
2606
|
-
key: "__reset",
|
|
2607
|
-
onClick: () => setConfig({
|
|
2608
|
-
contextMeter: true,
|
|
2609
|
-
segments: DEFAULT_SEGMENTS.slice(),
|
|
2610
|
-
hidden: [],
|
|
2611
|
-
currency: "CNY",
|
|
2612
|
-
models: Object.assign({}, DEFAULT_PRICES),
|
|
2613
|
-
modelOrder: Object.keys(DEFAULT_PRICES)
|
|
2614
|
-
})
|
|
2615
5028
|
}, t("reset")),
|
|
2616
|
-
|
|
5029
|
+
/* 有新版才出现:与导航胶囊同一个来源(updateState),点它走 DSH 官方的插件安装接口。
|
|
5030
|
+
三种状态都写在按钮自己身上:空闲报版本号、更新中带动效点、失败就说「更新失败」;
|
|
5031
|
+
装完直接撤掉 —— 末尾的版本号尾标会变成新版本,不必再多一句提示。 */
|
|
5032
|
+
update.hasUpdate === true && update.note !== "done"
|
|
5033
|
+
? h("button", {
|
|
5034
|
+
type: "button",
|
|
5035
|
+
className: "dsb-action",
|
|
5036
|
+
key: "__update",
|
|
5037
|
+
disabled: update.busy === true,
|
|
5038
|
+
onClick: startPluginUpdate
|
|
5039
|
+
}, update.busy === true
|
|
5040
|
+
? [t("updateRunning"), h("span", { className: "dsb-dots", key: "__dots" }, [
|
|
5041
|
+
h("span", { key: "__d1" }, "."),
|
|
5042
|
+
h("span", { key: "__d2" }, "."),
|
|
5043
|
+
h("span", { key: "__d3" }, ".")
|
|
5044
|
+
])]
|
|
5045
|
+
: (update.note === "failed"
|
|
5046
|
+
? t("updateFailed")
|
|
5047
|
+
: t("updateTo", { version: update.latest === null ? "" : update.latest })))
|
|
5048
|
+
: null,
|
|
2617
5049
|
/* 版本标记贴右、与行内底边对齐:改了客户端代码要刷新页面才生效,这行字让"跑的是哪版"一眼可见 */
|
|
2618
5050
|
h("span", { className: "dsb-version", key: "__version" }, "dsh-desktop-statusbar " + VERSION)
|
|
2619
5051
|
]));
|
|
@@ -2622,10 +5054,26 @@ window.__ModuleLoader__.load({
|
|
|
2622
5054
|
}
|
|
2623
5055
|
|
|
2624
5056
|
/* --------------------------------------------------------------- apply */
|
|
5057
|
+
|
|
5058
|
+
/* 自测/测试成员可直接拿到的纯函数(跨请求场景要靠它们驱动,见 tests/ 里的 harness)。
|
|
5059
|
+
注意:测试 harness 习惯在 `exports.apply = apply;` 那一行**前**插一份自己的
|
|
5060
|
+
`exports.__test = {...}`,位置在下面这行之后 ⇒ 它们那份会覆盖这一份(这是有意的,
|
|
5061
|
+
免得两份列表互相打架);要测这里的东西,把它们加进 harness 自己那份列表即可。 */
|
|
5062
|
+
exports.__test = {
|
|
5063
|
+
nextSubagentState: nextSubagentState,
|
|
5064
|
+
applyTurnFloor: applyTurnFloor,
|
|
5065
|
+
SUBAGENT_STATE_EMPTY: SUBAGENT_STATE_EMPTY
|
|
5066
|
+
};
|
|
5067
|
+
|
|
2625
5068
|
function apply(ctx) {
|
|
5069
|
+
/* 存下来给「更新」按钮用:它要调 ctx.remote.pluginManager */
|
|
5070
|
+
pluginCtx = ctx;
|
|
2626
5071
|
ctx.effect(installStyles, "dsh-desktop-statusbar: styles");
|
|
2627
5072
|
ctx.effect(() => ctx.locale.register(NS, { zh: zh, en: en }), "dsh-desktop-statusbar: locale");
|
|
2628
|
-
ctx.effect(() => installNavIcon(
|
|
5073
|
+
ctx.effect(() => installNavIcon(
|
|
5074
|
+
() => ctx.locale.bind(NS)("nav"),
|
|
5075
|
+
() => ctx.locale.bind(NS)("updatePill")
|
|
5076
|
+
), "dsh-desktop-statusbar: nav icon");
|
|
2629
5077
|
|
|
2630
5078
|
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
2631
5079
|
name: "conversation.composer.dock",
|