dsh-usage-spend 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/README.zh.md +94 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +4 -0
- package/lib/client.js +1351 -0
- package/lib/index.js +20 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +72 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-cost — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Contributes:
|
|
5
|
+
* - `conversation.composer.dock` id `usage` at priority -1, replacing the
|
|
6
|
+
* built-in token-usage pill with one that also shows estimated spend and
|
|
7
|
+
* keeps the cache-hit reading. Its dialog puts the amount right after the
|
|
8
|
+
* exact token count.
|
|
9
|
+
* - `conversation.chat.turnTail` id `turn-usage-cost`: a hidden anchor that
|
|
10
|
+
* rewrites the built-in per-turn "用量 N tok" action label to
|
|
11
|
+
* "N tok · ≈¥x.xx" (the line itself is gone).
|
|
12
|
+
* - `plugins.row.config` for this row, so the Plugins page exposes the
|
|
13
|
+
* currency and exchange-rate settings.
|
|
14
|
+
*
|
|
15
|
+
* Pricing is a local CNY / 1M token table taken from DeepSeek's official card,
|
|
16
|
+
* with the peak-hours multiplier (Beijing time, weekdays, holiday calendar) and
|
|
17
|
+
* an optional localStorage override. No network request is made for pricing.
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: "dsh-usage-cost",
|
|
21
|
+
factory: (require) => {
|
|
22
|
+
var module = { exports: {} };
|
|
23
|
+
var exports = module.exports;
|
|
24
|
+
|
|
25
|
+
const React = require("react");
|
|
26
|
+
const ReactDOM = require("react-dom");
|
|
27
|
+
const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
|
|
30
|
+
const NS = "dsh-usage-cost";
|
|
31
|
+
const STYLE_ID = "dsh-usage-cost/styles";
|
|
32
|
+
const ENTRY_ID = "usage-cost";
|
|
33
|
+
const CONFIG_KEY = "dsh-usage-spend#usage-cost";
|
|
34
|
+
const CURRENCY_KEY = "dsh.cost.currency";
|
|
35
|
+
const RATE_KEY = "dsh.cost.usdToCny";
|
|
36
|
+
const PRICES_KEY = "dsh.cost.prices";
|
|
37
|
+
const DEFAULT_RATE = 7.2;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------- locale
|
|
40
|
+
const zh = {
|
|
41
|
+
"pill.tokens": "{count} tok",
|
|
42
|
+
"pill.cacheHit": "缓存命中 {percent}%",
|
|
43
|
+
"pill.peak": "峰",
|
|
44
|
+
"pill.offPeak": "谷",
|
|
45
|
+
"dialog.title": "Token 用量",
|
|
46
|
+
"dialog.cacheHit": "缓存命中",
|
|
47
|
+
"dialog.input": "未缓存输入",
|
|
48
|
+
"dialog.cacheRead": "缓存读取",
|
|
49
|
+
"dialog.cacheWrite": "缓存写入",
|
|
50
|
+
"dialog.output": "输出",
|
|
51
|
+
"dialog.window": "计费时段",
|
|
52
|
+
"dialog.windowPeak": "高峰(×2)",
|
|
53
|
+
"dialog.windowOffPeak": "空闲",
|
|
54
|
+
"currency.cny": "¥ 人民币",
|
|
55
|
+
"currency.usd": "$ 美元",
|
|
56
|
+
"currency.both": "¥+$",
|
|
57
|
+
"config.summary": "在会话底部显示 token 用量与估算花费,可设置币种和汇率。",
|
|
58
|
+
"config.title": "用量与花费",
|
|
59
|
+
"config.currency": "显示币种",
|
|
60
|
+
"config.rate": "汇率:1 USD = ? CNY",
|
|
61
|
+
"config.rateHint": "只用于换算美元金额;人民币按官方价目直接计价。",
|
|
62
|
+
"config.ratesTitle": "单价(元 / 1M tok,空闲时段)",
|
|
63
|
+
"config.ratesNote": "官方价目,仅供估算。高峰时段为表中价格的两倍:北京时间周一至周五 9:00–12:00、14:00–18:00,法定节假日除外;其余时段(含周末与节假日全天)为空闲时段。",
|
|
64
|
+
"config.model": "模型",
|
|
65
|
+
"config.showBalance": "显示左侧栏余额",
|
|
66
|
+
"config.showBalanceHint": "关闭后不显示余额 chip,也不会请求账号余额。",
|
|
67
|
+
"balance.label": "余额",
|
|
68
|
+
"balance.loading": "读取中…",
|
|
69
|
+
"balance.unavailable": "暂不可用",
|
|
70
|
+
"balance.signedOut": "未登录",
|
|
71
|
+
"balance.refresh": "点击刷新余额",
|
|
72
|
+
"balance.tooltip": "充值余额 {wallets}",
|
|
73
|
+
"balance.bonus": "赠金 {wallets}",
|
|
74
|
+
"config.save": "保存",
|
|
75
|
+
"config.saving": "保存中…",
|
|
76
|
+
"config.saved": "已保存",
|
|
77
|
+
"config.failed": "保存失败,请检查取值后重试。",
|
|
78
|
+
"config.reset": "恢复默认",
|
|
79
|
+
"config.readOnly": "当前部署的设置为只读。",
|
|
80
|
+
"config.unavailable": "配置表单不可用(设置服务未加载)。",
|
|
81
|
+
"config.invalidRate": "汇率需为 1–100 之间的数字。",
|
|
82
|
+
};
|
|
83
|
+
const en = {
|
|
84
|
+
"pill.tokens": "{count} tok",
|
|
85
|
+
"pill.cacheHit": "cache hit {percent}%",
|
|
86
|
+
"pill.peak": "peak",
|
|
87
|
+
"pill.offPeak": "off-peak",
|
|
88
|
+
"dialog.title": "Token usage",
|
|
89
|
+
"dialog.cacheHit": "Cache hit",
|
|
90
|
+
"dialog.input": "Uncached input",
|
|
91
|
+
"dialog.cacheRead": "Cache read",
|
|
92
|
+
"dialog.cacheWrite": "Cache write",
|
|
93
|
+
"dialog.output": "Output",
|
|
94
|
+
"dialog.window": "Billing window",
|
|
95
|
+
"dialog.windowPeak": "Peak (×2)",
|
|
96
|
+
"dialog.windowOffPeak": "Off-peak",
|
|
97
|
+
"currency.cny": "¥ CNY",
|
|
98
|
+
"currency.usd": "$ USD",
|
|
99
|
+
"currency.both": "¥+$",
|
|
100
|
+
"config.summary": "Shows token usage and estimated spend under the composer, with currency and exchange-rate settings.",
|
|
101
|
+
"config.title": "Usage and spend",
|
|
102
|
+
"config.currency": "Display currency",
|
|
103
|
+
"config.rate": "Exchange rate: 1 USD = ? CNY",
|
|
104
|
+
"config.rateHint": "Only converts the USD figure; CNY comes straight from the official card.",
|
|
105
|
+
"config.ratesTitle": "Rates (CNY / 1M tok, off-peak)",
|
|
106
|
+
"config.ratesNote": "Official card, estimates only. Peak hours cost twice these rates: Beijing time Mon–Fri 09:00–12:00 and 14:00–18:00, excluding Chinese public holidays; everything else, weekends and holidays included, is off-peak.",
|
|
107
|
+
"config.model": "Model",
|
|
108
|
+
"config.showBalance": "Show the sidebar balance",
|
|
109
|
+
"config.showBalanceHint": "When off, the balance chip is hidden and no balance request is made.",
|
|
110
|
+
"balance.label": "Balance",
|
|
111
|
+
"balance.loading": "Loading…",
|
|
112
|
+
"balance.unavailable": "Unavailable",
|
|
113
|
+
"balance.signedOut": "Not signed in",
|
|
114
|
+
"balance.refresh": "Click to refresh the balance",
|
|
115
|
+
"balance.tooltip": "Credit balance {wallets}",
|
|
116
|
+
"balance.bonus": "Bonus {wallets}",
|
|
117
|
+
"config.save": "Save",
|
|
118
|
+
"config.saving": "Saving…",
|
|
119
|
+
"config.saved": "Saved",
|
|
120
|
+
"config.failed": "The values were not accepted; check them and retry.",
|
|
121
|
+
"config.reset": "Reset to default",
|
|
122
|
+
"config.readOnly": "This deployment stores settings read-only.",
|
|
123
|
+
"config.unavailable": "The configuration form is unavailable (settings service not loaded).",
|
|
124
|
+
"config.invalidRate": "Enter an exchange rate between 1 and 100.",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ----------------------------------------------------------------- styles
|
|
128
|
+
const CSS = [
|
|
129
|
+
".dshuc_anchor{min-width:0;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));display:inline-flex}",
|
|
130
|
+
".dshuc_pill{box-sizing:border-box;border-radius:var(--dsw-radius-sm);max-width:100%;color:var(--dsw-alias-label-tertiary);font:inherit;font-variant-numeric:tabular-nums;line-height:inherit;white-space:nowrap;background:0 0;border:none;align-items:center;gap:6px;padding:1px 8px;display:inline-flex}",
|
|
131
|
+
".dshuc_pill svg{flex:none;width:14px;height:14px}",
|
|
132
|
+
"button.dshuc_pill{cursor:pointer}",
|
|
133
|
+
"button.dshuc_pill[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
|
|
134
|
+
"@media (hover:hover){button.dshuc_pill:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}",
|
|
135
|
+
".dshuc_label{text-overflow:ellipsis;min-width:0;overflow:hidden}",
|
|
136
|
+
".dshuc_sep{color:var(--dsw-alias-separator-primary);margin:0 6px}",
|
|
137
|
+
".dshuc_money{color:var(--dsw-alias-label-secondary)}",
|
|
138
|
+
".dshuc_window{color:var(--dsw-alias-label-tertiary);border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-xs,4px);padding:0 3px;margin-left:4px;font-size:10px;line-height:14px}",
|
|
139
|
+
".dshuc_panel{z-index:1100;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100vw - 24px),var(--dsh-anchored-max-width,100vw));max-width:min(440px,calc(100vw - 24px),var(--dsh-anchored-max-width,100vw));border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;max-height:var(--dsh-anchored-max-height,none);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;padding:16px;font-size:12px;line-height:18px;position:fixed;overflow:hidden auto}",
|
|
140
|
+
".dshuc_title{color:var(--dsw-alias-label-primary);justify-content:space-between;gap:16px;margin-bottom:8px;font-weight:500;display:flex}",
|
|
141
|
+
".dshuc_titleRule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:10px}",
|
|
142
|
+
".dshuc_titleValue{font-variant-numeric:tabular-nums;white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis}",
|
|
143
|
+
".dshuc_titleLabel{align-items:center;gap:6px;min-width:0;display:inline-flex}",
|
|
144
|
+
".dshuc_titleLabel svg{flex:none;width:14px;height:14px}",
|
|
145
|
+
".dshuc_details{color:var(--dsw-alias-label-tertiary);grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;display:grid}",
|
|
146
|
+
".dshuc_details dt,.dshuc_details dd{min-width:0;margin:0}",
|
|
147
|
+
".dshuc_details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}",
|
|
148
|
+
".dshuc_cfg{display:flex;flex-direction:column;gap:14px;max-width:560px}",
|
|
149
|
+
".dshuc_cfgTitle{color:var(--dsw-alias-label-primary);font-weight:500;font-size:13px;line-height:20px}",
|
|
150
|
+
".dshuc_field{display:flex;flex-direction:column;gap:4px}",
|
|
151
|
+
".dshuc_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}",
|
|
152
|
+
".dshuc_toggleRow{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer}",
|
|
153
|
+
".dshuc_input{box-sizing:border-box;width:100%;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-l1,transparent);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 8px;outline:0}",
|
|
154
|
+
".dshuc_input:focus{border-color:var(--dsw-alias-label-accent,#6aa9ff)}",
|
|
155
|
+
".dshuc_hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
|
|
156
|
+
".dshuc_actions{display:flex;align-items:center;gap:8px}",
|
|
157
|
+
".dshuc_status{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
|
|
158
|
+
".dshuc_status[data-tone=error]{color:var(--dsw-alias-state-error-primary,#f2a1a1)}",
|
|
159
|
+
".dshuc_cfgNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;margin:0}",
|
|
160
|
+
".dshuc_table{border-collapse:collapse;width:100%;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}",
|
|
161
|
+
".dshuc_table th{color:var(--dsw-alias-label-tertiary);font-weight:400;text-align:right;padding:4px 6px;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
|
|
162
|
+
".dshuc_table th:first-child{text-align:left}",
|
|
163
|
+
".dshuc_table td{color:var(--dsw-alias-label-secondary);text-align:right;padding:4px 6px;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
|
|
164
|
+
".dshuc_table td:first-child{text-align:left;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
|
|
165
|
+
".dshuc_table tr:last-child td{border-bottom:0}",
|
|
166
|
+
".dshuc_balance{box-sizing:border-box;display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;border:0;border-radius:var(--dsw-radius-md);background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer;overflow:hidden}",
|
|
167
|
+
"@media (hover:hover){.dshuc_balance:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}",
|
|
168
|
+
".dshuc_balanceGlyph{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-bg-skeleton);font-size:10px;line-height:16px}",
|
|
169
|
+
".dshuc_balanceLabel{text-overflow:ellipsis;overflow:hidden;min-width:0}",
|
|
170
|
+
".dshuc_balanceValue{margin-left:auto;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis}",
|
|
171
|
+
".dshuc_balance[data-wide=false]{width:36px;height:36px;justify-content:center;padding:0;gap:0}",
|
|
172
|
+
".dshuc_balance[data-wide=false] .dshuc_balanceLabel,.dshuc_balance[data-wide=false] .dshuc_balanceGlyph{display:none}",
|
|
173
|
+
".dshuc_balance[data-wide=false] .dshuc_balanceValue{margin:0;font-size:10px}",
|
|
174
|
+
"[class*='footerActions']:has(.dshuc_balance){flex-wrap:wrap}",
|
|
175
|
+
].join("");
|
|
176
|
+
|
|
177
|
+
function injectStyles() {
|
|
178
|
+
if (typeof document === "undefined") return;
|
|
179
|
+
if (document.querySelector('style[data-plugin-css="' + STYLE_ID + '"]') !== null) return;
|
|
180
|
+
const tag = document.createElement("style");
|
|
181
|
+
tag.dataset.plugin = "dsh-usage-cost";
|
|
182
|
+
tag.dataset.pluginCss = STYLE_ID;
|
|
183
|
+
tag.textContent = CSS;
|
|
184
|
+
document.head.appendChild(tag);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------- storage
|
|
188
|
+
function readStorage(key) {
|
|
189
|
+
try {
|
|
190
|
+
return window.localStorage.getItem(key);
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function writeStorage(key, value) {
|
|
196
|
+
try {
|
|
197
|
+
window.localStorage.setItem(key, value);
|
|
198
|
+
} catch {
|
|
199
|
+
/* storage may be unavailable */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function readCurrency() {
|
|
203
|
+
const value = readStorage(CURRENCY_KEY);
|
|
204
|
+
return value === "USD" || value === "BOTH" || value === "CNY" ? value : "CNY";
|
|
205
|
+
}
|
|
206
|
+
function readRate() {
|
|
207
|
+
const raw = Number(readStorage(RATE_KEY));
|
|
208
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_RATE;
|
|
209
|
+
}
|
|
210
|
+
function sanitizeCurrency(value) {
|
|
211
|
+
return value === "USD" || value === "BOTH" ? value : "CNY";
|
|
212
|
+
}
|
|
213
|
+
function sanitizeRate(value) {
|
|
214
|
+
const rate = Number(value);
|
|
215
|
+
return Number.isFinite(rate) && rate > 0 ? rate : DEFAULT_RATE;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------ config store
|
|
219
|
+
/**
|
|
220
|
+
* Live values from the Host settings form, with localStorage as the fallback
|
|
221
|
+
* until the settings mirror answers (or when the service is not composed).
|
|
222
|
+
*/
|
|
223
|
+
function createConfigStore(entryId, fallback) {
|
|
224
|
+
let form = null;
|
|
225
|
+
let off = null;
|
|
226
|
+
let current = null;
|
|
227
|
+
const listeners = new Set();
|
|
228
|
+
|
|
229
|
+
function build() {
|
|
230
|
+
let value = null;
|
|
231
|
+
let writable = false;
|
|
232
|
+
let ready = false;
|
|
233
|
+
if (form !== null) {
|
|
234
|
+
try {
|
|
235
|
+
const snapshot = form.getSnapshot();
|
|
236
|
+
if (snapshot !== undefined && snapshot.status === "ready" && snapshot.value !== undefined) {
|
|
237
|
+
value = snapshot.value;
|
|
238
|
+
writable = snapshot.writable !== false;
|
|
239
|
+
ready = true;
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
/* keep the fallback */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const base = fallback();
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
currency: sanitizeCurrency(value?.currency ?? base.currency),
|
|
248
|
+
usdToCny: sanitizeRate(value?.usdToCny ?? base.usdToCny),
|
|
249
|
+
showBalance: value?.showBalance !== false,
|
|
250
|
+
ready,
|
|
251
|
+
writable,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function refresh() {
|
|
255
|
+
current = build();
|
|
256
|
+
for (const listener of listeners) {
|
|
257
|
+
try {
|
|
258
|
+
listener();
|
|
259
|
+
} catch {
|
|
260
|
+
/* a listener must not break the store */
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
current = build();
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
get: () => current,
|
|
268
|
+
subscribe(listener) {
|
|
269
|
+
listeners.add(listener);
|
|
270
|
+
return () => {
|
|
271
|
+
listeners.delete(listener);
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
attach(next) {
|
|
275
|
+
if (off !== null) {
|
|
276
|
+
try {
|
|
277
|
+
off();
|
|
278
|
+
} catch {
|
|
279
|
+
/* the previous form may already be disposed */
|
|
280
|
+
}
|
|
281
|
+
off = null;
|
|
282
|
+
}
|
|
283
|
+
form = next;
|
|
284
|
+
if (next !== null && typeof next.subscribe === "function") off = next.subscribe(refresh);
|
|
285
|
+
refresh();
|
|
286
|
+
return () => {
|
|
287
|
+
if (form === next) form = null;
|
|
288
|
+
if (off !== null) {
|
|
289
|
+
off();
|
|
290
|
+
off = null;
|
|
291
|
+
}
|
|
292
|
+
refresh();
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
async setCurrency(value) {
|
|
296
|
+
const next = sanitizeCurrency(value);
|
|
297
|
+
if (form !== null && current.ready && current.writable) {
|
|
298
|
+
try {
|
|
299
|
+
if (await form.set("currency", next)) {
|
|
300
|
+
refresh();
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
/* fall through to the local override */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
writeStorage(CURRENCY_KEY, next);
|
|
308
|
+
refresh();
|
|
309
|
+
return true;
|
|
310
|
+
},
|
|
311
|
+
async setRate(value) {
|
|
312
|
+
const next = sanitizeRate(value);
|
|
313
|
+
if (form !== null && current.ready && current.writable) {
|
|
314
|
+
try {
|
|
315
|
+
if (await form.set("usdToCny", next)) {
|
|
316
|
+
refresh();
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
/* fall through to the local override */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
writeStorage(RATE_KEY, String(next));
|
|
324
|
+
refresh();
|
|
325
|
+
return true;
|
|
326
|
+
},
|
|
327
|
+
entryId,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const configStore = createConfigStore(ENTRY_ID, () => ({ currency: readCurrency(), usdToCny: readRate() }));
|
|
332
|
+
function useConfig() {
|
|
333
|
+
return React.useSyncExternalStore(configStore.subscribe, configStore.get, configStore.get);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function attachConfig(ctx) {
|
|
337
|
+
if (typeof ctx.inject !== "function") return;
|
|
338
|
+
try {
|
|
339
|
+
ctx.inject(["configForms"], (scope) => {
|
|
340
|
+
let forms = scope.configForms;
|
|
341
|
+
if (forms === undefined && typeof scope.get === "function") forms = scope.get("configForms");
|
|
342
|
+
if (forms === undefined || typeof forms.get !== "function") return;
|
|
343
|
+
const detach = configStore.attach(forms.get(ENTRY_ID));
|
|
344
|
+
if (typeof scope.effect === "function") {
|
|
345
|
+
scope.effect(() => detach, "dsh-usage-cost: config form");
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
} catch {
|
|
349
|
+
/* the fallback store keeps working */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ----------------------------------------------------------------- prices
|
|
354
|
+
/**
|
|
355
|
+
* CNY per 1M tokens, off-peak rates from the official card
|
|
356
|
+
* (api-docs.deepseek.com/zh-cn/quick_start/pricing, checked 2026-11-07).
|
|
357
|
+
* Peak hours cost twice these rates. Cache writes bill at the cache-miss
|
|
358
|
+
* input price, which is what DeepSeek charges for them.
|
|
359
|
+
*/
|
|
360
|
+
const MODEL_PRICES = {
|
|
361
|
+
"deepseek-flash": { input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 },
|
|
362
|
+
// Retired names: still accepted by the API, served and billed as Flash.
|
|
363
|
+
"deepseek-v4-flash": { input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 },
|
|
364
|
+
"deepseek-v4-flash-vision-exp": { input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 },
|
|
365
|
+
"deepseek-v4-pro": { input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 },
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// ------------------------------------------------------------ peak hours
|
|
369
|
+
/**
|
|
370
|
+
* Peak = Beijing time, Monday to Friday, 09:00–12:00 and 14:00–18:00,
|
|
371
|
+
* excluding Chinese public holidays. Peak costs twice the off-peak rate.
|
|
372
|
+
* Everything else — nights, weekends, whole holiday days — is off-peak.
|
|
373
|
+
*/
|
|
374
|
+
const PEAK_FACTOR = 2;
|
|
375
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
376
|
+
const PEAK_HOURS = [
|
|
377
|
+
[9, 12],
|
|
378
|
+
[14, 18],
|
|
379
|
+
];
|
|
380
|
+
/** 国务院办公厅 2026 年放假安排;次年公布后追加即可。 */
|
|
381
|
+
const PUBLIC_HOLIDAY_RANGES = [
|
|
382
|
+
["2026-01-01", "2026-01-03"], // 元旦
|
|
383
|
+
["2026-02-15", "2026-02-23"], // 春节
|
|
384
|
+
["2026-04-04", "2026-04-06"], // 清明
|
|
385
|
+
["2026-05-01", "2026-05-05"], // 劳动节
|
|
386
|
+
["2026-06-19", "2026-06-21"], // 端午
|
|
387
|
+
["2026-09-25", "2026-09-27"], // 中秋
|
|
388
|
+
["2026-10-01", "2026-10-07"], // 国庆
|
|
389
|
+
];
|
|
390
|
+
const PUBLIC_HOLIDAYS = (() => {
|
|
391
|
+
const days = new Set();
|
|
392
|
+
for (const [from, to] of PUBLIC_HOLIDAY_RANGES) {
|
|
393
|
+
const last = Date.parse(to + "T00:00:00Z");
|
|
394
|
+
for (let at = Date.parse(from + "T00:00:00Z"); at <= last; at += 86_400_000) {
|
|
395
|
+
days.add(new Date(at).toISOString().slice(0, 10));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return days;
|
|
399
|
+
})();
|
|
400
|
+
|
|
401
|
+
/** Beijing wall-clock parts for an instant, independent of the host timezone. */
|
|
402
|
+
function beijingParts(at) {
|
|
403
|
+
const shifted = new Date(Number(at) + BEIJING_OFFSET_MS);
|
|
404
|
+
return {
|
|
405
|
+
date: shifted.toISOString().slice(0, 10),
|
|
406
|
+
weekday: shifted.getUTCDay(),
|
|
407
|
+
hour: shifted.getUTCHours() + shifted.getUTCMinutes() / 60,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** True while DeepSeek bills this instant at the peak rate. */
|
|
411
|
+
function isPeak(at) {
|
|
412
|
+
const ms = Number(at);
|
|
413
|
+
if (!Number.isFinite(ms)) return false;
|
|
414
|
+
const { date, weekday, hour } = beijingParts(ms);
|
|
415
|
+
if (weekday === 0 || weekday === 6) return false;
|
|
416
|
+
if (PUBLIC_HOLIDAYS.has(date)) return false;
|
|
417
|
+
return PEAK_HOURS.some(([from, to]) => hour >= from && hour < to);
|
|
418
|
+
}
|
|
419
|
+
/** Multiplier applied to the off-peak card. */
|
|
420
|
+
function peakFactor(at) {
|
|
421
|
+
return isPeak(at) ? PEAK_FACTOR : 1;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Turn-end time for each per-turn usage object. The object is owned by the
|
|
426
|
+
* chat snapshot, so keying by it keeps the selector's identity stable
|
|
427
|
+
* while still telling the render which billing window to use.
|
|
428
|
+
*/
|
|
429
|
+
const TURN_TIMES = new WeakMap();
|
|
430
|
+
|
|
431
|
+
const PRICE_OVERRIDES = (() => {
|
|
432
|
+
const raw = readStorage(PRICES_KEY);
|
|
433
|
+
if (raw === null) return {};
|
|
434
|
+
try {
|
|
435
|
+
const parsed = JSON.parse(raw);
|
|
436
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
437
|
+
} catch {
|
|
438
|
+
return {};
|
|
439
|
+
}
|
|
440
|
+
})();
|
|
441
|
+
|
|
442
|
+
function normalizeRates(value) {
|
|
443
|
+
if (value === null || typeof value !== "object") return null;
|
|
444
|
+
const input = Number(value.input);
|
|
445
|
+
const output = Number(value.output);
|
|
446
|
+
const cacheRead = Number(value.cacheRead);
|
|
447
|
+
const cacheWrite = Number(value.cacheWrite);
|
|
448
|
+
if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) return null;
|
|
449
|
+
return {
|
|
450
|
+
input,
|
|
451
|
+
output,
|
|
452
|
+
cacheRead: Number.isFinite(cacheRead) && cacheRead >= 0 ? cacheRead : input,
|
|
453
|
+
cacheWrite: Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : input,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function priceFor(route) {
|
|
458
|
+
if (route === null || route === undefined || typeof route.model !== "string" || route.model === "") return null;
|
|
459
|
+
const model = route.model;
|
|
460
|
+
const bare = model.slice(model.lastIndexOf("/") + 1);
|
|
461
|
+
const candidates = [model, route.provider + "/" + model, bare];
|
|
462
|
+
for (const key of candidates) {
|
|
463
|
+
const override = PRICE_OVERRIDES[key];
|
|
464
|
+
if (override !== undefined) {
|
|
465
|
+
const rates = normalizeRates(override);
|
|
466
|
+
if (rates !== null) return { rates, custom: true };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const table = MODEL_PRICES[bare] ?? MODEL_PRICES[model];
|
|
470
|
+
if (table === undefined) return null;
|
|
471
|
+
const rates = normalizeRates(table);
|
|
472
|
+
return rates === null ? null : { rates, custom: false };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ------------------------------------------------------------------ money
|
|
476
|
+
function num(value) {
|
|
477
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
478
|
+
}
|
|
479
|
+
function billedInput(usage) {
|
|
480
|
+
return num(usage.uncachedInputTokens) + num(usage.cacheReadTokens) + num(usage.cacheWriteTokens);
|
|
481
|
+
}
|
|
482
|
+
function totalTokens(usage) {
|
|
483
|
+
return billedInput(usage) + num(usage.outputTokens);
|
|
484
|
+
}
|
|
485
|
+
/** CNY for this usage at the given off-peak rates, scaled by the peak factor. */
|
|
486
|
+
function costOf(usage, rates, factor) {
|
|
487
|
+
if (rates === null) return null;
|
|
488
|
+
const scale = Number.isFinite(factor) && factor > 0 ? factor : 1;
|
|
489
|
+
const cny =
|
|
490
|
+
((num(usage.uncachedInputTokens) * rates.input +
|
|
491
|
+
num(usage.cacheReadTokens) * rates.cacheRead +
|
|
492
|
+
num(usage.cacheWriteTokens) * rates.cacheWrite +
|
|
493
|
+
num(usage.outputTokens) * rates.output) /
|
|
494
|
+
1e6) *
|
|
495
|
+
scale;
|
|
496
|
+
return Number.isFinite(cny) ? cny : null;
|
|
497
|
+
}
|
|
498
|
+
function cacheHitPercent(usage) {
|
|
499
|
+
const prompt = billedInput(usage);
|
|
500
|
+
if (prompt === 0) return null;
|
|
501
|
+
if (num(usage.cacheReadTokens) >= prompt) return "100";
|
|
502
|
+
const percent = (num(usage.cacheReadTokens) / prompt) * 100;
|
|
503
|
+
const rounded = Math.round(percent * 10) / 10;
|
|
504
|
+
if (rounded >= 100) return "99.9";
|
|
505
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
506
|
+
}
|
|
507
|
+
function formatTokens(value) {
|
|
508
|
+
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
509
|
+
if (value < 1000) return String(Math.round(value));
|
|
510
|
+
if (value < 1e6) return (value / 1e3).toFixed(1) + "K";
|
|
511
|
+
return (value / 1e6).toFixed(1) + "M";
|
|
512
|
+
}
|
|
513
|
+
function exactCount(value) {
|
|
514
|
+
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
515
|
+
}
|
|
516
|
+
function amount(value) {
|
|
517
|
+
if (!Number.isFinite(value)) return "0";
|
|
518
|
+
if (value >= 100) return value.toFixed(0);
|
|
519
|
+
if (value >= 1) return value.toFixed(2);
|
|
520
|
+
if (value >= 0.01) return value.toFixed(3);
|
|
521
|
+
return value.toFixed(4);
|
|
522
|
+
}
|
|
523
|
+
function moneyText(cny, currency, rate, spaced) {
|
|
524
|
+
if (cny === null) return null;
|
|
525
|
+
const gap = spaced === true ? " " : "";
|
|
526
|
+
const perUsd = Number.isFinite(rate) && rate > 0 ? rate : readRate();
|
|
527
|
+
const yuan = "≈" + gap + "¥" + amount(cny);
|
|
528
|
+
const dollar = "≈" + gap + "$" + amount(cny / perUsd);
|
|
529
|
+
if (currency === "USD") return dollar;
|
|
530
|
+
if (currency === "BOTH") return yuan + " / " + dollar;
|
|
531
|
+
return yuan;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// --------------------------------------------------------------- balance
|
|
535
|
+
function moneySymbol(currency) {
|
|
536
|
+
return currency === "USD" ? "$" : "¥";
|
|
537
|
+
}
|
|
538
|
+
function groupDigits(text) {
|
|
539
|
+
return text.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
540
|
+
}
|
|
541
|
+
/** Two decimals with grouping; tiny positive amounts read as `<0.01`. */
|
|
542
|
+
function formatMoney(value, currency) {
|
|
543
|
+
const symbol = moneySymbol(currency);
|
|
544
|
+
const number = Number(value);
|
|
545
|
+
if (!Number.isFinite(number)) return symbol + "0.00";
|
|
546
|
+
if (number === 0) return symbol + "0.00";
|
|
547
|
+
if (number < 0) {
|
|
548
|
+
const absolute = Math.abs(number);
|
|
549
|
+
return "-" + symbol + (absolute < 0.01 ? "0.01" : groupDigits(absolute.toFixed(2)));
|
|
550
|
+
}
|
|
551
|
+
if (number < 0.01) return symbol + "<0.01";
|
|
552
|
+
return symbol + groupDigits(number.toFixed(2));
|
|
553
|
+
}
|
|
554
|
+
/** Approximation that fits the 36px collapsed rail. */
|
|
555
|
+
function compactMoney(value, currency) {
|
|
556
|
+
const symbol = moneySymbol(currency);
|
|
557
|
+
const number = Number(value);
|
|
558
|
+
if (!Number.isFinite(number) || number === 0) return symbol + "0";
|
|
559
|
+
const sign = number < 0 ? "-" : "";
|
|
560
|
+
const absolute = Math.abs(number);
|
|
561
|
+
if (absolute >= 1000) return sign + symbol + (absolute / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
|
562
|
+
if (absolute >= 1) return sign + symbol + String(Math.round(absolute));
|
|
563
|
+
return sign + symbol + "<1";
|
|
564
|
+
}
|
|
565
|
+
function walletListText(wallets) {
|
|
566
|
+
return wallets.map((wallet) => formatMoney(wallet.balance, wallet.currency)).join(" / ");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function createBalanceStore(ctx) {
|
|
570
|
+
let state = Object.freeze({ status: "idle", wallets: Object.freeze([]), bonusWallets: Object.freeze([]), error: null });
|
|
571
|
+
const listeners = new Set();
|
|
572
|
+
let timer = null;
|
|
573
|
+
let retryTimer = null;
|
|
574
|
+
let running = false;
|
|
575
|
+
let generation = 0;
|
|
576
|
+
const retrySoon = (delayMs) => {
|
|
577
|
+
if (!running || retryTimer !== null) return;
|
|
578
|
+
retryTimer = setTimeout(() => {
|
|
579
|
+
retryTimer = null;
|
|
580
|
+
void refresh();
|
|
581
|
+
}, delayMs);
|
|
582
|
+
if (retryTimer !== null && typeof retryTimer === "object" && typeof retryTimer.unref === "function") retryTimer.unref();
|
|
583
|
+
};
|
|
584
|
+
const publish = (patch) => {
|
|
585
|
+
state = Object.freeze({ ...state, ...patch });
|
|
586
|
+
for (const listener of listeners) {
|
|
587
|
+
try {
|
|
588
|
+
listener();
|
|
589
|
+
} catch {
|
|
590
|
+
/* a subscriber must not break the balance chip */
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const clientMeta = () => {
|
|
595
|
+
let locale = "zh";
|
|
596
|
+
try {
|
|
597
|
+
locale = ctx.locale.getSnapshot().active;
|
|
598
|
+
} catch {
|
|
599
|
+
/* keep the default */
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
version: "0.2.2-rc.1",
|
|
603
|
+
locale,
|
|
604
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
const refresh = async () => {
|
|
608
|
+
const generationAtStart = ++generation;
|
|
609
|
+
if (state.status === "idle") publish({ status: "loading", error: null });
|
|
610
|
+
try {
|
|
611
|
+
const remote = ctx.remote;
|
|
612
|
+
const account =
|
|
613
|
+
remote !== undefined && remote !== null && remote.account !== undefined
|
|
614
|
+
? remote.account
|
|
615
|
+
: typeof ctx.get === "function"
|
|
616
|
+
? ctx.get("remote.account")
|
|
617
|
+
: undefined;
|
|
618
|
+
if (account === undefined || account === null || typeof account.getBalance !== "function") {
|
|
619
|
+
publish({ status: "loading", error: "account remote not mounted" });
|
|
620
|
+
retrySoon(1000);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const result = await account.getBalance(clientMeta());
|
|
624
|
+
if (generationAtStart !== generation) return;
|
|
625
|
+
if (result === null || result === undefined || result.ok !== true) {
|
|
626
|
+
publish({ status: "unavailable", error: result?.error?.code ?? "balance request failed" });
|
|
627
|
+
retrySoon(5000);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const value = result.value;
|
|
631
|
+
if (value === null || value === undefined) {
|
|
632
|
+
publish({ status: "signedOut", wallets: Object.freeze([]), bonusWallets: Object.freeze([]), error: "account grant unavailable" });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (value.status !== "ready") {
|
|
636
|
+
publish({ status: "unavailable", error: "balance " + String(value.status) });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
publish({
|
|
640
|
+
status: "ready",
|
|
641
|
+
wallets: Object.freeze(Array.isArray(value.value) ? value.value : []),
|
|
642
|
+
bonusWallets: Object.freeze(Array.isArray(value.bonusWallets) ? value.bonusWallets : []),
|
|
643
|
+
error: null,
|
|
644
|
+
});
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (generationAtStart !== generation) return;
|
|
647
|
+
publish({ status: "unavailable", error: error instanceof Error ? error.message : String(error) });
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const onVisible = () => {
|
|
651
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") void refresh();
|
|
652
|
+
};
|
|
653
|
+
return {
|
|
654
|
+
getSnapshot: () => state,
|
|
655
|
+
subscribe(listener) {
|
|
656
|
+
listeners.add(listener);
|
|
657
|
+
return () => {
|
|
658
|
+
listeners.delete(listener);
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
refresh,
|
|
662
|
+
start() {
|
|
663
|
+
if (running) return;
|
|
664
|
+
running = true;
|
|
665
|
+
void refresh();
|
|
666
|
+
timer = setInterval(() => {
|
|
667
|
+
void refresh();
|
|
668
|
+
}, 60000);
|
|
669
|
+
if (timer !== null && typeof timer === "object" && typeof timer.unref === "function") timer.unref();
|
|
670
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
|
671
|
+
},
|
|
672
|
+
stop() {
|
|
673
|
+
running = false;
|
|
674
|
+
generation += 1;
|
|
675
|
+
if (timer !== null) {
|
|
676
|
+
clearInterval(timer);
|
|
677
|
+
timer = null;
|
|
678
|
+
}
|
|
679
|
+
if (retryTimer !== null) {
|
|
680
|
+
clearTimeout(retryTimer);
|
|
681
|
+
retryTimer = null;
|
|
682
|
+
}
|
|
683
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function BalanceChip({ wide, balance, t }) {
|
|
689
|
+
const config = useConfig();
|
|
690
|
+
const state = React.useSyncExternalStore(balance.subscribe, balance.getSnapshot, balance.getSnapshot);
|
|
691
|
+
if (!config.showBalance) return null;
|
|
692
|
+
const wallets = state.wallets ?? [];
|
|
693
|
+
const bonusWallets = state.bonusWallets ?? [];
|
|
694
|
+
const symbol = wallets[0]?.currency === "USD" ? "$" : "¥";
|
|
695
|
+
let value;
|
|
696
|
+
if (state.status === "ready") {
|
|
697
|
+
value =
|
|
698
|
+
wallets.length === 0
|
|
699
|
+
? formatMoney(0, "CNY")
|
|
700
|
+
: wide
|
|
701
|
+
? walletListText(wallets)
|
|
702
|
+
: compactMoney(wallets[0].balance, wallets[0].currency);
|
|
703
|
+
} else if (state.status === "signedOut") {
|
|
704
|
+
value = wide ? t("balance.signedOut") : "--";
|
|
705
|
+
} else if (state.status === "unavailable") {
|
|
706
|
+
value = "--";
|
|
707
|
+
} else {
|
|
708
|
+
value = "…";
|
|
709
|
+
}
|
|
710
|
+
const tooltip =
|
|
711
|
+
state.status === "ready"
|
|
712
|
+
? [
|
|
713
|
+
t("balance.tooltip", { wallets: wallets.length > 0 ? walletListText(wallets) : formatMoney(0, "CNY") }),
|
|
714
|
+
bonusWallets.length > 0 ? t("balance.bonus", { wallets: walletListText(bonusWallets) }) : null,
|
|
715
|
+
t("balance.refresh"),
|
|
716
|
+
]
|
|
717
|
+
.filter((part) => part !== null)
|
|
718
|
+
.join(" · ")
|
|
719
|
+
: state.status === "signedOut"
|
|
720
|
+
? t("balance.signedOut") + " · " + t("balance.refresh")
|
|
721
|
+
: state.status === "unavailable"
|
|
722
|
+
? t("balance.unavailable") + (state.error ? "(" + state.error + ")" : "") + " · " + t("balance.refresh")
|
|
723
|
+
: t("balance.loading");
|
|
724
|
+
return h(
|
|
725
|
+
primitives.Tooltip,
|
|
726
|
+
{
|
|
727
|
+
label: tooltip,
|
|
728
|
+
side: "top",
|
|
729
|
+
children: h(
|
|
730
|
+
"button",
|
|
731
|
+
{
|
|
732
|
+
type: "button",
|
|
733
|
+
className: "dshuc_balance",
|
|
734
|
+
"data-wide": wide,
|
|
735
|
+
"aria-label": tooltip,
|
|
736
|
+
onClick: () => {
|
|
737
|
+
void balance.refresh();
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
h("span", { className: "dshuc_balanceGlyph", "aria-hidden": true }, symbol),
|
|
741
|
+
h("span", { className: "dshuc_balanceLabel" }, t("balance.label")),
|
|
742
|
+
h("span", { className: "dshuc_balanceValue" }, value),
|
|
743
|
+
),
|
|
744
|
+
},
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function attachBalance(ctx) {
|
|
749
|
+
if (typeof ctx.inject !== "function") return;
|
|
750
|
+
try {
|
|
751
|
+
ctx.inject(["remote", "remote.account", "remoteAccess"], (scope) => {
|
|
752
|
+
const balance = createBalanceStore(scope);
|
|
753
|
+
let unsubscribe = null;
|
|
754
|
+
const register = () => {
|
|
755
|
+
const sync = () => {
|
|
756
|
+
if (configStore.get().showBalance) balance.start();
|
|
757
|
+
else balance.stop();
|
|
758
|
+
};
|
|
759
|
+
unsubscribe = configStore.subscribe(sync);
|
|
760
|
+
sync();
|
|
761
|
+
const slots = scope.slots ?? (typeof scope.get === "function" ? scope.get("slots") : undefined);
|
|
762
|
+
if (slots === undefined || typeof slots.inject !== "function") {
|
|
763
|
+
return () => {
|
|
764
|
+
unsubscribe?.();
|
|
765
|
+
unsubscribe = null;
|
|
766
|
+
balance.stop();
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const off = slots.inject("sidebar.footer.action", () =>
|
|
770
|
+
slots.register(
|
|
771
|
+
{
|
|
772
|
+
name: "sidebar.footer.action",
|
|
773
|
+
id: "usage-cost-balance",
|
|
774
|
+
order: 10,
|
|
775
|
+
locale: NS,
|
|
776
|
+
inject: () => ({ balance }),
|
|
777
|
+
},
|
|
778
|
+
BalanceChip,
|
|
779
|
+
),
|
|
780
|
+
);
|
|
781
|
+
return () => {
|
|
782
|
+
unsubscribe?.();
|
|
783
|
+
unsubscribe = null;
|
|
784
|
+
balance.stop();
|
|
785
|
+
off?.();
|
|
786
|
+
};
|
|
787
|
+
};
|
|
788
|
+
const access = scope.remoteAccess;
|
|
789
|
+
if (access !== undefined && typeof access.whenAccess === "function") {
|
|
790
|
+
scope.effect(() => access.whenAccess("account/watch", register), "dsh-usage-cost: balance");
|
|
791
|
+
} else {
|
|
792
|
+
scope.effect(() => register(), "dsh-usage-cost: balance");
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
} catch {
|
|
796
|
+
/* the balance chip is best effort */
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// -------------------------------------------------------------- components
|
|
801
|
+
const MEASURE_STYLE = { visibility: "hidden", left: 0, top: 0 };
|
|
802
|
+
|
|
803
|
+
function DialogPill(props) {
|
|
804
|
+
const { stat, icon, label, text, title, titleValue, children } = props;
|
|
805
|
+
const [open, setOpen] = React.useState(false);
|
|
806
|
+
const rootRef = React.useRef(null);
|
|
807
|
+
const panelRef = React.useRef(null);
|
|
808
|
+
const pos = primitives.useAnchoredPosition({
|
|
809
|
+
open,
|
|
810
|
+
anchorRef: rootRef,
|
|
811
|
+
panelRef,
|
|
812
|
+
side: "top",
|
|
813
|
+
gap: 8,
|
|
814
|
+
margin: 12,
|
|
815
|
+
});
|
|
816
|
+
primitives.useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
|
|
817
|
+
React.useEffect(() => {
|
|
818
|
+
if (!open) return;
|
|
819
|
+
const onKeyDown = (event) => {
|
|
820
|
+
if (event.key === "Escape") setOpen(false);
|
|
821
|
+
};
|
|
822
|
+
document.addEventListener("keydown", onKeyDown);
|
|
823
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
824
|
+
}, [open]);
|
|
825
|
+
return h(
|
|
826
|
+
"span",
|
|
827
|
+
{ ref: rootRef, className: "dshuc_anchor", "data-composer-stat": stat },
|
|
828
|
+
h(
|
|
829
|
+
primitives.Tooltip,
|
|
830
|
+
{
|
|
831
|
+
label: text,
|
|
832
|
+
side: "top",
|
|
833
|
+
portal: true,
|
|
834
|
+
delayMs: 200,
|
|
835
|
+
onlyWhenClipped: true,
|
|
836
|
+
disabled: open,
|
|
837
|
+
},
|
|
838
|
+
h(
|
|
839
|
+
"button",
|
|
840
|
+
{
|
|
841
|
+
type: "button",
|
|
842
|
+
className: "dshuc_pill",
|
|
843
|
+
"aria-haspopup": "dialog",
|
|
844
|
+
"aria-expanded": open,
|
|
845
|
+
"aria-label": text,
|
|
846
|
+
onClick: () => setOpen(!open),
|
|
847
|
+
},
|
|
848
|
+
icon,
|
|
849
|
+
h("span", { className: "dshuc_label" }, label),
|
|
850
|
+
),
|
|
851
|
+
),
|
|
852
|
+
open &&
|
|
853
|
+
ReactDOM.createPortal(
|
|
854
|
+
h(
|
|
855
|
+
"div",
|
|
856
|
+
{
|
|
857
|
+
ref: panelRef,
|
|
858
|
+
className: "dshuc_panel",
|
|
859
|
+
role: "dialog",
|
|
860
|
+
"aria-label": title,
|
|
861
|
+
style: pos ?? MEASURE_STYLE,
|
|
862
|
+
},
|
|
863
|
+
h(
|
|
864
|
+
"div",
|
|
865
|
+
{ className: "dshuc_title" },
|
|
866
|
+
h("span", { className: "dshuc_titleLabel" }, icon, title),
|
|
867
|
+
titleValue !== undefined ? h("span", { className: "dshuc_titleValue" }, titleValue) : null,
|
|
868
|
+
),
|
|
869
|
+
h("div", { className: "dshuc_titleRule", "aria-hidden": true }),
|
|
870
|
+
children,
|
|
871
|
+
),
|
|
872
|
+
document.body,
|
|
873
|
+
),
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function SessionUsageCostPill({ useProjection, t }) {
|
|
878
|
+
const usage = useProjection("tokenUsage");
|
|
879
|
+
const selection = useProjection("modelSelection");
|
|
880
|
+
const config = useConfig();
|
|
881
|
+
if (usage === undefined) return null;
|
|
882
|
+
const prompt = billedInput(usage);
|
|
883
|
+
const output = num(usage.outputTokens);
|
|
884
|
+
if (prompt === 0 && output === 0) return null;
|
|
885
|
+
|
|
886
|
+
const route = selection?.pending ?? selection?.lastUsed ?? null;
|
|
887
|
+
const priced = priceFor(route);
|
|
888
|
+
const peak = isPeak(Date.now());
|
|
889
|
+
const cny = priced === null ? null : costOf(usage, priced.rates, peak ? PEAK_FACTOR : 1);
|
|
890
|
+
const total = prompt + output;
|
|
891
|
+
const cacheHit = cacheHitPercent(usage);
|
|
892
|
+
const money = moneyText(cny, config.currency, config.usdToCny);
|
|
893
|
+
const titleMoney = moneyText(cny, config.currency, config.usdToCny, true);
|
|
894
|
+
const moneyNode =
|
|
895
|
+
money === null
|
|
896
|
+
? null
|
|
897
|
+
: h(
|
|
898
|
+
"span",
|
|
899
|
+
{ className: "dshuc_money" },
|
|
900
|
+
money,
|
|
901
|
+
h("span", { className: "dshuc_window" }, t(peak ? "pill.peak" : "pill.offPeak")),
|
|
902
|
+
);
|
|
903
|
+
const cacheHitText = cacheHit === null ? null : t("pill.cacheHit", { percent: cacheHit });
|
|
904
|
+
|
|
905
|
+
const segments = [t("pill.tokens", { count: formatTokens(total) })];
|
|
906
|
+
const label = h(
|
|
907
|
+
React.Fragment,
|
|
908
|
+
null,
|
|
909
|
+
segments[0],
|
|
910
|
+
moneyNode !== null
|
|
911
|
+
? h(React.Fragment, null, h("span", { className: "dshuc_sep", "aria-hidden": true }, "·"), moneyNode)
|
|
912
|
+
: null,
|
|
913
|
+
cacheHitText !== null
|
|
914
|
+
? h(
|
|
915
|
+
React.Fragment,
|
|
916
|
+
null,
|
|
917
|
+
h("span", { className: "dshuc_sep", "aria-hidden": true }, "·"),
|
|
918
|
+
cacheHitText,
|
|
919
|
+
)
|
|
920
|
+
: null,
|
|
921
|
+
);
|
|
922
|
+
const text = segments
|
|
923
|
+
.concat(money === null ? [] : [money])
|
|
924
|
+
.concat(cacheHitText === null ? [] : [cacheHitText])
|
|
925
|
+
.join(" · ");
|
|
926
|
+
|
|
927
|
+
const rows = [
|
|
928
|
+
cacheHit !== null ? [t("dialog.cacheHit"), cacheHit + "%"] : null,
|
|
929
|
+
[t("dialog.input"), exactCount(num(usage.uncachedInputTokens))],
|
|
930
|
+
[t("dialog.cacheRead"), exactCount(num(usage.cacheReadTokens))],
|
|
931
|
+
num(usage.cacheWriteTokens) !== 0
|
|
932
|
+
? [t("dialog.cacheWrite"), exactCount(num(usage.cacheWriteTokens))]
|
|
933
|
+
: null,
|
|
934
|
+
[t("dialog.output"), exactCount(output)],
|
|
935
|
+
[t("dialog.window"), t(peak ? "dialog.windowPeak" : "dialog.windowOffPeak")],
|
|
936
|
+
].filter((row) => row !== null);
|
|
937
|
+
|
|
938
|
+
return h(
|
|
939
|
+
DialogPill,
|
|
940
|
+
{
|
|
941
|
+
stat: "usage",
|
|
942
|
+
icon: h(primitives.IconDatabaseOutlineRegular, {}),
|
|
943
|
+
label,
|
|
944
|
+
text,
|
|
945
|
+
title: t("dialog.title"),
|
|
946
|
+
titleValue: exactCount(total) + (titleMoney === null ? "" : " · " + titleMoney),
|
|
947
|
+
},
|
|
948
|
+
h(
|
|
949
|
+
"dl",
|
|
950
|
+
{ className: "dshuc_details", "data-session-stats-usage": true },
|
|
951
|
+
rows.map(([key, value], index) =>
|
|
952
|
+
h(
|
|
953
|
+
React.Fragment,
|
|
954
|
+
{ key: index },
|
|
955
|
+
h("dt", null, key),
|
|
956
|
+
h("dd", null, value),
|
|
957
|
+
),
|
|
958
|
+
),
|
|
959
|
+
),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Hidden anchor in the turn tail: rewrites the built-in usage action label
|
|
965
|
+
* from "用量 N tok" to "N tok · ≈¥x.xx" without adding a line of its own.
|
|
966
|
+
*/
|
|
967
|
+
function TurnUsageChip({ turn, t, useChat, useProjection }) {
|
|
968
|
+
const config = useConfig();
|
|
969
|
+
const selection = typeof useProjection === "function" ? useProjection("modelSelection") : undefined;
|
|
970
|
+
const turnNumber = typeof turn === "number" ? turn : turn !== null && typeof turn === "object" ? turn.turn : undefined;
|
|
971
|
+
const useChatSafe = typeof useChat === "function" ? useChat : () => undefined;
|
|
972
|
+
const usage = useChatSafe((snapshot) => {
|
|
973
|
+
if (snapshot === null || typeof snapshot !== "object" || snapshot.nodes === undefined) return undefined;
|
|
974
|
+
const nodes = snapshot.nodes;
|
|
975
|
+
if (typeof nodes.values !== "function") return undefined;
|
|
976
|
+
let found;
|
|
977
|
+
for (const node of nodes.values()) {
|
|
978
|
+
if (node === null || typeof node !== "object" || node.kind !== "turn-tail") continue;
|
|
979
|
+
if (node.data?.turn !== turnNumber || node.data?.tokenUsage === undefined) continue;
|
|
980
|
+
found = node.data.tokenUsage;
|
|
981
|
+
if (typeof node.data.time === "number") TURN_TIMES.set(found, node.data.time);
|
|
982
|
+
}
|
|
983
|
+
let summed = null;
|
|
984
|
+
if (Array.isArray(snapshot.legacy?.nodes)) {
|
|
985
|
+
let uncached = 0;
|
|
986
|
+
let cacheRead = 0;
|
|
987
|
+
let cacheWrite = 0;
|
|
988
|
+
let output = 0;
|
|
989
|
+
let total = 0;
|
|
990
|
+
for (const node of snapshot.legacy.nodes) {
|
|
991
|
+
if (node === null || typeof node !== "object" || node.kind !== "assistant" || node.turn !== turnNumber) continue;
|
|
992
|
+
const sample = node.usage;
|
|
993
|
+
if (sample === null || sample === undefined) continue;
|
|
994
|
+
const input = Number(sample.inputTokens) || 0;
|
|
995
|
+
const read = Number(sample.cacheReadTokens) || 0;
|
|
996
|
+
const write = Number(sample.cacheWriteTokens) || 0;
|
|
997
|
+
const out = Number(sample.outputTokens) || 0;
|
|
998
|
+
uncached += input;
|
|
999
|
+
cacheRead += read;
|
|
1000
|
+
cacheWrite += write;
|
|
1001
|
+
output += out;
|
|
1002
|
+
total += Number(sample.totalTokens) || input + read + write + out;
|
|
1003
|
+
}
|
|
1004
|
+
if (total > 0) {
|
|
1005
|
+
summed = {
|
|
1006
|
+
uncachedInputTokens: uncached,
|
|
1007
|
+
cacheReadTokens: cacheRead,
|
|
1008
|
+
cacheWriteTokens: cacheWrite,
|
|
1009
|
+
outputTokens: output,
|
|
1010
|
+
totalTokens: total,
|
|
1011
|
+
estimated: true,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return found ?? summed;
|
|
1016
|
+
});
|
|
1017
|
+
const anchorRef = React.useRef(null);
|
|
1018
|
+
|
|
1019
|
+
let text = null;
|
|
1020
|
+
if (usage !== undefined && usage !== null) {
|
|
1021
|
+
const total = typeof usage.totalTokens === "number" ? usage.totalTokens : totalTokens(usage);
|
|
1022
|
+
const route = usage.routes?.[0] ?? selection?.pending ?? selection?.lastUsed ?? null;
|
|
1023
|
+
const priced = priceFor(route);
|
|
1024
|
+
// The turn's own end time decides its window; the summed fallback has
|
|
1025
|
+
// no timestamp, so it is priced at whatever window it is read in.
|
|
1026
|
+
const factor = peakFactor(TURN_TIMES.get(usage) ?? Date.now());
|
|
1027
|
+
const cny = priced === null ? null : costOf(usage, priced.rates, factor);
|
|
1028
|
+
const money = moneyText(cny, config.currency, config.usdToCny);
|
|
1029
|
+
text = (usage.estimated ? "≈" : "") + formatTokens(total) + " tok" + (money === null ? "" : " · " + money);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
React.useEffect(() => {
|
|
1033
|
+
const anchor = anchorRef.current;
|
|
1034
|
+
if (anchor === null || text === null) return;
|
|
1035
|
+
const root = anchor.closest("[data-turn-tail]");
|
|
1036
|
+
if (root === null) return;
|
|
1037
|
+
let original = null;
|
|
1038
|
+
let patched = null;
|
|
1039
|
+
const apply = () => {
|
|
1040
|
+
const button = root.querySelector('button[aria-haspopup="dialog"]');
|
|
1041
|
+
const label = button === null ? null : button.lastElementChild;
|
|
1042
|
+
if (label === null) return;
|
|
1043
|
+
const textNode = label.firstChild;
|
|
1044
|
+
if (original === null) original = textNode !== null && textNode.nodeType === 3 ? textNode.nodeValue : label.textContent;
|
|
1045
|
+
// Keep the text node's identity: replacing it would leave React's
|
|
1046
|
+
// fiber pointing at a detached node and break a later unmount.
|
|
1047
|
+
if (textNode !== null && textNode.nodeType === 3) {
|
|
1048
|
+
if (textNode.nodeValue !== text) textNode.nodeValue = text;
|
|
1049
|
+
patched = textNode;
|
|
1050
|
+
} else if (label.textContent !== text) {
|
|
1051
|
+
label.textContent = text;
|
|
1052
|
+
patched = label;
|
|
1053
|
+
} else {
|
|
1054
|
+
patched = label;
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
apply();
|
|
1058
|
+
const observer = new MutationObserver(apply);
|
|
1059
|
+
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
|
1060
|
+
return () => {
|
|
1061
|
+
observer.disconnect();
|
|
1062
|
+
if (patched === null || original === null) return;
|
|
1063
|
+
if (!patched.isConnected) return;
|
|
1064
|
+
if (patched.nodeType === 3 && patched.nodeValue === text) patched.nodeValue = original;
|
|
1065
|
+
else if (patched.nodeType !== 3 && patched.textContent === text) patched.textContent = original;
|
|
1066
|
+
};
|
|
1067
|
+
}, [text]);
|
|
1068
|
+
|
|
1069
|
+
return h("span", { ref: anchorRef, hidden: true, "data-dshuc-anchor": true });
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// -------------------------------------------------------------- config page
|
|
1073
|
+
function UsageCostConfigForm({ form, t }) {
|
|
1074
|
+
const state = form.state ?? {};
|
|
1075
|
+
const value = state.value ?? {};
|
|
1076
|
+
const revision = state.revision;
|
|
1077
|
+
const [draft, setDraft] = React.useState(() => ({
|
|
1078
|
+
currency: sanitizeCurrency(value.currency),
|
|
1079
|
+
rate: String(sanitizeRate(value.usdToCny)),
|
|
1080
|
+
showBalance: value.showBalance !== false,
|
|
1081
|
+
}));
|
|
1082
|
+
const [status, setStatus] = React.useState("idle");
|
|
1083
|
+
|
|
1084
|
+
React.useEffect(() => {
|
|
1085
|
+
setDraft({
|
|
1086
|
+
currency: sanitizeCurrency(value.currency),
|
|
1087
|
+
rate: String(sanitizeRate(value.usdToCny)),
|
|
1088
|
+
showBalance: value.showBalance !== false,
|
|
1089
|
+
});
|
|
1090
|
+
setStatus("idle");
|
|
1091
|
+
// Reset the draft only when the Host revision moves.
|
|
1092
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1093
|
+
}, [revision]);
|
|
1094
|
+
|
|
1095
|
+
const readOnly = state.writable === false;
|
|
1096
|
+
const busy = status === "saving";
|
|
1097
|
+
const canToggleBalance = Object.hasOwn(value, "showBalance");
|
|
1098
|
+
|
|
1099
|
+
const save = async () => {
|
|
1100
|
+
const rate = Number(draft.rate.trim());
|
|
1101
|
+
if (!Number.isFinite(rate) || rate < 1 || rate > 100) {
|
|
1102
|
+
setStatus("invalid");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const ops = [];
|
|
1106
|
+
if (draft.currency !== sanitizeCurrency(value.currency)) {
|
|
1107
|
+
ops.push({ op: "set", path: ["currency"], value: draft.currency });
|
|
1108
|
+
}
|
|
1109
|
+
if (rate !== sanitizeRate(value.usdToCny)) {
|
|
1110
|
+
ops.push({ op: "set", path: ["usdToCny"], value: rate });
|
|
1111
|
+
}
|
|
1112
|
+
if (canToggleBalance && draft.showBalance !== (value.showBalance !== false)) {
|
|
1113
|
+
ops.push({ op: "set", path: ["showBalance"], value: draft.showBalance });
|
|
1114
|
+
}
|
|
1115
|
+
if (ops.length === 0) {
|
|
1116
|
+
setStatus("saved");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
setStatus("saving");
|
|
1120
|
+
try {
|
|
1121
|
+
const accepted = await form.mutate(ops, revision);
|
|
1122
|
+
setStatus(accepted ? "saved" : "failed");
|
|
1123
|
+
} catch {
|
|
1124
|
+
setStatus("failed");
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const reset = async () => {
|
|
1129
|
+
setStatus("saving");
|
|
1130
|
+
const ops = [
|
|
1131
|
+
{ op: "unset", path: ["currency"] },
|
|
1132
|
+
{ op: "unset", path: ["usdToCny"] },
|
|
1133
|
+
];
|
|
1134
|
+
if (canToggleBalance) ops.push({ op: "unset", path: ["showBalance"] });
|
|
1135
|
+
try {
|
|
1136
|
+
const accepted = await form.mutate(ops, revision);
|
|
1137
|
+
setStatus(accepted ? "saved" : "failed");
|
|
1138
|
+
} catch {
|
|
1139
|
+
setStatus("failed");
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const message =
|
|
1144
|
+
status === "saved"
|
|
1145
|
+
? t("config.saved")
|
|
1146
|
+
: status === "failed"
|
|
1147
|
+
? t("config.failed")
|
|
1148
|
+
: status === "invalid"
|
|
1149
|
+
? t("config.invalidRate")
|
|
1150
|
+
: readOnly
|
|
1151
|
+
? t("config.readOnly")
|
|
1152
|
+
: "";
|
|
1153
|
+
|
|
1154
|
+
return h(
|
|
1155
|
+
"div",
|
|
1156
|
+
{ className: "dshuc_cfg" },
|
|
1157
|
+
h("div", { className: "dshuc_cfgTitle" }, t("config.title")),
|
|
1158
|
+
canToggleBalance
|
|
1159
|
+
? h(
|
|
1160
|
+
"div",
|
|
1161
|
+
{ className: "dshuc_field" },
|
|
1162
|
+
h(
|
|
1163
|
+
"label",
|
|
1164
|
+
{ className: "dshuc_toggleRow" },
|
|
1165
|
+
h("span", { className: "dshuc_fieldLabel" }, t("config.showBalance")),
|
|
1166
|
+
h(primitives.Switch, {
|
|
1167
|
+
checked: draft.showBalance,
|
|
1168
|
+
label: t("config.showBalance"),
|
|
1169
|
+
disabled: readOnly || busy,
|
|
1170
|
+
onChange: (checked) => setDraft({ ...draft, showBalance: checked }),
|
|
1171
|
+
}),
|
|
1172
|
+
),
|
|
1173
|
+
h("span", { className: "dshuc_hint" }, t("config.showBalanceHint")),
|
|
1174
|
+
)
|
|
1175
|
+
: null,
|
|
1176
|
+
h(
|
|
1177
|
+
"label",
|
|
1178
|
+
{ className: "dshuc_field" },
|
|
1179
|
+
h("span", { className: "dshuc_fieldLabel" }, t("config.currency")),
|
|
1180
|
+
h(
|
|
1181
|
+
"select",
|
|
1182
|
+
{
|
|
1183
|
+
className: "dshuc_input",
|
|
1184
|
+
value: draft.currency,
|
|
1185
|
+
disabled: readOnly || busy,
|
|
1186
|
+
onChange: (event) => setDraft({ ...draft, currency: event.target.value }),
|
|
1187
|
+
},
|
|
1188
|
+
h("option", { value: "CNY" }, t("currency.cny")),
|
|
1189
|
+
h("option", { value: "USD" }, t("currency.usd")),
|
|
1190
|
+
h("option", { value: "BOTH" }, t("currency.both")),
|
|
1191
|
+
),
|
|
1192
|
+
),
|
|
1193
|
+
h(
|
|
1194
|
+
"label",
|
|
1195
|
+
{ className: "dshuc_field" },
|
|
1196
|
+
h("span", { className: "dshuc_fieldLabel" }, t("config.rate")),
|
|
1197
|
+
h("input", {
|
|
1198
|
+
className: "dshuc_input",
|
|
1199
|
+
type: "number",
|
|
1200
|
+
min: 1,
|
|
1201
|
+
max: 100,
|
|
1202
|
+
step: 0.01,
|
|
1203
|
+
value: draft.rate,
|
|
1204
|
+
disabled: readOnly || busy,
|
|
1205
|
+
onChange: (event) => setDraft({ ...draft, rate: event.target.value }),
|
|
1206
|
+
}),
|
|
1207
|
+
h("span", { className: "dshuc_hint" }, t("config.rateHint")),
|
|
1208
|
+
),
|
|
1209
|
+
h(
|
|
1210
|
+
"div",
|
|
1211
|
+
{ className: "dshuc_field" },
|
|
1212
|
+
h("span", { className: "dshuc_fieldLabel" }, t("config.ratesTitle")),
|
|
1213
|
+
h(
|
|
1214
|
+
"table",
|
|
1215
|
+
{ className: "dshuc_table" },
|
|
1216
|
+
h(
|
|
1217
|
+
"thead",
|
|
1218
|
+
null,
|
|
1219
|
+
h(
|
|
1220
|
+
"tr",
|
|
1221
|
+
null,
|
|
1222
|
+
h("th", null, t("config.model")),
|
|
1223
|
+
h("th", null, t("dialog.input")),
|
|
1224
|
+
h("th", null, t("dialog.cacheRead")),
|
|
1225
|
+
h("th", null, t("dialog.cacheWrite")),
|
|
1226
|
+
h("th", null, t("dialog.output")),
|
|
1227
|
+
),
|
|
1228
|
+
),
|
|
1229
|
+
h(
|
|
1230
|
+
"tbody",
|
|
1231
|
+
null,
|
|
1232
|
+
Object.entries(MODEL_PRICES).map(([model, rates]) =>
|
|
1233
|
+
h(
|
|
1234
|
+
"tr",
|
|
1235
|
+
{ key: model },
|
|
1236
|
+
h("td", null, model),
|
|
1237
|
+
h("td", null, "¥" + rates.input),
|
|
1238
|
+
h("td", null, "¥" + rates.cacheRead),
|
|
1239
|
+
h("td", null, "¥" + rates.cacheWrite),
|
|
1240
|
+
h("td", null, "¥" + rates.output),
|
|
1241
|
+
),
|
|
1242
|
+
),
|
|
1243
|
+
),
|
|
1244
|
+
),
|
|
1245
|
+
h("span", { className: "dshuc_hint" }, t("config.ratesNote")),
|
|
1246
|
+
),
|
|
1247
|
+
h(
|
|
1248
|
+
"div",
|
|
1249
|
+
{ className: "dshuc_actions" },
|
|
1250
|
+
h(
|
|
1251
|
+
primitives.Button,
|
|
1252
|
+
{ variant: "primary", size: "sm", disabled: readOnly || busy, onClick: () => void save() },
|
|
1253
|
+
busy ? t("config.saving") : t("config.save"),
|
|
1254
|
+
),
|
|
1255
|
+
h(primitives.Button, { variant: "outline", size: "sm", disabled: readOnly || busy, onClick: () => void reset() }, t("config.reset")),
|
|
1256
|
+
),
|
|
1257
|
+
message === "" ? null : h("div", { className: "dshuc_status", "data-tone": status === "failed" || status === "invalid" ? "error" : undefined }, message),
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function UsageCostConfigPage(props) {
|
|
1262
|
+
const { view, form, t } = props;
|
|
1263
|
+
if (view === "summary") return t("config.summary");
|
|
1264
|
+
if (form === undefined || form === null) {
|
|
1265
|
+
return h("p", { className: "dshuc_cfgNote" }, t("config.unavailable"));
|
|
1266
|
+
}
|
|
1267
|
+
return h(UsageCostConfigForm, { form, t });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// -------------------------------------------------------------------- apply
|
|
1271
|
+
function apply(ctx) {
|
|
1272
|
+
ctx.effect(
|
|
1273
|
+
() =>
|
|
1274
|
+
ctx.locale.register(NS, {
|
|
1275
|
+
zh,
|
|
1276
|
+
en,
|
|
1277
|
+
ja: en,
|
|
1278
|
+
es: en,
|
|
1279
|
+
fr: en,
|
|
1280
|
+
de: en,
|
|
1281
|
+
ru: en,
|
|
1282
|
+
pt: en,
|
|
1283
|
+
}),
|
|
1284
|
+
"dsh-usage-cost: dictionaries",
|
|
1285
|
+
);
|
|
1286
|
+
injectStyles();
|
|
1287
|
+
attachConfig(ctx);
|
|
1288
|
+
attachBalance(ctx);
|
|
1289
|
+
ctx.slots.inject("conversation.composer.dock", () =>
|
|
1290
|
+
ctx.slots.register(
|
|
1291
|
+
{
|
|
1292
|
+
name: "conversation.composer.dock",
|
|
1293
|
+
id: "usage",
|
|
1294
|
+
order: 1,
|
|
1295
|
+
priority: -1,
|
|
1296
|
+
locale: NS,
|
|
1297
|
+
},
|
|
1298
|
+
SessionUsageCostPill,
|
|
1299
|
+
),
|
|
1300
|
+
);
|
|
1301
|
+
ctx.slots.inject("conversation.chat.turnTail", () =>
|
|
1302
|
+
ctx.slots.register(
|
|
1303
|
+
{
|
|
1304
|
+
name: "conversation.chat.turnTail",
|
|
1305
|
+
id: "turn-usage-cost",
|
|
1306
|
+
order: 40,
|
|
1307
|
+
locale: NS,
|
|
1308
|
+
},
|
|
1309
|
+
TurnUsageChip,
|
|
1310
|
+
),
|
|
1311
|
+
);
|
|
1312
|
+
ctx.slots.inject("plugins.row.config", () =>
|
|
1313
|
+
ctx.slots.register(
|
|
1314
|
+
{
|
|
1315
|
+
name: "plugins.row.config",
|
|
1316
|
+
key: CONFIG_KEY,
|
|
1317
|
+
locale: NS,
|
|
1318
|
+
},
|
|
1319
|
+
UsageCostConfigPage,
|
|
1320
|
+
),
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
exports.name = "dsh-usage-cost";
|
|
1325
|
+
exports.inject = ["slots", "locale"];
|
|
1326
|
+
exports.apply = apply;
|
|
1327
|
+
/** Test-only internals (loaded through the stub module factory). */
|
|
1328
|
+
exports.__test = {
|
|
1329
|
+
MODEL_PRICES,
|
|
1330
|
+
priceFor,
|
|
1331
|
+
costOf,
|
|
1332
|
+
isPeak,
|
|
1333
|
+
peakFactor,
|
|
1334
|
+
beijingParts,
|
|
1335
|
+
cacheHitPercent,
|
|
1336
|
+
formatTokens,
|
|
1337
|
+
amount,
|
|
1338
|
+
moneyText,
|
|
1339
|
+
billedInput,
|
|
1340
|
+
totalTokens,
|
|
1341
|
+
createConfigStore,
|
|
1342
|
+
sanitizeCurrency,
|
|
1343
|
+
sanitizeRate,
|
|
1344
|
+
formatMoney,
|
|
1345
|
+
compactMoney,
|
|
1346
|
+
walletListText,
|
|
1347
|
+
createBalanceStore,
|
|
1348
|
+
};
|
|
1349
|
+
return module.exports;
|
|
1350
|
+
},
|
|
1351
|
+
});
|