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