dsh-desktop-statusbar 1.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 -0
- package/README.md +104 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1491 -0
- package/lib/index.js +565 -0
- package/package.json +60 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-statusbar — client 侧。
|
|
3
|
+
*
|
|
4
|
+
* 接管对话区底部的统计行,全部走官方 contract,不改动任何官方文件:
|
|
5
|
+
* 1. 往 `conversation.composer.dock`(list 槽)追加自己的 cell(id 'mini-bar')。
|
|
6
|
+
* 2. 注入 CSS 藏掉官方 StatsPills(按本插件自己的 data-dsb 标记区分,不依赖官方类名)。
|
|
7
|
+
* 3. 往 `settings.section` 注册设置页:基础开关 / 数据字段(拖拽排序)/ 自定义模型价格。
|
|
8
|
+
*
|
|
9
|
+
* 数据来源:
|
|
10
|
+
* - 官方投影 sessionStats / tokenUsage / contextPressure
|
|
11
|
+
* - 本插件 host 侧注册的 desktopStatusbarModel / desktopStatusbarUsage / desktopStatusbarActiveTime
|
|
12
|
+
* - 会话作用域槽的标准 props:useSession / useSessions / useChat / sessionId
|
|
13
|
+
* - 本插件 host 侧路由 /dsh-desktop-statusbar/api/balance(余额)与 /active-model(当前模型上报)
|
|
14
|
+
*
|
|
15
|
+
* 还原:卸载本插件即可(CSS 与两个 cell 都由 ctx.effect 管理)。
|
|
16
|
+
*/
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: "dsh-desktop-statusbar",
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} };
|
|
21
|
+
var exports = module.exports;
|
|
22
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
23
|
+
|
|
24
|
+
const react = require("react");
|
|
25
|
+
const h = react.createElement;
|
|
26
|
+
|
|
27
|
+
const NS = "dsh-desktop-statusbar";
|
|
28
|
+
const STYLE_TAG_ID = "dsh-desktop-statusbar/styles";
|
|
29
|
+
const STORAGE_KEY = "dsh.desktopStatusBar.v1";
|
|
30
|
+
/** 改名前用的旧键:读到就迁移过来,保留是为了能回退到旧版本。 */
|
|
31
|
+
const LEGACY_STORAGE_KEY = "dsh.miniStatusBar.v1";
|
|
32
|
+
const BALANCE_URL = "/dsh-desktop-statusbar/api/balance";
|
|
33
|
+
const ACTIVE_MODEL_URL = "/dsh-desktop-statusbar/api/active-model";
|
|
34
|
+
const BALANCE_POLL_MS = 60000;
|
|
35
|
+
const PRICES_URL = "/dsh-desktop-statusbar/api/prices";
|
|
36
|
+
/**
|
|
37
|
+
* 配置结构版本。
|
|
38
|
+
* v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
|
|
39
|
+
*/
|
|
40
|
+
const CONFIG_VERSION = 6;
|
|
41
|
+
/** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
|
|
42
|
+
const PRICE_VERSION = 2;
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------ 价格库 */
|
|
45
|
+
|
|
46
|
+
/** DeepSeek 参考价(CNY / 1M tokens)。高峰时段为北京时间周一至周五 9:00-12:00、14:00-18:00,空闲价为高峰价的一半。 */
|
|
47
|
+
const DEFAULT_PRICES = {
|
|
48
|
+
"deepseek-flash": {
|
|
49
|
+
peak: { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
|
|
50
|
+
offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 }
|
|
51
|
+
},
|
|
52
|
+
"deepseek-v4-pro": {
|
|
53
|
+
peak: { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 },
|
|
54
|
+
offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 }
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const EMPTY_TIER = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
59
|
+
|
|
60
|
+
/** 把一条价格规范化成 { peak, offPeak };旧的平铺结构视为两档同价。 */
|
|
61
|
+
function normalizePrice(entry) {
|
|
62
|
+
if (entry === null || entry === undefined || typeof entry !== "object") return null;
|
|
63
|
+
const flatten = (value) => ({
|
|
64
|
+
input: Number(value.input) || 0,
|
|
65
|
+
cacheRead: Number(value.cacheRead) || 0,
|
|
66
|
+
cacheWrite: Number(value.cacheWrite) || 0,
|
|
67
|
+
output: Number(value.output) || 0
|
|
68
|
+
});
|
|
69
|
+
if (entry.peak !== undefined || entry.offPeak !== undefined) {
|
|
70
|
+
const peak = entry.peak === undefined || entry.peak === null ? null : flatten(entry.peak);
|
|
71
|
+
const offPeak = entry.offPeak === undefined || entry.offPeak === null ? null : flatten(entry.offPeak);
|
|
72
|
+
return {
|
|
73
|
+
peak: peak !== null ? peak : (offPeak !== null ? offPeak : EMPTY_TIER),
|
|
74
|
+
offPeak: offPeak !== null ? offPeak : (peak !== null ? peak : EMPTY_TIER)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const flat = flatten(entry);
|
|
78
|
+
return { peak: flat, offPeak: flat };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 按当前价格库查模型单价(规范化成峰谷两档);精确名优先,其次子串匹配;找不到返回 null。 */
|
|
82
|
+
function priceOf(model) {
|
|
83
|
+
if (typeof model !== "string") return null;
|
|
84
|
+
const book = config.models === undefined || config.models === null ? {} : config.models;
|
|
85
|
+
if (book[model] !== undefined) return normalizePrice(book[model]);
|
|
86
|
+
const lower = model.toLowerCase();
|
|
87
|
+
const keys = Object.keys(book);
|
|
88
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
89
|
+
if (lower.indexOf(keys[i].toLowerCase()) !== -1) return normalizePrice(book[keys[i]]);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 高峰时段判定:北京时间(UTC+8)周一至周五 9:00-12:00、14:00-18:00。
|
|
96
|
+
* 官方口径是"其余为空闲时段",所以周末整天空闲价。
|
|
97
|
+
*/
|
|
98
|
+
const PEAK_WINDOWS = [[9, 12], [14, 18]];
|
|
99
|
+
function isPeakTime(ms) {
|
|
100
|
+
const at = typeof ms === "number" && ms > 0 ? ms : Date.now();
|
|
101
|
+
const beijing = new Date(at + 8 * 3600 * 1000);
|
|
102
|
+
const day = beijing.getUTCDay();
|
|
103
|
+
if (day === 0 || day === 6) return false;
|
|
104
|
+
const hour = beijing.getUTCHours() + beijing.getUTCMinutes() / 60;
|
|
105
|
+
for (let i = 0; i < PEAK_WINDOWS.length; i += 1) {
|
|
106
|
+
if (hour >= PEAK_WINDOWS[i][0] && hour < PEAK_WINDOWS[i][1]) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 距离下一个峰谷边界还有多少毫秒(北京时间 9:00 / 12:00 / 14:00 / 18:00)。
|
|
113
|
+
* 跨午夜不列为边界:高峰只占工作日 9-12、14-18,午夜两侧都是空闲,显示不会变。
|
|
114
|
+
*/
|
|
115
|
+
function msToNextPeakBoundary(from) {
|
|
116
|
+
const at = typeof from === "number" && from > 0 ? from : Date.now();
|
|
117
|
+
const beijing = new Date(at + 8 * 3600 * 1000);
|
|
118
|
+
const dayStart = Date.UTC(beijing.getUTCFullYear(), beijing.getUTCMonth(), beijing.getUTCDate()) - 8 * 3600 * 1000;
|
|
119
|
+
const hours = [9, 12, 14, 18];
|
|
120
|
+
for (let i = 0; i < hours.length; i += 1) {
|
|
121
|
+
const boundary = dayStart + hours[i] * 3600 * 1000;
|
|
122
|
+
if (boundary > at) return boundary - at;
|
|
123
|
+
}
|
|
124
|
+
/* 今天的边界都过了:下一个是明天 9:00(33 = 24 + 9) */
|
|
125
|
+
return dayStart + 33 * 3600 * 1000 - at;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 价格库是否为空或全是 0(用户可能加过一个没填价的条目)。 */
|
|
129
|
+
function pricesMissing(book) {
|
|
130
|
+
if (book === null || book === undefined || typeof book !== "object") return true;
|
|
131
|
+
const names = Object.keys(book);
|
|
132
|
+
if (names.length === 0) return true;
|
|
133
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
134
|
+
const price = normalizePrice(book[names[i]]);
|
|
135
|
+
if (price === null) continue;
|
|
136
|
+
const total = price.peak.input + price.peak.cacheRead + price.peak.cacheWrite + price.peak.output
|
|
137
|
+
+ price.offPeak.input + price.offPeak.cacheRead + price.offPeak.cacheWrite + price.offPeak.output;
|
|
138
|
+
if (total > 0) return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 价格缺失或全 0 时回退到官方参考价:优先向 host 要(那儿是最新的),
|
|
145
|
+
* 拿不到就用内嵌的 DEFAULT_PRICES。用户填过的非 0 价格不会被覆盖。
|
|
146
|
+
*/
|
|
147
|
+
function ensurePrices() {
|
|
148
|
+
if (!pricesMissing(config.models)) return Promise.resolve(false);
|
|
149
|
+
const fallback = () => {
|
|
150
|
+
setConfig({ models: normalizeModels(DEFAULT_PRICES), priceVersion: PRICE_VERSION, priceConfigured: true });
|
|
151
|
+
return false;
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
return window.fetch(PRICES_URL, { cache: "no-store" })
|
|
155
|
+
.then((response) => response.json())
|
|
156
|
+
.then((data) => {
|
|
157
|
+
const models = data !== null && data !== undefined && data.models !== undefined ? data.models : null;
|
|
158
|
+
if (models === null || Object.keys(models).length === 0) return fallback();
|
|
159
|
+
setConfig({ models: normalizeModels(models), priceVersion: PRICE_VERSION, priceConfigured: true });
|
|
160
|
+
return true;
|
|
161
|
+
})
|
|
162
|
+
.catch(() => fallback());
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return Promise.resolve(fallback());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function currencySymbol(currency) {
|
|
169
|
+
return currency === "USD" ? "$" : "¥";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 模型名显示用:deepseek → DeepSeek、glm → GLM、gpt → GPT,其余按 - 分段首字母大写。 */
|
|
173
|
+
const MODEL_NAME_SPECIAL = { deepseek: "DeepSeek", glm: "GLM", gpt: "GPT" };
|
|
174
|
+
function displayModelName(name) {
|
|
175
|
+
if (typeof name !== "string" || name.length === 0) return name;
|
|
176
|
+
return name.split("-").map((part) => {
|
|
177
|
+
if (part.length === 0) return part;
|
|
178
|
+
const special = MODEL_NAME_SPECIAL[part.toLowerCase()];
|
|
179
|
+
if (special !== undefined) return special;
|
|
180
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
181
|
+
}).join("-");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 该模型是否按峰谷两档计价(缺省视为两档)。 */
|
|
185
|
+
function isTiered(entry) {
|
|
186
|
+
return entry === null || entry === undefined || entry.tiered !== false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
/** 按价格库算一段用量的费用(按给定时刻定峰谷),返回不带符号的两位小数字符串。 */
|
|
191
|
+
function costOf(bucket, model, at) {
|
|
192
|
+
const price = priceOf(model);
|
|
193
|
+
if (price === null || bucket === undefined || bucket === null) return null;
|
|
194
|
+
const tier = isPeakTime(at) ? price.peak : price.offPeak;
|
|
195
|
+
const amount = ((bucket.input || 0) * tier.input
|
|
196
|
+
+ (bucket.cacheRead || 0) * tier.cacheRead
|
|
197
|
+
+ (bucket.cacheWrite || 0) * tier.cacheWrite
|
|
198
|
+
+ (bucket.output || 0) * tier.output) / 1e6;
|
|
199
|
+
/* 大缓存会话单条调用可能不到一分钱,退到 4 位小数,避免一律显示 0.00 */
|
|
200
|
+
return amount > 0 && amount < 0.01 ? amount.toFixed(4) : amount.toFixed(2);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
|
|
204
|
+
function diffUsage(current, base) {
|
|
205
|
+
if (current === null || current === undefined || base === null || base === undefined) return null;
|
|
206
|
+
return {
|
|
207
|
+
input: Math.max(0, current.input - base.input),
|
|
208
|
+
cacheRead: Math.max(0, current.cacheRead - base.cacheRead),
|
|
209
|
+
cacheWrite: Math.max(0, current.cacheWrite - base.cacheWrite),
|
|
210
|
+
output: Math.max(0, current.output - base.output)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeTier(rawTier) {
|
|
215
|
+
const entry = rawTier === null || rawTier === undefined || typeof rawTier !== "object" ? {} : rawTier;
|
|
216
|
+
return {
|
|
217
|
+
input: Number(entry.input) || 0,
|
|
218
|
+
cacheRead: Number(entry.cacheRead) || 0,
|
|
219
|
+
cacheWrite: Number(entry.cacheWrite) || 0,
|
|
220
|
+
output: Number(entry.output) || 0
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function normalizeModels(rawModels) {
|
|
224
|
+
const out = {};
|
|
225
|
+
if (rawModels === null || rawModels === undefined || typeof rawModels !== "object") return out;
|
|
226
|
+
const names = Object.keys(rawModels);
|
|
227
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
228
|
+
const price = normalizePrice(rawModels[names[i]]);
|
|
229
|
+
if (price === null) continue;
|
|
230
|
+
out[names[i]] = {
|
|
231
|
+
tiered: isTiered(rawModels[names[i]]),
|
|
232
|
+
peak: normalizeTier(price.peak),
|
|
233
|
+
offPeak: normalizeTier(price.offPeak)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function foldTurnUsage(nodes, skip) {
|
|
240
|
+
if (!Array.isArray(nodes)) return null;
|
|
241
|
+
const turns = [];
|
|
242
|
+
for (const node of nodes) {
|
|
243
|
+
if (node === null || node === undefined) continue;
|
|
244
|
+
if (node.kind !== "assistant") continue;
|
|
245
|
+
if (typeof node.turn !== "number") continue;
|
|
246
|
+
if (turns.indexOf(node.turn) === -1) turns.push(node.turn);
|
|
247
|
+
}
|
|
248
|
+
if (turns.length <= skip) return null;
|
|
249
|
+
turns.sort((a, b) => a - b);
|
|
250
|
+
const target = turns[turns.length - 1 - skip];
|
|
251
|
+
const acc = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
252
|
+
let found = false;
|
|
253
|
+
for (const node of nodes) {
|
|
254
|
+
if (node === null || node === undefined) continue;
|
|
255
|
+
if (node.kind !== "assistant" || node.turn !== target) continue;
|
|
256
|
+
const usage = node.usage;
|
|
257
|
+
if (usage === null || usage === undefined || typeof usage !== "object") continue;
|
|
258
|
+
acc.input += usage.inputTokens || 0;
|
|
259
|
+
acc.cacheRead += usage.cacheReadTokens || 0;
|
|
260
|
+
acc.cacheWrite += usage.cacheWriteTokens || 0;
|
|
261
|
+
acc.output += usage.outputTokens || 0;
|
|
262
|
+
found = true;
|
|
263
|
+
}
|
|
264
|
+
return found ? acc : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ---------------------------------------------------------------- i18n */
|
|
268
|
+
const zh = {
|
|
269
|
+
nav: "状态栏",
|
|
270
|
+
intro: "接管对话区底部的统计行。勾选要显示的字段、拖动调整顺序或配置模型费用单价。",
|
|
271
|
+
enabled: "启用状态栏",
|
|
272
|
+
enabledHint: "关闭后底栏整体隐藏。",
|
|
273
|
+
wrap: "允许换行",
|
|
274
|
+
wrapHint: "开启后统计段自动折行;关闭时单行省略。",
|
|
275
|
+
segStatus: "会话状态",
|
|
276
|
+
segCounts: "轮次&步数",
|
|
277
|
+
segTtft: "首字延迟",
|
|
278
|
+
segCacheHit: "缓存命中率",
|
|
279
|
+
segTokens: "Token计数",
|
|
280
|
+
segTps: "输出速度",
|
|
281
|
+
segSessionTime: "运行用时",
|
|
282
|
+
segCost: "会话费用",
|
|
283
|
+
segLastCost: "本次费用",
|
|
284
|
+
segBalance: "余额",
|
|
285
|
+
f_peak: "高峰",
|
|
286
|
+
f_valley: "低谷",
|
|
287
|
+
f_counts: "{turns} 轮 {steps} 步",
|
|
288
|
+
f_llm: "模型 {duration}",
|
|
289
|
+
f_tool: "工具 {duration}",
|
|
290
|
+
f_ttft: "首字平均 {duration}",
|
|
291
|
+
f_cacheHit: "缓存命中 {percent}%",
|
|
292
|
+
f_tokens: "{total} tokens",
|
|
293
|
+
f_tokensHit: "输入(命中缓存)",
|
|
294
|
+
f_tokensMiss: "输入(未命中缓存)",
|
|
295
|
+
f_tokensOut: "输出",
|
|
296
|
+
f_context: "上下文 {percent}%",
|
|
297
|
+
f_speed: "输出速度 {throughput}t/s",
|
|
298
|
+
f_sessionTime: "总用时 {duration}",
|
|
299
|
+
f_cost: "总计 {symbol}{cost}",
|
|
300
|
+
priceTierPeak: "高峰时段",
|
|
301
|
+
priceTierOffPeak: "空闲时段",
|
|
302
|
+
f_balanceDash: "余额 -",
|
|
303
|
+
f_lastCost: "本轮 {symbol}{cost}",
|
|
304
|
+
f_balance: "余额 {symbol}{amount}",
|
|
305
|
+
dragHint: "拖动排序",
|
|
306
|
+
secBasic: "基础",
|
|
307
|
+
secSegments: "数据字段",
|
|
308
|
+
secSegmentsHint: "勾选显示;按住拖动可调整顺序。",
|
|
309
|
+
secPrices: "自定义模型价格",
|
|
310
|
+
secPricesHint: "填写你使用的模型单价(每百万tokens/元)。",
|
|
311
|
+
segStatusHint: "当前会话的运行状态及峰谷时段。",
|
|
312
|
+
segCountsHint: "当前会话的总轮次与执行步数。",
|
|
313
|
+
segTtftHint: "首字的平均延迟。",
|
|
314
|
+
segCacheHitHint: "当前会话的平均缓存命中率。",
|
|
315
|
+
segTokensHint: "当前会话累计消耗的模型token(鼠标悬停查看明细)。",
|
|
316
|
+
segTpsHint: "token的每秒平均输出速度。",
|
|
317
|
+
segSessionTimeHint: "当前会话的总计运行时间。",
|
|
318
|
+
segCostHint: "当前会话估算消耗费用,包含主模型、子代理和辅助调用。",
|
|
319
|
+
segLastCostHint: "当前或最近一轮交流的估算消耗费用。",
|
|
320
|
+
segBalanceHint: "账户余额,每分钟刷新。",
|
|
321
|
+
priceCurrent: "当前会话使用:",
|
|
322
|
+
modelUnknown: "未识别",
|
|
323
|
+
unitCountTurns: " 轮",
|
|
324
|
+
unitCountSteps: " 步",
|
|
325
|
+
unitCost: "",
|
|
326
|
+
unitSpeed: "t/s",
|
|
327
|
+
priceSuggestedHint: "价格库正使用官方参考价。",
|
|
328
|
+
priceNewModel: "自定义模型",
|
|
329
|
+
priceAdd: "添加",
|
|
330
|
+
priceEmpty: "价格库为空 —— 输入模型名(如 deepseek-flash)后点添加。",
|
|
331
|
+
priceRemove: "删除",
|
|
332
|
+
priceInput: "输入(缓存未命中)",
|
|
333
|
+
priceCacheRead: "输入(缓存命中)",
|
|
334
|
+
priceOutput: "输出",
|
|
335
|
+
priceTiered: "峰谷计价",
|
|
336
|
+
priceEdit: "修改",
|
|
337
|
+
priceSave: "保存",
|
|
338
|
+
reset: "恢复默认设置"
|
|
339
|
+
};
|
|
340
|
+
const en = {
|
|
341
|
+
nav: "Status Bar",
|
|
342
|
+
intro: "Takes over the stats line under the composer. Tick the fields to show, drag to reorder, or set model prices.",
|
|
343
|
+
enabled: "Enable status bar",
|
|
344
|
+
enabledHint: "Hides the whole bar when off.",
|
|
345
|
+
wrap: "Allow wrapping",
|
|
346
|
+
wrapHint: "Segments wrap onto multiple lines; when off, a single elided line.",
|
|
347
|
+
segStatus: "Session status",
|
|
348
|
+
segCounts: "Turns & steps",
|
|
349
|
+
segTtft: "First-token delay",
|
|
350
|
+
segCacheHit: "Cache hit rate",
|
|
351
|
+
segTokens: "Session tokens",
|
|
352
|
+
segTps: "Output speed",
|
|
353
|
+
segSessionTime: "Run time",
|
|
354
|
+
segCost: "Session cost",
|
|
355
|
+
segLastCost: "This turn cost",
|
|
356
|
+
segBalance: "Balance",
|
|
357
|
+
f_peak: "peak",
|
|
358
|
+
f_valley: "off-peak",
|
|
359
|
+
f_counts: "{turns} turns · {steps} steps",
|
|
360
|
+
f_llm: "model {duration}",
|
|
361
|
+
f_tool: "tool {duration}",
|
|
362
|
+
f_ttft: "avg first token {duration}",
|
|
363
|
+
f_cacheHit: "cache hit {percent}%",
|
|
364
|
+
f_tokens: "{total} tokens",
|
|
365
|
+
f_tokensHit: "input (cache hit)",
|
|
366
|
+
f_tokensMiss: "input (cache miss)",
|
|
367
|
+
f_tokensOut: "output",
|
|
368
|
+
f_context: "context {percent}%",
|
|
369
|
+
f_speed: "output {throughput}t/s",
|
|
370
|
+
f_sessionTime: "total {duration}",
|
|
371
|
+
f_cost: "total {symbol}{cost}",
|
|
372
|
+
priceTierPeak: "Peak",
|
|
373
|
+
priceTierOffPeak: "Off-peak",
|
|
374
|
+
f_lastCost: "this turn {symbol}{cost}",
|
|
375
|
+
f_balance: "balance {symbol}{amount}",
|
|
376
|
+
dragHint: "Drag to reorder",
|
|
377
|
+
secBasic: "Basics",
|
|
378
|
+
secSegments: "Data fields",
|
|
379
|
+
secSegmentsHint: "Tick to show; drag to reorder.",
|
|
380
|
+
secPrices: "Custom model prices",
|
|
381
|
+
secPricesHint: "Prices per million tokens (CNY).",
|
|
382
|
+
segStatusHint: "Run state and peak/off-peak period of the current session.",
|
|
383
|
+
segCountsHint: "Total turns and executed steps of the current session.",
|
|
384
|
+
segTtftHint: "Average delay of the first token.",
|
|
385
|
+
segCacheHitHint: "Average cache hit rate of the current session.",
|
|
386
|
+
segTokensHint: "Model tokens consumed by the current session (hover for the breakdown).",
|
|
387
|
+
segTpsHint: "Average output speed in tokens per second.",
|
|
388
|
+
segSessionTimeHint: "Total run time of the current session.",
|
|
389
|
+
segCostHint: "Estimated cost of the current session, including the main model, subagents, and helper calls.",
|
|
390
|
+
segLastCostHint: "Estimated cost of the current or most recent turn.",
|
|
391
|
+
segBalanceHint: "Account balance, refreshed every minute.",
|
|
392
|
+
priceCurrent: "Current session:",
|
|
393
|
+
modelUnknown: "unknown",
|
|
394
|
+
unitCountTurns: " turns",
|
|
395
|
+
unitCountSteps: " steps",
|
|
396
|
+
unitCost: " $",
|
|
397
|
+
unitSpeed: "t/s",
|
|
398
|
+
priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
|
|
399
|
+
f_balanceDash: "balance -",
|
|
400
|
+
priceNewModel: "Custom model",
|
|
401
|
+
priceAdd: "Add",
|
|
402
|
+
priceEmpty: "Price book is empty — type a model name (e.g. deepseek-flash) and click Add.",
|
|
403
|
+
priceRemove: "Remove",
|
|
404
|
+
priceInput: "Input (cache miss)",
|
|
405
|
+
priceCacheRead: "Input (cache hit)",
|
|
406
|
+
priceOutput: "Output",
|
|
407
|
+
priceTiered: "Peak / off-peak pricing",
|
|
408
|
+
priceEdit: "Edit",
|
|
409
|
+
priceSave: "Save",
|
|
410
|
+
reset: "Reset settings"
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/* --------------------------------------------------------------- 段定义
|
|
414
|
+
* array 顺序即底栏渲染顺序,也是出厂默认顺序;全部段默认勾选。
|
|
415
|
+
* 「恢复默认设置」按此重排并全选,与设置页截图一致。 */
|
|
416
|
+
const SEGMENTS = [
|
|
417
|
+
{ id: "status", label: "segStatus", hint: "segStatusHint", def: true, short: { zh: "峰谷", en: "period" } },
|
|
418
|
+
{ id: "counts", label: "segCounts", hint: "segCountsHint", def: true, short: { zh: "轮次/步数", en: "turns/steps" } },
|
|
419
|
+
{ id: "cacheHit", label: "segCacheHit", hint: "segCacheHitHint", def: true, short: { zh: "缓存命中", en: "cache hit" } },
|
|
420
|
+
{ id: "ttft", label: "segTtft", hint: "segTtftHint", def: true, short: { zh: "首字平均", en: "avg first token" } },
|
|
421
|
+
{ id: "tps", label: "segTps", hint: "segTpsHint", def: true, short: { zh: "输出速度", en: "output speed" } },
|
|
422
|
+
{ id: "sessionTime", label: "segSessionTime", hint: "segSessionTimeHint", def: true, short: { zh: "总用时", en: "total time" } },
|
|
423
|
+
{ id: "lastCost", label: "segLastCost", hint: "segLastCostHint", def: true, short: { zh: "本轮", en: "this turn" } },
|
|
424
|
+
{ id: "cost", label: "segCost", hint: "segCostHint", def: true, short: { zh: "总计", en: "total" } },
|
|
425
|
+
{ id: "balance", label: "segBalance", hint: "segBalanceHint", def: true, short: { zh: "余额", en: "balance" } },
|
|
426
|
+
{ id: "tokens", label: "segTokens", hint: "segTokensHint", def: true, short: { zh: "输入/输出", en: "in/out tokens" } }
|
|
427
|
+
];
|
|
428
|
+
const KNOWN = {};
|
|
429
|
+
SEGMENTS.forEach((s) => { KNOWN[s.id] = true; });
|
|
430
|
+
/** 出厂默认:全部段,顺序 = 上方 SEGMENTS 声明顺序。 */
|
|
431
|
+
const DEFAULT_SEGMENTS = SEGMENTS.filter((s) => s.def).map((s) => s.id);
|
|
432
|
+
|
|
433
|
+
/* --------------------------------------------------------------- store */
|
|
434
|
+
/** 读配置:新键优先;只有旧键时把内容搬到新键再用(旧键保留)。 */
|
|
435
|
+
function readStored() {
|
|
436
|
+
const current = window.localStorage.getItem(STORAGE_KEY);
|
|
437
|
+
if (current !== null) return current;
|
|
438
|
+
const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
439
|
+
if (legacy === null) return null;
|
|
440
|
+
try { window.localStorage.setItem(STORAGE_KEY, legacy); } catch (error) { /* 隐私模式:仅内存生效 */ }
|
|
441
|
+
return legacy;
|
|
442
|
+
}
|
|
443
|
+
function loadConfig() {
|
|
444
|
+
const fallback = {
|
|
445
|
+
version: CONFIG_VERSION,
|
|
446
|
+
priceConfigured: false,
|
|
447
|
+
enabled: true,
|
|
448
|
+
wrap: true,
|
|
449
|
+
segments: DEFAULT_SEGMENTS.slice(),
|
|
450
|
+
hidden: [],
|
|
451
|
+
currency: "CNY",
|
|
452
|
+
models: Object.assign({}, DEFAULT_PRICES),
|
|
453
|
+
modelOrder: Object.keys(DEFAULT_PRICES)
|
|
454
|
+
};
|
|
455
|
+
try {
|
|
456
|
+
const raw = readStored();
|
|
457
|
+
if (raw === null) return fallback;
|
|
458
|
+
const parsed = JSON.parse(raw);
|
|
459
|
+
const stored = Array.isArray(parsed.segments)
|
|
460
|
+
? parsed.segments.filter((id) => KNOWN[id] === true)
|
|
461
|
+
: fallback.segments.slice();
|
|
462
|
+
const version = typeof parsed.version === "number" ? parsed.version : 1;
|
|
463
|
+
const hiddenStored = Array.isArray(parsed.hidden)
|
|
464
|
+
? parsed.hidden.filter((id) => KNOWN[id] === true)
|
|
465
|
+
: [];
|
|
466
|
+
if (version < 6) {
|
|
467
|
+
/* v5 及更早:segments 只存已勾选的段。未勾选的按出厂顺序补到末尾并标记为未勾选。 */
|
|
468
|
+
DEFAULT_SEGMENTS.forEach((id) => {
|
|
469
|
+
if (stored.indexOf(id) === -1) {
|
|
470
|
+
stored.push(id);
|
|
471
|
+
if (hiddenStored.indexOf(id) === -1) hiddenStored.push(id);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/* 不变量:段序覆盖全部段;hidden 只表示勾选状态 */
|
|
476
|
+
DEFAULT_SEGMENTS.forEach((id) => {
|
|
477
|
+
if (stored.indexOf(id) === -1) stored.push(id);
|
|
478
|
+
});
|
|
479
|
+
const hidden = hiddenStored.filter((id) => stored.indexOf(id) !== -1);
|
|
480
|
+
/* 价格库落后于参考价版本时并入官方价;用户自己改过的条目不覆盖 */
|
|
481
|
+
/* 价格库为空或全 0 时直接用内嵌官方价(自愈,无需手动填) */
|
|
482
|
+
if (pricesMissing(normalizeModels(parsed.models))) parsed.models = Object.assign({}, DEFAULT_PRICES);
|
|
483
|
+
const priceVersion = typeof parsed.priceVersion === "number" ? parsed.priceVersion : 0;
|
|
484
|
+
const models = priceVersion < PRICE_VERSION
|
|
485
|
+
? normalizeModels(Object.assign({}, parsed.models, DEFAULT_PRICES))
|
|
486
|
+
: normalizeModels(parsed.models);
|
|
487
|
+
const priceConfigured = parsed.priceConfigured === true || priceVersion >= PRICE_VERSION;
|
|
488
|
+
/* 模型顺序:显式数组(整数样式的键会被 Object.keys 排到最前,不能靠对象键序) */
|
|
489
|
+
const modelOrder = [];
|
|
490
|
+
(Array.isArray(parsed.modelOrder) ? parsed.modelOrder : []).forEach((name) => {
|
|
491
|
+
if (typeof name === "string" && models[name] !== undefined && modelOrder.indexOf(name) === -1) modelOrder.push(name);
|
|
492
|
+
});
|
|
493
|
+
Object.keys(models).forEach((name) => {
|
|
494
|
+
if (modelOrder.indexOf(name) === -1) modelOrder.push(name);
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
version: CONFIG_VERSION,
|
|
498
|
+
priceConfigured: parsed.priceConfigured === true,
|
|
499
|
+
enabled: parsed.enabled !== false,
|
|
500
|
+
wrap: parsed.wrap !== false,
|
|
501
|
+
segments: stored,
|
|
502
|
+
hidden: hidden,
|
|
503
|
+
currency: "CNY", /* 币种选择已移除:固定人民币 */
|
|
504
|
+
priceVersion: PRICE_VERSION,
|
|
505
|
+
priceConfigured: priceConfigured,
|
|
506
|
+
models: Object.keys(models).length > 0 ? models : normalizeModels(DEFAULT_PRICES),
|
|
507
|
+
modelOrder: modelOrder.length > 0 ? modelOrder : Object.keys(DEFAULT_PRICES)
|
|
508
|
+
};
|
|
509
|
+
} catch (error) {
|
|
510
|
+
return fallback;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let config = loadConfig();
|
|
515
|
+
const listeners = new Set();
|
|
516
|
+
function subscribe(fn) {
|
|
517
|
+
listeners.add(fn);
|
|
518
|
+
return () => { listeners.delete(fn); };
|
|
519
|
+
}
|
|
520
|
+
function snapshot() { return config; }
|
|
521
|
+
function setConfig(patch) {
|
|
522
|
+
config = Object.assign({ version: CONFIG_VERSION }, config, patch);
|
|
523
|
+
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch (error) { /* 隐私模式下仅内存生效 */ }
|
|
524
|
+
listeners.forEach((fn) => { try { fn(); } catch (error) { /* 单个订阅者失败不影响其余 */ } });
|
|
525
|
+
}
|
|
526
|
+
function useConfig() {
|
|
527
|
+
return react.useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
528
|
+
}
|
|
529
|
+
/** 勾选只切换显示:段序不动,取消勾选的段留在原位。 */
|
|
530
|
+
function toggleSegment(id, on) {
|
|
531
|
+
const hidden = (Array.isArray(config.hidden) ? config.hidden : []).filter((x) => x !== id);
|
|
532
|
+
if (on !== true) hidden.push(id);
|
|
533
|
+
setConfig({ hidden: hidden });
|
|
534
|
+
}
|
|
535
|
+
/** 该段是否勾选显示:在段序里且不在 hidden 里。 */
|
|
536
|
+
function isSegmentOn(cfg, id) {
|
|
537
|
+
if (Array.isArray(cfg.segments) && cfg.segments.indexOf(id) === -1) return false;
|
|
538
|
+
return !(Array.isArray(cfg.hidden) && cfg.hidden.indexOf(id) !== -1);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* 把 fromId 插到 toId 的前面(after=false)或后面(after=true)。
|
|
542
|
+
* 先摘出被拖项再按目标定位,避免向下拖时索引差一位。
|
|
543
|
+
*/
|
|
544
|
+
function reorderSegments(fromId, toId, after) {
|
|
545
|
+
if (fromId === null || fromId === undefined || toId === null || toId === undefined) return;
|
|
546
|
+
if (fromId === toId) return;
|
|
547
|
+
const next = config.segments.slice();
|
|
548
|
+
if (next.indexOf(fromId) === -1 || next.indexOf(toId) === -1) return;
|
|
549
|
+
next.splice(next.indexOf(fromId), 1);
|
|
550
|
+
const at = next.indexOf(toId) + (after === true ? 1 : 0);
|
|
551
|
+
next.splice(at, 0, fromId);
|
|
552
|
+
setConfig({ segments: next });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ---------------------------------------------------------------- 工具 */
|
|
556
|
+
/** token 精确到个位数,每三位一个逗号。 */
|
|
557
|
+
function formatTokens(value) {
|
|
558
|
+
const n = Math.round(Number(value) || 0);
|
|
559
|
+
const sign = n < 0 ? "-" : "";
|
|
560
|
+
const digits = String(Math.abs(n));
|
|
561
|
+
let out = "";
|
|
562
|
+
for (let i = 0; i < digits.length; i += 1) {
|
|
563
|
+
if (i > 0 && (digits.length - i) % 3 === 0) out += ",";
|
|
564
|
+
out += digits[i];
|
|
565
|
+
}
|
|
566
|
+
return sign + out;
|
|
567
|
+
}
|
|
568
|
+
/** 秒级时长,保留一位小数(首字延迟这类不会超过一分钟的指标用)。 */
|
|
569
|
+
function formatSeconds(ms) {
|
|
570
|
+
const n = Number(ms) || 0;
|
|
571
|
+
if (n <= 0) return null;
|
|
572
|
+
return String(Math.round(n / 100) / 10);
|
|
573
|
+
}
|
|
574
|
+
/** 时长按最小单位给:>=1h 用 h/m,>=1m 用 m/s,不足一分钟只给秒;分钟与秒之间不加空格。 */
|
|
575
|
+
function formatDuration(ms) {
|
|
576
|
+
const n = Number(ms) || 0;
|
|
577
|
+
if (n <= 0) return null;
|
|
578
|
+
const seconds = n / 1000;
|
|
579
|
+
const whole = Math.round(seconds);
|
|
580
|
+
if (whole < 60) return (Math.round(seconds * 10) / 10) + "s";
|
|
581
|
+
const minutes = Math.floor(whole / 60);
|
|
582
|
+
if (minutes < 60) return minutes + "m" + (whole % 60) + "s";
|
|
583
|
+
return Math.floor(minutes / 60) + "h" + (minutes % 60) + "m";
|
|
584
|
+
}
|
|
585
|
+
function formatThroughput(tokensPerSecond) {
|
|
586
|
+
return String(Math.round(Number(tokensPerSecond) || 0));
|
|
587
|
+
}
|
|
588
|
+
function billedInputTokens(usage) {
|
|
589
|
+
return (usage.uncachedInputTokens || 0) + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0);
|
|
590
|
+
}
|
|
591
|
+
function elapsedFromTimeline(timeline, now) {
|
|
592
|
+
if (timeline === undefined || timeline === null) return null;
|
|
593
|
+
const turns = timeline.turns;
|
|
594
|
+
let list = [];
|
|
595
|
+
try {
|
|
596
|
+
if (turns !== undefined && turns !== null && typeof turns.values === "function") list = Array.from(turns.values());
|
|
597
|
+
else if (Array.isArray(turns)) list = turns;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
let start = null;
|
|
602
|
+
let end = null;
|
|
603
|
+
for (const turn of list) {
|
|
604
|
+
if (turn === undefined || turn === null) continue;
|
|
605
|
+
const began = turn.start !== undefined && turn.start !== null ? turn.start.time : undefined;
|
|
606
|
+
const finished = turn.end !== undefined && turn.end !== null ? turn.end.time : undefined;
|
|
607
|
+
if (typeof began === "number" && (start === null || began < start)) start = began;
|
|
608
|
+
if (typeof finished === "number" && (end === null || finished > end)) end = finished;
|
|
609
|
+
}
|
|
610
|
+
if (start === null) return null;
|
|
611
|
+
return Math.max(0, (end === null ? now : end) - start);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/* ------------------------------------------------------------ 段取值 */
|
|
615
|
+
function segmentView(id, src, t) {
|
|
616
|
+
const stats = src.stats;
|
|
617
|
+
const usage = src.usage;
|
|
618
|
+
const pressure = src.pressure;
|
|
619
|
+
const symbol = currencySymbol(src.currency);
|
|
620
|
+
|
|
621
|
+
if (id === "status") {
|
|
622
|
+
const running = src.running === true
|
|
623
|
+
|| (src.partial !== undefined && src.partial !== null)
|
|
624
|
+
|| (Array.isArray(src.runningCalls) && src.runningCalls.length > 0);
|
|
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
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (id === "counts") {
|
|
639
|
+
if (stats === undefined || stats === null || !(stats.steps > 0)) return null;
|
|
640
|
+
return { id: id, text: t("f_counts", { turns: stats.turns || 0, steps: stats.steps || 0 }) };
|
|
641
|
+
}
|
|
642
|
+
|
|
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
|
+
if (id === "ttft") {
|
|
655
|
+
if (stats === undefined || stats === null || !(stats.ttftSteps > 0) || !(stats.ttftMs > 0)) return null;
|
|
656
|
+
const duration = formatSeconds(stats.ttftMs / stats.ttftSteps);
|
|
657
|
+
if (duration === null) return null;
|
|
658
|
+
return { id: id, text: t("f_ttft", { duration: duration + "s" }) };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (id === "cacheHit") {
|
|
662
|
+
if (usage === undefined || usage === null) return null;
|
|
663
|
+
const denominator = billedInputTokens(usage);
|
|
664
|
+
if (denominator <= 0) return null;
|
|
665
|
+
const percent = Math.min(99.99, ((usage.cacheReadTokens || 0) / denominator) * 100).toFixed(2);
|
|
666
|
+
return { id: id, text: t("f_cacheHit", { percent: percent }) };
|
|
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
|
+
];
|
|
681
|
+
return {
|
|
682
|
+
id: id,
|
|
683
|
+
/* 只显示三者之和(命中缓存 + 未命中缓存 + 输出),不带段名 */
|
|
684
|
+
text: t("f_tokens", { total: formatTokens(total) }),
|
|
685
|
+
rows: rows,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
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
|
+
if (id === "tps") {
|
|
699
|
+
if (stats === undefined || stats === null || !(stats.decodeMs > 0) || !(stats.decodeTokens > 0)) return null;
|
|
700
|
+
return { id: id, text: t("f_speed", { throughput: formatThroughput(stats.decodeTokens / (stats.decodeMs / 1000)) }) };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/* 总用时 = 各轮用时之和(与官方"本轮总用时"同口径) */
|
|
704
|
+
if (id === "sessionTime") {
|
|
705
|
+
let elapsed = null;
|
|
706
|
+
const range = src.timeRange;
|
|
707
|
+
if (range !== undefined && range !== null) {
|
|
708
|
+
if (typeof range.turns === "number" && range.turns > 0) {
|
|
709
|
+
elapsed = range.turns;
|
|
710
|
+
if (typeof range.since === "number") elapsed += Math.max(0, src.now - range.since);
|
|
711
|
+
} else if (typeof range.steps === "number" && range.steps > 0) {
|
|
712
|
+
elapsed = range.steps;
|
|
713
|
+
if (typeof range.stepSince === "number") elapsed += Math.max(0, src.now - range.stepSince);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (elapsed === null || elapsed <= 0) elapsed = elapsedFromTimeline(src.timeline, src.now);
|
|
717
|
+
if (elapsed === null) return null;
|
|
718
|
+
const duration = formatDuration(elapsed);
|
|
719
|
+
if (duration === null) return null;
|
|
720
|
+
return { id: id, text: t("f_sessionTime", { duration: duration }) };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (id === "cost") {
|
|
724
|
+
const sessionUsage = src.sessionUsage;
|
|
725
|
+
if (sessionUsage === undefined || sessionUsage === null) return null;
|
|
726
|
+
const calls = Array.isArray(sessionUsage.calls) ? sessionUsage.calls : null;
|
|
727
|
+
if (calls === null || calls.length === 0) return null;
|
|
728
|
+
/* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价 */
|
|
729
|
+
const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
|
|
730
|
+
let total = 0;
|
|
731
|
+
let priced = false;
|
|
732
|
+
for (let i = 0; i < calls.length; i += 1) {
|
|
733
|
+
const callModel = typeof calls[i].model === "string" ? calls[i].model : fallbackModel;
|
|
734
|
+
const one = costOf(calls[i], callModel, calls[i].at);
|
|
735
|
+
if (one === null) continue;
|
|
736
|
+
priced = true;
|
|
737
|
+
total += Number(one);
|
|
738
|
+
}
|
|
739
|
+
if (!priced) return null;
|
|
740
|
+
return { id: id, text: t("f_cost", { symbol: symbol, cost: total.toFixed(2) }) };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/* 本轮费用:优先按 node.turn 折叠(与官方"本轮用量"同源),否则退回差分 */
|
|
744
|
+
if (id === "lastCost") {
|
|
745
|
+
const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
|
|
746
|
+
const sessionUsageNow = src.sessionUsage;
|
|
747
|
+
const turnCalls = sessionUsageNow !== undefined && sessionUsageNow !== null
|
|
748
|
+
&& sessionUsageNow.current !== null && sessionUsageNow.current !== undefined
|
|
749
|
+
&& Array.isArray(sessionUsageNow.current.calls)
|
|
750
|
+
? sessionUsageNow.current.calls
|
|
751
|
+
: null;
|
|
752
|
+
if (turnCalls !== null && turnCalls.length > 0) {
|
|
753
|
+
let turnTotal = 0;
|
|
754
|
+
let turnPriced = false;
|
|
755
|
+
for (let i = 0; i < turnCalls.length; i += 1) {
|
|
756
|
+
const callModel = typeof turnCalls[i].model === "string" ? turnCalls[i].model : fallbackModel;
|
|
757
|
+
const one = costOf(turnCalls[i], callModel, turnCalls[i].at);
|
|
758
|
+
if (one === null) continue;
|
|
759
|
+
turnPriced = true;
|
|
760
|
+
turnTotal += Number(one);
|
|
761
|
+
}
|
|
762
|
+
if (turnPriced) return { id: id, text: t("f_lastCost", { symbol: symbol, cost: turnTotal.toFixed(2) }) };
|
|
763
|
+
}
|
|
764
|
+
/* 兜底:官方 tokenUsage 差分,按当下时段计价 */
|
|
765
|
+
const bucket = src.turnUsage !== null && src.turnUsage !== undefined ? src.turnUsage : null;
|
|
766
|
+
if (bucket === null) return null;
|
|
767
|
+
const cost = costOf(bucket, fallbackModel, src.now);
|
|
768
|
+
if (cost === null) return null;
|
|
769
|
+
return { id: id, text: t("f_lastCost", { symbol: symbol, cost: cost }) };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (id === "balance") {
|
|
773
|
+
const balance = src.balance;
|
|
774
|
+
if (balance === null || balance === undefined) return null;
|
|
775
|
+
if (balance.ok !== true) return null;
|
|
776
|
+
if (typeof balance.total !== "string") return null;
|
|
777
|
+
/* 余额段不带悬停气泡 */
|
|
778
|
+
return { id: id, text: t("f_balance", { symbol: symbol, amount: balance.total }) };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/* ------------------------------------------------------- 缺数据占位显示
|
|
785
|
+
* 段名说明这一段统计的是什么,缺数据的位置用横杠顶上,
|
|
786
|
+
* 计数、比率、速度、时长各自带上单位(- 轮 - 步 / -m -s / - t/s),
|
|
787
|
+
* 金额段无数据只留横杠,不写「暂无」这类文字。 */
|
|
788
|
+
function detectLocale(t) {
|
|
789
|
+
return t("segCounts") === zh.segCounts ? "zh" : "en";
|
|
790
|
+
}
|
|
791
|
+
function segmentText(id, view, t) {
|
|
792
|
+
if (view !== null && view !== undefined) return view.text;
|
|
793
|
+
const segment = SEGMENTS.filter((s) => s.id === id)[0];
|
|
794
|
+
if (segment === undefined || segment.short === undefined) return "";
|
|
795
|
+
const locale = detectLocale(t);
|
|
796
|
+
const label = segment.short[locale] !== undefined ? segment.short[locale] : segment.short.zh;
|
|
797
|
+
const dash = "-";
|
|
798
|
+
if (id === "counts") return dash + t("unitCountTurns") + " " + dash + t("unitCountSteps");
|
|
799
|
+
if (id === "cacheHit") return label + " " + dash + " %";
|
|
800
|
+
if (id === "ttft") return label + " " + dash + " s";
|
|
801
|
+
if (id === "tps") return label + " " + dash + t("unitSpeed");
|
|
802
|
+
if (id === "sessionTime" || id === "durations") return label + " " + dash + "m " + dash + "s";
|
|
803
|
+
if (id === "cost" || id === "lastCost") return label + " " + dash + t("unitCost");
|
|
804
|
+
if (id === "balance") return t("f_balanceDash");
|
|
805
|
+
if (id === "tokens") return dash + " tokens";
|
|
806
|
+
return dash;
|
|
807
|
+
}
|
|
808
|
+
/* ---------------------------------------------------------------- css */
|
|
809
|
+
const CSS = [
|
|
810
|
+
/* 接管输入框下方的统计行:dock 里凡“不是本插件那一项”的都藏掉(也就是官方 StatsPills)。
|
|
811
|
+
* 两条不依赖官方构建 hash 的要点:
|
|
812
|
+
* 1. 用 .dsb-root 认自己,而不是官方组件的类名(hash 会随 DSH 更新变,2.0.10 踩过);
|
|
813
|
+
* 2. 自己带标记与“后代里带标记”都要判 —— :has() 只看后代,漏掉前者会把自己的行也藏掉。 */
|
|
814
|
+
'[data-slot="conversation.composer.dock"] > *:not(.dsb-root):not(:has(.dsb-root)){display:none !important}',
|
|
815
|
+
".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
|
+
".dsb-wrap{flex-wrap:wrap}",
|
|
817
|
+
".dsb-dot{flex:none;width:7px;height:7px;border-radius:50%;margin-right:7px;background:#9ca3af}",
|
|
818
|
+
".dsb-dot-running{background:#22c55e;box-shadow:0 0 0 2px color-mix(in srgb,#22c55e 15%,transparent)}",
|
|
819
|
+
".dsb-dot-error{background:#ef4444;box-shadow:0 0 0 2px color-mix(in srgb,#ef4444 15%,transparent)}",
|
|
820
|
+
".dsb-dot-approval{background:#f59e0b;box-shadow:0 0 0 2px color-mix(in srgb,#f59e0b 15%,transparent)}",
|
|
821
|
+
".dsb-seg{white-space:nowrap}",
|
|
822
|
+
/* 气泡相对状态栏定位 */
|
|
823
|
+
".dsb-root{position:relative}",
|
|
824
|
+
".dsb-tip{position:absolute;bottom:100%;left:0;margin-bottom:6px;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#1f1f1f);border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));box-shadow:0 6px 18px rgba(0,0,0,.18);white-space:nowrap;z-index:40;pointer-events:none}",
|
|
825
|
+
".dsb-tip-grid{display:grid;grid-template-columns:auto auto;gap:2px 16px;font-variant-numeric:tabular-nums}",
|
|
826
|
+
".dsb-tip-label{text-align:left;color:var(--dsw-alias-label-secondary)}",
|
|
827
|
+
".dsb-tip-value{text-align:right;color:var(--dsw-alias-label-primary)}",
|
|
828
|
+
".dsb-sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
|
|
829
|
+
/* 设置页 */
|
|
830
|
+
".dsb-settings{display:flex;flex-direction:column;gap:18px;font-size:14px}",
|
|
831
|
+
".dsb-settings .dsb-intro{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.6}",
|
|
832
|
+
".dsb-settings .dsb-section{display:flex;flex-direction:column;gap:8px}",
|
|
833
|
+
".dsb-settings .dsb-sectitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;letter-spacing:.02em}",
|
|
834
|
+
".dsb-settings .dsb-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}",
|
|
835
|
+
".dsb-settings .dsb-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-1,transparent)}",
|
|
836
|
+
".dsb-settings .dsb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}",
|
|
837
|
+
".dsb-settings .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
|
|
838
|
+
".dsb-settings .dsb-check input{flex:none;margin:2px 0 0}",
|
|
839
|
+
".dsb-settings input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
|
|
840
|
+
".dsb-settings .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
|
|
841
|
+
".dsb-settings .dsb-name{color:var(--dsw-alias-label-primary)}",
|
|
842
|
+
".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
|
|
843
|
+
".dsb-settings .dsb-row.dsb-dragging{opacity:.35}",
|
|
844
|
+
".dsb-settings .dsb-row.dsb-over-before{box-shadow:inset 0 2px 0 var(--dsw-alias-brand-primary,#4d6bfe)}",
|
|
845
|
+
".dsb-settings .dsb-row.dsb-over-after{box-shadow:inset 0 -2px 0 var(--dsw-alias-brand-primary,#4d6bfe)}",
|
|
846
|
+
".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
|
|
847
|
+
'.dsb-settings .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
|
|
848
|
+
".dsb-settings .dsb-handle:active{cursor:grabbing}",
|
|
849
|
+
".dsb-settings .dsb-handle-off{opacity:.25;cursor:default}",
|
|
850
|
+
/* 价格库 */
|
|
851
|
+
".dsb-settings .dsb-card{border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.25));border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:10px}",
|
|
852
|
+
".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
|
|
853
|
+
".dsb-settings .dsb-current b{color:var(--dsw-alias-label-primary);font-weight:600}",
|
|
854
|
+
".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))}",
|
|
856
|
+
".dsb-settings .dsb-tier{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
857
|
+
".dsb-settings .dsb-tier-name{flex:none;width:48px;font-size:12px;color:var(--dsw-alias-label-secondary)}",
|
|
858
|
+
".dsb-settings .dsb-model:first-child{border-top:0;padding-top:0}",
|
|
859
|
+
".dsb-settings .dsb-model:last-child{padding-bottom:0}",
|
|
860
|
+
".dsb-settings .dsb-model-name{color:var(--dsw-alias-label-primary);font-weight:600;font-size:13px}",
|
|
861
|
+
".dsb-settings .dsb-model-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:28px}",
|
|
862
|
+
".dsb-settings .dsb-model-actions{display:flex;gap:6px;flex:none}",
|
|
863
|
+
".dsb-settings .dsb-inline-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}",
|
|
864
|
+
".dsb-settings .dsb-fields{display:flex;flex-wrap:wrap;gap:8px}",
|
|
865
|
+
".dsb-settings .dsb-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
866
|
+
".dsb-settings .dsb-field input{width:74px;text-align:right}",
|
|
867
|
+
".dsb-settings input[type=number]{text-align:right}",
|
|
868
|
+
".dsb-settings input[type=number]::-webkit-outer-spin-button,.dsb-settings input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}",
|
|
869
|
+
".dsb-settings input[type=number]{-moz-appearance:textfield;appearance:textfield}",
|
|
870
|
+
".dsb-settings input::placeholder{color:var(--dsw-alias-label-tertiary);opacity:.5}",
|
|
871
|
+
".dsb-settings input[type=text],.dsb-settings input[type=number],.dsb-settings select{box-sizing:border-box;height:28px;padding:0 8px;font:inherit;font-size:13px;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;outline:none}",
|
|
872
|
+
".dsb-settings input[type=text]:focus,.dsb-settings input[type=number]:focus,.dsb-settings select:focus{border-color:var(--dsw-alias-brand-primary,#4d6bfe)}",
|
|
873
|
+
".dsb-settings .dsb-addrow{display:flex;gap:8px;align-items:center}",
|
|
874
|
+
".dsb-settings .dsb-addrow input{flex:1;min-width:0}",
|
|
875
|
+
".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
|
+
".dsb-settings .dsb-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
|
|
877
|
+
".dsb-settings .dsb-action-sm{height:24px;padding:0 8px;font-size:12px}",
|
|
878
|
+
".dsb-settings .dsb-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}"
|
|
879
|
+
].join("");
|
|
880
|
+
|
|
881
|
+
/* 导航图标使用外部传入的 gauge(24x24 lucide,stroke=currentColor,随主题变色) */
|
|
882
|
+
const NAV_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>';
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* 把设置导航里本插件那一项的前导图标换成 gauge。
|
|
886
|
+
* 定位方式是按导航文案匹配(DSH 没给导航项留 id 属性),匹配不到就什么都不做。
|
|
887
|
+
*/
|
|
888
|
+
function installNavIcon(getLabel) {
|
|
889
|
+
const apply = () => {
|
|
890
|
+
const label = getLabel();
|
|
891
|
+
if (typeof label !== "string" || label.length === 0) return;
|
|
892
|
+
const cells = document.querySelectorAll("nav button");
|
|
893
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
894
|
+
const cell = cells[i];
|
|
895
|
+
if (String(cell.textContent).trim() !== label) continue;
|
|
896
|
+
if (cell.querySelector(".lucide-gauge") !== null) continue; /* 已经换过 */
|
|
897
|
+
const first = cell.firstElementChild;
|
|
898
|
+
if (first === null || typeof first.tagName !== "string" || first.tagName.toLowerCase() !== "svg") continue;
|
|
899
|
+
const box = document.createElement("span");
|
|
900
|
+
box.innerHTML = NAV_ICON_SVG;
|
|
901
|
+
const gauge = box.firstElementChild;
|
|
902
|
+
if (gauge === null) continue;
|
|
903
|
+
first.replaceWith(gauge);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
let scheduled = false;
|
|
907
|
+
const schedule = () => {
|
|
908
|
+
if (scheduled === true) return; /* 面板每次挂载/切换只扫一帧 */
|
|
909
|
+
scheduled = true;
|
|
910
|
+
window.requestAnimationFrame(() => { scheduled = false; apply(); });
|
|
911
|
+
};
|
|
912
|
+
apply();
|
|
913
|
+
const observer = new MutationObserver(schedule);
|
|
914
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
915
|
+
return () => observer.disconnect();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function installStyles() {
|
|
919
|
+
if (document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]') === null) {
|
|
920
|
+
const tag = document.createElement("style");
|
|
921
|
+
tag.dataset.plugin = "dsh-desktop-statusbar";
|
|
922
|
+
tag.dataset.pluginCss = STYLE_TAG_ID;
|
|
923
|
+
tag.textContent = CSS;
|
|
924
|
+
document.head.appendChild(tag);
|
|
925
|
+
}
|
|
926
|
+
return () => {
|
|
927
|
+
const tag = document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]');
|
|
928
|
+
if (tag !== null) tag.remove();
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** 该槽给的是 selector hook;包一层以保持"hook 调用顺序稳定"的语义。 */
|
|
933
|
+
function pick(hook, selector, fallback) {
|
|
934
|
+
if (typeof hook !== "function") return fallback;
|
|
935
|
+
return hook(selector);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/* ------------------------------------------------------------- 底栏组件 */
|
|
939
|
+
function StatusBar(props) {
|
|
940
|
+
const t = props.t;
|
|
941
|
+
const useProjection = props.useProjection;
|
|
942
|
+
const useSession = props.useSession;
|
|
943
|
+
const useSessions = props.useSessions;
|
|
944
|
+
const sessionId = props.sessionId;
|
|
945
|
+
const cfg = useConfig();
|
|
946
|
+
|
|
947
|
+
const stats = useProjection("sessionStats");
|
|
948
|
+
const usage = useProjection("tokenUsage");
|
|
949
|
+
const pressure = useProjection("contextPressure");
|
|
950
|
+
const sessionModel = useProjection("desktopStatusbarModel");
|
|
951
|
+
const sessionUsage = useProjection("desktopStatusbarUsage");
|
|
952
|
+
const timeRange = useProjection("desktopStatusbarActiveTime");
|
|
953
|
+
/* 设置页拿不到会话投影,把当前模型上报给 host,供设置页读取 */
|
|
954
|
+
const reportedModel = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.model === "string" && sessionModel.model.length > 0
|
|
955
|
+
? sessionModel.model
|
|
956
|
+
: null;
|
|
957
|
+
const reportedProvider = sessionModel !== null && sessionModel !== undefined && typeof sessionModel.provider === "string" ? sessionModel.provider : null;
|
|
958
|
+
react.useEffect(() => {
|
|
959
|
+
if (reportedModel === null) return;
|
|
960
|
+
window.fetch(ACTIVE_MODEL_URL, {
|
|
961
|
+
method: "POST",
|
|
962
|
+
headers: { "content-type": "application/json" },
|
|
963
|
+
body: JSON.stringify({ provider: reportedProvider, model: reportedModel })
|
|
964
|
+
}).catch(() => { /* 上报失败不影响底栏 */ });
|
|
965
|
+
}, [reportedModel, reportedProvider]);
|
|
966
|
+
|
|
967
|
+
const running = pick(useSession, (s) => s.running, undefined);
|
|
968
|
+
const partial = pick(useSession, (s) => s.partial, undefined);
|
|
969
|
+
const runningCalls = pick(useSession, (s) => s.runningCalls, undefined);
|
|
970
|
+
const lastAgentError = pick(useSession, (s) => s.lastAgentError, undefined);
|
|
971
|
+
const pendingApprovals = pick(useSession, (s) => s.pendingApprovals, undefined);
|
|
972
|
+
const timeline = pick(props.useChat, (s) => s.timeline, undefined);
|
|
973
|
+
const chatNodes = pick(props.useChat, (s) => (s.legacy === undefined || s.legacy === null ? undefined : s.legacy.nodes), undefined);
|
|
974
|
+
|
|
975
|
+
const [now, setNow] = react.useState(() => Date.now());
|
|
976
|
+
const [balance, setBalance] = react.useState(null);
|
|
977
|
+
const [tipId, setTipId] = react.useState(null);
|
|
978
|
+
|
|
979
|
+
/* 本轮费用兜底:官方全量 tokenUsage 差分 */
|
|
980
|
+
const turnBaseRef = react.useRef(null);
|
|
981
|
+
const turnLastRef = react.useRef(null);
|
|
982
|
+
const [, bumpTurn] = react.useState(0);
|
|
983
|
+
const usageSnapshot = usage === undefined || usage === null ? null : {
|
|
984
|
+
input: usage.uncachedInputTokens || 0,
|
|
985
|
+
cacheRead: usage.cacheReadTokens || 0,
|
|
986
|
+
cacheWrite: usage.cacheWriteTokens || 0,
|
|
987
|
+
output: usage.outputTokens || 0
|
|
988
|
+
};
|
|
989
|
+
react.useEffect(() => {
|
|
990
|
+
if (usageSnapshot === null) return;
|
|
991
|
+
if (running === true) {
|
|
992
|
+
if (turnBaseRef.current === null) turnBaseRef.current = usageSnapshot;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (turnBaseRef.current !== null) {
|
|
996
|
+
turnLastRef.current = diffUsage(usageSnapshot, turnBaseRef.current);
|
|
997
|
+
turnBaseRef.current = usageSnapshot;
|
|
998
|
+
bumpTurn((n) => n + 1);
|
|
999
|
+
}
|
|
1000
|
+
}, [running, usage]);
|
|
1001
|
+
|
|
1002
|
+
/* 计时:只要时间投影里有活跃基线就跑时钟。
|
|
1003
|
+
* 不能把 running 当唯一开关 —— 它在 step/turn 边界会短暂转 false,
|
|
1004
|
+
* 那样时钟会在每轮中途停摆,要等下一个事件才跳一次。 */
|
|
1005
|
+
/* 需要“现在几点”的段:总用时(时长累加)与峰谷判断(时段文字) */
|
|
1006
|
+
const needsNow = cfg.enabled === true && (isSegmentOn(cfg, "sessionTime") || isSegmentOn(cfg, "status"));
|
|
1007
|
+
const timeRangeNow = timeRange === undefined || timeRange === null ? null : timeRange;
|
|
1008
|
+
const clockActive = timeRangeNow !== null
|
|
1009
|
+
&& ((timeRangeNow.since !== null && timeRangeNow.since !== undefined)
|
|
1010
|
+
|| (timeRangeNow.stepSince !== null && timeRangeNow.stepSince !== undefined));
|
|
1011
|
+
react.useEffect(() => {
|
|
1012
|
+
if (needsNow !== true) return undefined;
|
|
1013
|
+
if (running === true || clockActive) {
|
|
1014
|
+
const timer = window.setInterval(() => setNow(Date.now()), running === true ? 1000 : 10000);
|
|
1015
|
+
return () => window.clearInterval(timer);
|
|
1016
|
+
}
|
|
1017
|
+
/* 空闲:睡到下一个峰谷边界再刷新,不轮询 */
|
|
1018
|
+
let timer = 0;
|
|
1019
|
+
const tick = () => {
|
|
1020
|
+
setNow(Date.now());
|
|
1021
|
+
timer = window.setTimeout(tick, msToNextPeakBoundary(Date.now()) + 1000);
|
|
1022
|
+
};
|
|
1023
|
+
timer = window.setTimeout(tick, msToNextPeakBoundary(Date.now()) + 1000);
|
|
1024
|
+
return () => window.clearTimeout(timer);
|
|
1025
|
+
}, [needsNow, running, clockActive]);
|
|
1026
|
+
|
|
1027
|
+
/* 价格库缺省或全 0 时自动补上官方参考价(不用用户手动填) */
|
|
1028
|
+
react.useEffect(() => {
|
|
1029
|
+
void ensurePrices();
|
|
1030
|
+
}, []);
|
|
1031
|
+
|
|
1032
|
+
const wantsBalance = cfg.enabled === true && isSegmentOn(cfg, "balance");
|
|
1033
|
+
react.useEffect(() => {
|
|
1034
|
+
if (!wantsBalance) return undefined;
|
|
1035
|
+
let alive = true;
|
|
1036
|
+
const load = () => {
|
|
1037
|
+
window.fetch(BALANCE_URL, { cache: "no-store" })
|
|
1038
|
+
.then((response) => response.json())
|
|
1039
|
+
.then((data) => { if (alive) setBalance(data); })
|
|
1040
|
+
.catch(() => { if (alive) setBalance({ ok: false, reason: "fetch-failed" }); });
|
|
1041
|
+
};
|
|
1042
|
+
load();
|
|
1043
|
+
const timer = window.setInterval(load, BALANCE_POLL_MS);
|
|
1044
|
+
return () => { alive = false; window.clearInterval(timer); };
|
|
1045
|
+
}, [wantsBalance]);
|
|
1046
|
+
|
|
1047
|
+
if (cfg.enabled !== true) return null;
|
|
1048
|
+
|
|
1049
|
+
/* 本轮费用:优先节点折叠(与官方同源),节点缺失才用差分兜底 */
|
|
1050
|
+
const nodeTurnUsage = foldTurnUsage(chatNodes, running === true ? 1 : 0);
|
|
1051
|
+
const turnUsage = nodeTurnUsage !== null ? nodeTurnUsage : turnLastRef.current;
|
|
1052
|
+
|
|
1053
|
+
const debugSrc = {
|
|
1054
|
+
stats: stats, usage: usage, pressure: pressure,
|
|
1055
|
+
sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRangeNow,
|
|
1056
|
+
running: running, partial: partial, runningCalls: runningCalls,
|
|
1057
|
+
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
|
|
1058
|
+
now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency
|
|
1059
|
+
};
|
|
1060
|
+
window.__dsbDebug = {
|
|
1061
|
+
now: now, running: running, clockActive: clockActive, timeRange: timeRangeNow,
|
|
1062
|
+
steps: stats === undefined || stats === null ? undefined : stats.steps,
|
|
1063
|
+
sessionModel: sessionModel === undefined ? null : sessionModel,
|
|
1064
|
+
priceBook: config.models,
|
|
1065
|
+
peakNow: isPeakTime(now),
|
|
1066
|
+
calls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.length : 0,
|
|
1067
|
+
lastCalls: sessionUsage !== undefined && sessionUsage !== null && Array.isArray(sessionUsage.calls) ? sessionUsage.calls.slice(-3) : [],
|
|
1068
|
+
modelsAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.models,
|
|
1069
|
+
currentAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.current,
|
|
1070
|
+
lastAgg: sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.last,
|
|
1071
|
+
turnUsage: turnUsage,
|
|
1072
|
+
costText: segmentText("cost", segmentView("cost", debugSrc, t), t),
|
|
1073
|
+
lastCostText: segmentText("lastCost", segmentView("lastCost", debugSrc, t), t),
|
|
1074
|
+
sessionTime: segmentText("sessionTime", segmentView("sessionTime", debugSrc, t), t)
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const src = {
|
|
1078
|
+
stats: stats, usage: usage, pressure: pressure,
|
|
1079
|
+
sessionModel: sessionModel, sessionUsage: sessionUsage, timeRange: timeRange,
|
|
1080
|
+
running: running, partial: partial, runningCalls: runningCalls,
|
|
1081
|
+
lastAgentError: lastAgentError, pendingApprovals: pendingApprovals, timeline: timeline,
|
|
1082
|
+
now: now, balance: balance, turnUsage: turnUsage, currency: cfg.currency
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
const views = [];
|
|
1086
|
+
cfg.segments.filter((id) => isSegmentOn(cfg, id)).forEach((id) => {
|
|
1087
|
+
const view = segmentView(id, src, t);
|
|
1088
|
+
/* 保留 view 上的 state(状态点靠它上色),只把缺数据的段换成占位文本 */
|
|
1089
|
+
views.push(view !== null && view !== undefined
|
|
1090
|
+
? view
|
|
1091
|
+
: { id: id, text: segmentText(id, view, t) });
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const statusView = views.filter((v) => v.id === "status")[0];
|
|
1095
|
+
const dotClass = "dsb-dot"
|
|
1096
|
+
+ (statusView !== undefined && statusView.state === "running" ? " dsb-dot-running" : "")
|
|
1097
|
+
+ (statusView !== undefined && statusView.state === "error" ? " dsb-dot-error" : "")
|
|
1098
|
+
+ (statusView !== undefined && statusView.state === "approval" ? " dsb-dot-approval" : "");
|
|
1099
|
+
|
|
1100
|
+
const children = [h("span", { className: dotClass, key: "__dot" })];
|
|
1101
|
+
let lastShown = null;
|
|
1102
|
+
views.forEach((view) => {
|
|
1103
|
+
if (view.text === "") return;
|
|
1104
|
+
if (lastShown !== null) {
|
|
1105
|
+
children.push(h("span", { className: "dsb-sep", key: "__sep" + view.id }, "|"));
|
|
1106
|
+
}
|
|
1107
|
+
lastShown = view;
|
|
1108
|
+
children.push(h("span", {
|
|
1109
|
+
className: "dsb-seg", key: "__seg" + view.id,
|
|
1110
|
+
onMouseEnter: view.rows !== undefined && view.rows !== null ? () => setTipId(view.id) : undefined,
|
|
1111
|
+
onMouseLeave: view.rows !== undefined && view.rows !== null ? () => setTipId(null) : undefined
|
|
1112
|
+
}, view.text));
|
|
1113
|
+
});
|
|
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
|
+
return h("span", {
|
|
1129
|
+
className: cfg.wrap === true ? "dsb-root dsb-wrap" : "dsb-root",
|
|
1130
|
+
"data-dsb": "bar"
|
|
1131
|
+
}, children);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* -------------------------------------------------------------- 设置页 */
|
|
1135
|
+
function SettingsSection(props) {
|
|
1136
|
+
const t = props.t;
|
|
1137
|
+
const cfg = useConfig();
|
|
1138
|
+
|
|
1139
|
+
const [dragId, setDragId] = react.useState(null);
|
|
1140
|
+
const [overId, setOverId] = react.useState(null);
|
|
1141
|
+
const [overAfter, setOverAfter] = react.useState(false); /* 落点画在目标行的下半区 */
|
|
1142
|
+
const [newModel, setNewModel] = react.useState("");
|
|
1143
|
+
const [hostModel, setHostModel] = react.useState(null);
|
|
1144
|
+
const [editing, setEditing] = react.useState(null);
|
|
1145
|
+
const [draft, setDraft] = react.useState(null);
|
|
1146
|
+
|
|
1147
|
+
/* 当前会话模型:底栏会上报给 host,这里读取(设置页自己拿不到会话投影) */
|
|
1148
|
+
react.useEffect(() => {
|
|
1149
|
+
let alive = true;
|
|
1150
|
+
const pull = () => {
|
|
1151
|
+
window.fetch(ACTIVE_MODEL_URL, { cache: "no-store" })
|
|
1152
|
+
.then((response) => response.json())
|
|
1153
|
+
.then((data) => {
|
|
1154
|
+
if (alive !== true) return;
|
|
1155
|
+
const model = data !== null && data !== undefined && typeof data.model === "string" && data.model.length > 0 ? data.model : null;
|
|
1156
|
+
setHostModel(model);
|
|
1157
|
+
})
|
|
1158
|
+
.catch(() => { /* 拿不到就保持未识别 */ });
|
|
1159
|
+
};
|
|
1160
|
+
pull();
|
|
1161
|
+
const timer = window.setInterval(pull, 10000);
|
|
1162
|
+
return () => { alive = false; window.clearInterval(timer); };
|
|
1163
|
+
}, []);
|
|
1164
|
+
|
|
1165
|
+
/* ---- 价格库操作:先改草稿,点保存才写入配置 ---- */
|
|
1166
|
+
/* 顺序来自 modelOrder:新加的模型追加在末尾 */
|
|
1167
|
+
const modelOrder = Array.isArray(cfg.modelOrder) ? cfg.modelOrder : [];
|
|
1168
|
+
const modelNames = modelOrder.filter((name) => cfg.models[name] !== undefined)
|
|
1169
|
+
.concat(Object.keys(cfg.models).filter((name) => modelOrder.indexOf(name) === -1));
|
|
1170
|
+
function addModel() {
|
|
1171
|
+
const name = newModel.trim();
|
|
1172
|
+
if (name.length === 0 || cfg.models[name] !== undefined) return;
|
|
1173
|
+
const next = Object.assign({}, cfg.models);
|
|
1174
|
+
next[name] = { tiered: false, peak: Object.assign({}, EMPTY_TIER), offPeak: Object.assign({}, EMPTY_TIER) };
|
|
1175
|
+
setNewModel("");
|
|
1176
|
+
setConfig({
|
|
1177
|
+
models: next,
|
|
1178
|
+
modelOrder: (Array.isArray(cfg.modelOrder) ? cfg.modelOrder : Object.keys(cfg.models)).concat([name])
|
|
1179
|
+
});
|
|
1180
|
+
startEdit(name, next[name]); /* 新加的模型直接展开待填(默认单档) */
|
|
1181
|
+
}
|
|
1182
|
+
function removeModel(name) {
|
|
1183
|
+
const next = Object.assign({}, cfg.models);
|
|
1184
|
+
delete next[name];
|
|
1185
|
+
setConfig({
|
|
1186
|
+
models: next,
|
|
1187
|
+
modelOrder: (Array.isArray(cfg.modelOrder) ? cfg.modelOrder : Object.keys(cfg.models)).filter((x) => x !== name)
|
|
1188
|
+
});
|
|
1189
|
+
if (editing === name) cancelEdit();
|
|
1190
|
+
}
|
|
1191
|
+
/** 打开编辑:价格抄进草稿,数字转字符串,输入过程中不会被清零。 */
|
|
1192
|
+
function startEdit(name, preset) {
|
|
1193
|
+
const stored = preset === undefined ? cfg.models[name] : preset;
|
|
1194
|
+
const entry = normalizePrice(stored);
|
|
1195
|
+
const asText = (tier) => ({
|
|
1196
|
+
input: String(tier.input),
|
|
1197
|
+
cacheRead: String(tier.cacheRead),
|
|
1198
|
+
cacheWrite: String(tier.cacheWrite),
|
|
1199
|
+
output: String(tier.output)
|
|
1200
|
+
});
|
|
1201
|
+
setDraft({
|
|
1202
|
+
tiered: isTiered(stored),
|
|
1203
|
+
peak: asText(entry === null ? EMPTY_TIER : entry.peak),
|
|
1204
|
+
offPeak: asText(entry === null ? EMPTY_TIER : entry.offPeak)
|
|
1205
|
+
});
|
|
1206
|
+
setEditing(name);
|
|
1207
|
+
}
|
|
1208
|
+
/** 收起并丢弃草稿。 */
|
|
1209
|
+
function cancelEdit() {
|
|
1210
|
+
setEditing(null);
|
|
1211
|
+
setDraft(null);
|
|
1212
|
+
}
|
|
1213
|
+
function setDraftValue(tierKey, field, value) {
|
|
1214
|
+
setDraft((prev) => {
|
|
1215
|
+
if (prev === null) return prev;
|
|
1216
|
+
const next = { tiered: prev.tiered, peak: Object.assign({}, prev.peak), offPeak: Object.assign({}, prev.offPeak) };
|
|
1217
|
+
next[tierKey][field] = value;
|
|
1218
|
+
if (prev.tiered !== true) next.offPeak = Object.assign({}, next.peak); /* 全天同价:两档始终一致 */
|
|
1219
|
+
return next;
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
function setDraftTiered(on) {
|
|
1223
|
+
setDraft((prev) => {
|
|
1224
|
+
if (prev === null) return prev;
|
|
1225
|
+
return {
|
|
1226
|
+
tiered: on,
|
|
1227
|
+
peak: Object.assign({}, prev.peak),
|
|
1228
|
+
offPeak: on === true ? Object.assign({}, prev.offPeak) : Object.assign({}, prev.peak)
|
|
1229
|
+
};
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
function saveDraft() {
|
|
1233
|
+
if (editing === null || draft === null) return;
|
|
1234
|
+
const num = (value) => Number(value) || 0;
|
|
1235
|
+
const asNumber = (tier) => ({ input: num(tier.input), cacheRead: num(tier.cacheRead), cacheWrite: num(tier.cacheWrite), output: num(tier.output) });
|
|
1236
|
+
const next = Object.assign({}, cfg.models);
|
|
1237
|
+
next[editing] = {
|
|
1238
|
+
tiered: draft.tiered === true,
|
|
1239
|
+
peak: asNumber(draft.peak),
|
|
1240
|
+
offPeak: draft.tiered === true ? asNumber(draft.offPeak) : asNumber(draft.peak)
|
|
1241
|
+
};
|
|
1242
|
+
setConfig({ models: next, priceConfigured: true });
|
|
1243
|
+
setEditing(null);
|
|
1244
|
+
setDraft(null);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const sections = [];
|
|
1248
|
+
|
|
1249
|
+
sections.push(h("p", { className: "dsb-intro", key: "__intro" }, t("intro")));
|
|
1250
|
+
|
|
1251
|
+
/* 基础 */
|
|
1252
|
+
const basicRows = [];
|
|
1253
|
+
basicRows.push(h("div", { className: "dsb-row", key: "__enabled" },
|
|
1254
|
+
h("label", { className: "dsb-check" },
|
|
1255
|
+
h("input", {
|
|
1256
|
+
type: "checkbox",
|
|
1257
|
+
checked: cfg.enabled === true,
|
|
1258
|
+
onChange: (event) => setConfig({ enabled: event.target.checked })
|
|
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" },
|
|
1267
|
+
h("label", { className: "dsb-check" },
|
|
1268
|
+
h("input", {
|
|
1269
|
+
type: "checkbox",
|
|
1270
|
+
checked: cfg.wrap === true,
|
|
1271
|
+
onChange: (event) => setConfig({ wrap: event.target.checked })
|
|
1272
|
+
}),
|
|
1273
|
+
h("span", { className: "dsb-labels" },
|
|
1274
|
+
h("span", { className: "dsb-name" }, t("wrap")),
|
|
1275
|
+
h("span", { className: "dsb-desc" }, t("wrapHint"))
|
|
1276
|
+
)
|
|
1277
|
+
)
|
|
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);
|
|
1290
|
+
|
|
1291
|
+
const segmentRows = orderedSegments.map((segment) => {
|
|
1292
|
+
const on = isSegmentOn(cfg, segment.id);
|
|
1293
|
+
const classes = ["dsb-row"];
|
|
1294
|
+
if (dragId === segment.id) classes.push("dsb-dragging");
|
|
1295
|
+
if (overId === segment.id && dragId !== segment.id) {
|
|
1296
|
+
classes.push(overAfter === true ? "dsb-over-after" : "dsb-over-before");
|
|
1297
|
+
}
|
|
1298
|
+
return h("div", {
|
|
1299
|
+
className: classes.join(" "),
|
|
1300
|
+
key: segment.id,
|
|
1301
|
+
draggable: true, /* 未勾选的段也能调位置 */
|
|
1302
|
+
onDragStart: (event) => {
|
|
1303
|
+
setDragId(segment.id);
|
|
1304
|
+
try {
|
|
1305
|
+
event.dataTransfer.effectAllowed = "move";
|
|
1306
|
+
event.dataTransfer.setData("text/plain", segment.id);
|
|
1307
|
+
} catch (error) { /* 某些环境 dataTransfer 受限 */ }
|
|
1308
|
+
},
|
|
1309
|
+
onDragOver: (event) => {
|
|
1310
|
+
if (dragId === null || dragId === segment.id) return;
|
|
1311
|
+
event.preventDefault();
|
|
1312
|
+
try { event.dataTransfer.dropEffect = "move"; } catch (error) { /* ignore */ }
|
|
1313
|
+
/* 以目标行中线分前后;中线附近 4px 内保持原状态,避免来回闪 */
|
|
1314
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
1315
|
+
const middle = rect.top + rect.height / 2;
|
|
1316
|
+
if (Math.abs(event.clientY - middle) < 4) return;
|
|
1317
|
+
const after = event.clientY > middle;
|
|
1318
|
+
if (overId !== segment.id || overAfter !== after) {
|
|
1319
|
+
setOverId(segment.id);
|
|
1320
|
+
setOverAfter(after);
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
onDragLeave: () => {
|
|
1324
|
+
if (overId === segment.id) {
|
|
1325
|
+
setOverId(null);
|
|
1326
|
+
setOverAfter(false);
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
onDrop: (event) => {
|
|
1330
|
+
event.preventDefault();
|
|
1331
|
+
reorderSegments(dragId, segment.id, overId === segment.id && overAfter === true);
|
|
1332
|
+
setDragId(null);
|
|
1333
|
+
setOverId(null);
|
|
1334
|
+
setOverAfter(false);
|
|
1335
|
+
},
|
|
1336
|
+
onDragEnd: () => { setDragId(null); setOverId(null); setOverAfter(false); }
|
|
1337
|
+
},
|
|
1338
|
+
h("label", { className: "dsb-check" },
|
|
1339
|
+
h("input", {
|
|
1340
|
+
type: "checkbox",
|
|
1341
|
+
checked: on,
|
|
1342
|
+
onChange: (event) => toggleSegment(segment.id, event.target.checked)
|
|
1343
|
+
}),
|
|
1344
|
+
h("span", { className: "dsb-labels" },
|
|
1345
|
+
h("span", { className: "dsb-name" }, t(segment.label)),
|
|
1346
|
+
h("span", { className: "dsb-desc" }, t(segment.hint))
|
|
1347
|
+
)
|
|
1348
|
+
),
|
|
1349
|
+
h("span", {
|
|
1350
|
+
className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
|
|
1351
|
+
title: on === true ? t("dragHint") : ""
|
|
1352
|
+
})
|
|
1353
|
+
);
|
|
1354
|
+
});
|
|
1355
|
+
sections.push(h("div", { className: "dsb-section", key: "__segments" },
|
|
1356
|
+
h("div", { className: "dsb-sectitle" }, t("secSegments")),
|
|
1357
|
+
h("p", { className: "dsb-hint" }, t("secSegmentsHint")),
|
|
1358
|
+
segmentRows
|
|
1359
|
+
));
|
|
1360
|
+
|
|
1361
|
+
/* 自定义模型价格 */
|
|
1362
|
+
const priceCards = [];
|
|
1363
|
+
priceCards.push(h("div", { className: "dsb-hint", key: "__pricehint" }, t("secPricesHint")));
|
|
1364
|
+
/* "当前会话使用"提示:设置页是否能拿到投影由宿主决定,拿不到就显示占位 */
|
|
1365
|
+
const settingsModel = typeof props.useProjection === "function" ? props.useProjection("desktopStatusbarModel") : undefined;
|
|
1366
|
+
const currentModelName = settingsModel !== null && settingsModel !== undefined && typeof settingsModel.model === "string"
|
|
1367
|
+
? settingsModel.model
|
|
1368
|
+
: null;
|
|
1369
|
+
const shownModel = currentModelName !== null ? currentModelName : hostModel;
|
|
1370
|
+
priceCards.push(h("div", { className: "dsb-current", key: "__current" },
|
|
1371
|
+
t("priceCurrent") + " ",
|
|
1372
|
+
h("b", null, shownModel === null ? t("modelUnknown") : displayModelName(shownModel))
|
|
1373
|
+
)); if (cfg.priceConfigured !== true) {
|
|
1374
|
+
priceCards.push(h("div", { className: "dsb-hint", key: "__pricesuggest" }, t("priceSuggestedHint")));
|
|
1375
|
+
}
|
|
1376
|
+
/* 新增模型:夹在说明与模型列表之间 */
|
|
1377
|
+
priceCards.push(h("div", { className: "dsb-addrow", key: "__add" },
|
|
1378
|
+
h("input", {
|
|
1379
|
+
type: "text",
|
|
1380
|
+
placeholder: t("priceNewModel"),
|
|
1381
|
+
value: newModel,
|
|
1382
|
+
onChange: (event) => setNewModel(event.target.value),
|
|
1383
|
+
onKeyDown: (event) => { if (event.key === "Enter") addModel(); }
|
|
1384
|
+
}),
|
|
1385
|
+
h("button", { type: "button", className: "dsb-action", onClick: addModel }, t("priceAdd"))
|
|
1386
|
+
));
|
|
1387
|
+
if (modelNames.length === 0) {
|
|
1388
|
+
priceCards.push(h("div", { className: "dsb-hint", key: "__empty" }, t("priceEmpty")));
|
|
1389
|
+
}
|
|
1390
|
+
const modelRows = [];
|
|
1391
|
+
modelNames.forEach((name) => {
|
|
1392
|
+
const open = editing === name && draft !== null;
|
|
1393
|
+
const priceField = (tierKey, label, field) => h("label", { className: "dsb-field", key: tierKey + field },
|
|
1394
|
+
h("span", null, label),
|
|
1395
|
+
h("input", {
|
|
1396
|
+
type: "number",
|
|
1397
|
+
step: "0.01",
|
|
1398
|
+
min: "0",
|
|
1399
|
+
value: draft[tierKey][field],
|
|
1400
|
+
onChange: (event) => setDraftValue(tierKey, field, event.target.value)
|
|
1401
|
+
})
|
|
1402
|
+
);
|
|
1403
|
+
const tierRow = (tierKey, label) => h("div", { className: "dsb-tier", key: tierKey },
|
|
1404
|
+
label === null ? null : h("span", { className: "dsb-tier-name" }, label),
|
|
1405
|
+
h("div", { className: "dsb-fields" },
|
|
1406
|
+
priceField(tierKey, t("priceCacheRead"), "cacheRead"),
|
|
1407
|
+
priceField(tierKey, t("priceInput"), "input"),
|
|
1408
|
+
priceField(tierKey, t("priceOutput"), "output")
|
|
1409
|
+
)
|
|
1410
|
+
);
|
|
1411
|
+
const action = (label, key, onClick) => h("button", { type: "button", key: key, className: "dsb-action dsb-action-sm", onClick: onClick }, label);
|
|
1412
|
+
const head = h("div", { className: "dsb-model-head", key: "__head" },
|
|
1413
|
+
h("span", { className: "dsb-model-name" }, displayModelName(name)),
|
|
1414
|
+
h("div", { className: "dsb-model-actions" },
|
|
1415
|
+
open === true ? action(t("priceSave"), "__save", saveDraft) : action(t("priceEdit"), "__edit", () => startEdit(name)),
|
|
1416
|
+
action(t("priceRemove"), "__remove", () => removeModel(name))
|
|
1417
|
+
)
|
|
1418
|
+
);
|
|
1419
|
+
const body = open === true
|
|
1420
|
+
? [
|
|
1421
|
+
h("label", { className: "dsb-inline-check", key: "__tiered" },
|
|
1422
|
+
h("input", {
|
|
1423
|
+
type: "checkbox",
|
|
1424
|
+
checked: draft.tiered === true,
|
|
1425
|
+
onChange: (event) => setDraftTiered(event.target.checked)
|
|
1426
|
+
}),
|
|
1427
|
+
h("span", null, t("priceTiered"))
|
|
1428
|
+
),
|
|
1429
|
+
draft.tiered === true ? tierRow("offPeak", t("priceTierOffPeak")) : tierRow("peak", null),
|
|
1430
|
+
draft.tiered === true ? tierRow("peak", t("priceTierPeak")) : null
|
|
1431
|
+
]
|
|
1432
|
+
: [];
|
|
1433
|
+
modelRows.push(h("div", { className: "dsb-model", key: name }, head, body));
|
|
1434
|
+
});
|
|
1435
|
+
if (modelRows.length > 0) {
|
|
1436
|
+
priceCards.push(h("div", { className: "dsb-models", key: "__models" }, modelRows));
|
|
1437
|
+
}
|
|
1438
|
+
sections.push(h("div", { className: "dsb-section", key: "__prices" },
|
|
1439
|
+
h("div", { className: "dsb-sectitle" }, t("secPrices")),
|
|
1440
|
+
h("div", { className: "dsb-card" }, priceCards)
|
|
1441
|
+
));
|
|
1442
|
+
|
|
1443
|
+
/* 收尾 */
|
|
1444
|
+
sections.push(h("div", { className: "dsb-foot", key: "__foot" },
|
|
1445
|
+
h("button", {
|
|
1446
|
+
type: "button",
|
|
1447
|
+
className: "dsb-action",
|
|
1448
|
+
onClick: () => setConfig({
|
|
1449
|
+
enabled: true,
|
|
1450
|
+
wrap: true,
|
|
1451
|
+
segments: DEFAULT_SEGMENTS.slice(),
|
|
1452
|
+
hidden: [],
|
|
1453
|
+
currency: "CNY",
|
|
1454
|
+
models: Object.assign({}, DEFAULT_PRICES),
|
|
1455
|
+
modelOrder: Object.keys(DEFAULT_PRICES)
|
|
1456
|
+
})
|
|
1457
|
+
}, t("reset"))
|
|
1458
|
+
));
|
|
1459
|
+
|
|
1460
|
+
return h("div", { className: "dsb-settings" }, sections);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/* --------------------------------------------------------------- apply */
|
|
1464
|
+
function apply(ctx) {
|
|
1465
|
+
ctx.effect(installStyles, "dsh-desktop-statusbar: styles");
|
|
1466
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: zh, en: en }), "dsh-desktop-statusbar: locale");
|
|
1467
|
+
ctx.effect(() => installNavIcon(() => ctx.locale.bind(NS)("nav")), "dsh-desktop-statusbar: nav icon");
|
|
1468
|
+
|
|
1469
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
1470
|
+
name: "conversation.composer.dock",
|
|
1471
|
+
id: "mini-bar",
|
|
1472
|
+
order: 0,
|
|
1473
|
+
locale: NS
|
|
1474
|
+
}, StatusBar));
|
|
1475
|
+
|
|
1476
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1477
|
+
name: "settings.section",
|
|
1478
|
+
id: "dsh-desktop-statusbar",
|
|
1479
|
+
order: 40,
|
|
1480
|
+
label: () => ctx.locale.bind(NS)("nav"),
|
|
1481
|
+
locale: NS
|
|
1482
|
+
}, SettingsSection));
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const inject = ["slots", "locale"];
|
|
1486
|
+
|
|
1487
|
+
exports.apply = apply;
|
|
1488
|
+
exports.inject = inject;
|
|
1489
|
+
return module.exports;
|
|
1490
|
+
}
|
|
1491
|
+
});
|