dsh-plugin-billing 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/client.js +872 -0
- package/lib/index.js +563 -0
- package/lib/prices.js +202 -0
- package/lib/pricing.js +108 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
- package/pricing.json +23 -0
- package/scripts/build-client.mjs +97 -0
- package/scripts/update-prices.mjs +132 -0
- package/scripts/verify.mjs +1474 -0
- package/src/client-body.js +642 -0
- package/src/pricing-body.js +161 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 余额与金额行的浏览器侧实现 —— 会被构建脚本拼进 `lib/client.js` 的 factory 闭包
|
|
3
|
+
* (见 `scripts/build-client.mjs`)。同样是**纯声明**,没有 import/export。
|
|
4
|
+
*
|
|
5
|
+
* 放在 conversation composer 的 dock 里,紧跟 ui-conversation 自己的 StatsLine
|
|
6
|
+
* (`id: "stats"`,`order: 0`),所以它显示在统计行下方、输入框上方。
|
|
7
|
+
*
|
|
8
|
+
* 金额口径:
|
|
9
|
+
* - **本轮**:取已加载记录里最后一个轮次的 assistant 节点用量,按该轮发生时刻的
|
|
10
|
+
* 单价计价,因此高峰/空闲切换会自动跟随。
|
|
11
|
+
* - **本会话**:把每个轮次各自按各自时刻的单价计价后求和。
|
|
12
|
+
*
|
|
13
|
+
* 会话语料来自会话窗口内的节点(与 StatsLine 的统计行同源);被压缩或分页移出
|
|
14
|
+
* 窗口的历史不在其中,因此会话金额是“已加载记录”的口径。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 余额路由(由本插件 Host 侧注册)。 */
|
|
18
|
+
const SUMMARY_URL = '/plugins/billing/summary';
|
|
19
|
+
|
|
20
|
+
/** 价格表路由:`GET` 读当前表,`POST .../refresh` 从官方页更新(写本机覆盖文件)。 */
|
|
21
|
+
const PRICES_URL = '/plugins/billing/prices';
|
|
22
|
+
const PRICES_REFRESH_URL = PRICES_URL + '/refresh';
|
|
23
|
+
|
|
24
|
+
/** 余额刷新间隔(毫秒)。每个轮次结束也会立即刷新一次。 */
|
|
25
|
+
const REFRESH_MS = 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 价格表版本号:设置里点完「立即更新」后 +1,金额行据此**立刻**重算,
|
|
29
|
+
* 不用等它自己那一分钟一次的轮询。
|
|
30
|
+
*/
|
|
31
|
+
let pricesEpoch = 0;
|
|
32
|
+
const pricesListeners = new Set();
|
|
33
|
+
|
|
34
|
+
/** 通知所有金额行:价格变了。 */
|
|
35
|
+
function bumpPricesEpoch() {
|
|
36
|
+
pricesEpoch += 1;
|
|
37
|
+
for (const listener of pricesListeners) listener();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 订阅价格版本号(放在 effect 依赖里就会跟着重跑)。 */
|
|
41
|
+
function usePricesEpoch() {
|
|
42
|
+
const [value, setValue] = react.useState(pricesEpoch);
|
|
43
|
+
react.useEffect(function () {
|
|
44
|
+
const listener = function () {
|
|
45
|
+
setValue(pricesEpoch);
|
|
46
|
+
};
|
|
47
|
+
pricesListeners.add(listener);
|
|
48
|
+
return function () {
|
|
49
|
+
pricesListeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** 本插件的 locale 命名空间。 */
|
|
57
|
+
const NS = 'billing';
|
|
58
|
+
|
|
59
|
+
/** 文案字典:zh / en 双份,由 ctx.locale.register 注册。 */
|
|
60
|
+
const zh = {
|
|
61
|
+
'balance': '余额 {amount}',
|
|
62
|
+
'balance.unavailable': '余额 —',
|
|
63
|
+
'turn': '本轮 {amount}',
|
|
64
|
+
'session': '本会话 {amount}',
|
|
65
|
+
'turn.unpriced': '未配置单价',
|
|
66
|
+
'detail.balance': '余额 {total}(赠送 {granted} · 充值 {toppedUp})',
|
|
67
|
+
'detail.balanceUnavailable': '余额不可用:{reason}',
|
|
68
|
+
'detail.modelSession': '按 {model} 计价(该会话实际路由)',
|
|
69
|
+
'detail.modelDefault': '按 {model} 计价(部署默认路由,未取到会话路由)',
|
|
70
|
+
'detail.ratePeak': '高峰价',
|
|
71
|
+
'detail.rateOffPeak': '空闲价',
|
|
72
|
+
'detail.rate': '{period} {currency}/百万 tokens:命中 {hit} · 未命中 {miss} · 输出 {out}',
|
|
73
|
+
'detail.turnTokens': '本轮 tokens 入 {miss}(命中 {hit})· 出 {out}',
|
|
74
|
+
'detail.sessionTokens': '本会话 tokens 入 {miss}(命中 {hit})· 出 {out}',
|
|
75
|
+
'detail.scope': '会话金额按已加载记录折算',
|
|
76
|
+
'detail.source': '单价来源:{source}',
|
|
77
|
+
'prices.title': '用量价格表',
|
|
78
|
+
'prices.description': '当前:{source}',
|
|
79
|
+
'prices.updatedAt': '上次更新于 {date}',
|
|
80
|
+
'prices.action': '立即更新',
|
|
81
|
+
'prices.busy': '更新中…',
|
|
82
|
+
'prices.ok': '已更新:{models}',
|
|
83
|
+
'prices.none': '已是最新',
|
|
84
|
+
'prices.failed': '更新失败:{reason}',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const en = {
|
|
88
|
+
'balance': 'Balance {amount}',
|
|
89
|
+
'balance.unavailable': 'Balance —',
|
|
90
|
+
'turn': 'This turn {amount}',
|
|
91
|
+
'session': 'This session {amount}',
|
|
92
|
+
'turn.unpriced': 'no price configured',
|
|
93
|
+
'detail.balance': 'Balance {total} (granted {granted} · topped up {toppedUp})',
|
|
94
|
+
'detail.balanceUnavailable': 'Balance unavailable: {reason}',
|
|
95
|
+
'detail.modelSession': 'priced as {model} (this session\'s actual route)',
|
|
96
|
+
'detail.modelDefault': 'priced as {model} (deployment default; session route unavailable)',
|
|
97
|
+
'detail.ratePeak': 'peak rate',
|
|
98
|
+
'detail.rateOffPeak': 'off-peak rate',
|
|
99
|
+
'detail.rate': '{period} {currency}/1M tokens: hit {hit} · miss {miss} · out {out}',
|
|
100
|
+
'detail.turnTokens': 'this turn input {miss} (hit {hit}) · output {out}',
|
|
101
|
+
'detail.sessionTokens': 'this session input {miss} (hit {hit}) · output {out}',
|
|
102
|
+
'detail.scope': 'session amount covers loaded records only',
|
|
103
|
+
'detail.source': 'prices: {source}',
|
|
104
|
+
'prices.title': 'Usage price list',
|
|
105
|
+
'prices.description': 'Current: {source}',
|
|
106
|
+
'prices.updatedAt': 'Last updated {date}',
|
|
107
|
+
'prices.action': 'Update now',
|
|
108
|
+
'prices.busy': 'Updating…',
|
|
109
|
+
'prices.ok': 'Updated: {models}',
|
|
110
|
+
'prices.none': 'Already up to date',
|
|
111
|
+
'prices.failed': 'Update failed: {reason}',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** 插件自有样式的标记 id:既用于去重,也让 HMR/卸载能按插件归属清理。 */
|
|
115
|
+
const CSS_TAG_ID = 'dsh-plugin-billing/BillingLine.module.css';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 排版照抄 0.2.0 原生用量行(`dsh-client-ui-chat` 的 `StatsPills.module.css`):
|
|
119
|
+
*
|
|
120
|
+
* - **必须显式写 `font-size`**:dock 在输入卡片**外面**,不写会继承 body 的 14px,
|
|
121
|
+
* 而原生这一行是 `--dsh-content-font-size-secondary - 1px`(默认 12px)——这是
|
|
122
|
+
* 之前和原生"不像一排"的最大原因。
|
|
123
|
+
* - **不要写 `width:100%` / `text-align` / `margin:auto` / `opacity`**:外层
|
|
124
|
+
* `.yhfFVG_dock` 已经是 `display:flex; justify-content:center; align-items:center; gap:12px`,
|
|
125
|
+
* 条目自己要做的只是"别乱抢宽度",否则会把原生那两枚胶囊挤开。
|
|
126
|
+
* - **分隔符用中点 `·`**(原生 `span.sep` 就是它),不是竖线。
|
|
127
|
+
* - 胶囊 `padding:1px 8px` + 20px 行高 = 22px 高,与原生胶囊、上下文圆环完全等高。
|
|
128
|
+
*/
|
|
129
|
+
const CSS =
|
|
130
|
+
'.dshbilling_root{box-sizing:border-box;min-width:0;max-width:100%;display:flex;justify-content:center;align-items:center;gap:12px;' +
|
|
131
|
+
'font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);' +
|
|
132
|
+
'line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));}' +
|
|
133
|
+
'.dshbilling_pill{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;padding:1px 8px;border-radius:999px;' +
|
|
134
|
+
'background:0 0;border:none;color:var(--dsw-alias-label-tertiary);font:inherit;line-height:inherit;' +
|
|
135
|
+
'font-variant-numeric:tabular-nums;white-space:nowrap;max-width:100%;}' +
|
|
136
|
+
'.dshbilling_group{min-width:0;overflow:hidden;text-overflow:ellipsis}' +
|
|
137
|
+
'.dshbilling_sep{color:var(--dsw-alias-separator-primary);margin:0 6px}' +
|
|
138
|
+
// 设置行的排版照抄原生设置行(`DeveloperToolsRow`),混在原生的行里看不出差别。
|
|
139
|
+
'.dshbilling_row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}' +
|
|
140
|
+
'.dshbilling_rowText{min-width:0}' +
|
|
141
|
+
'.dshbilling_rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}' +
|
|
142
|
+
'.dshbilling_rowDesc{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}' +
|
|
143
|
+
'.dshbilling_rowError{color:var(--dsw-alias-state-error-primary);margin-top:4px;font-size:12px;line-height:18px}';
|
|
144
|
+
|
|
145
|
+
const ROOT_CLASS = 'dshbilling_root';
|
|
146
|
+
const PILL_CLASS = 'dshbilling_pill';
|
|
147
|
+
const GROUP_CLASS = 'dshbilling_group';
|
|
148
|
+
const SEP_CLASS = 'dshbilling_sep';
|
|
149
|
+
const ROW_CLASS = 'dshbilling_row';
|
|
150
|
+
const ROW_TEXT_CLASS = 'dshbilling_rowText';
|
|
151
|
+
const ROW_TITLE_CLASS = 'dshbilling_rowTitle';
|
|
152
|
+
const ROW_DESC_CLASS = 'dshbilling_rowDesc';
|
|
153
|
+
const ROW_ERROR_CLASS = 'dshbilling_rowError';
|
|
154
|
+
|
|
155
|
+
/** 把插件自有样式注入为全局样式(factory 物化时执行一次)。 */
|
|
156
|
+
function injectStyles() {
|
|
157
|
+
if (typeof document === 'undefined') return;
|
|
158
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG_ID) + ']') !== null) return;
|
|
159
|
+
const tag = document.createElement('style');
|
|
160
|
+
tag.dataset.plugin = 'dsh-plugin-billing';
|
|
161
|
+
tag.dataset.pluginCss = CSS_TAG_ID;
|
|
162
|
+
tag.textContent = CSS;
|
|
163
|
+
document.head.appendChild(tag);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 每个轮次的第一个 assistant 节点时间即该轮发生时刻;缺失时退化为“现在”。
|
|
168
|
+
* @param node - assistant 节点。
|
|
169
|
+
* @returns epoch 毫秒。
|
|
170
|
+
*/
|
|
171
|
+
function nodeTime(node) {
|
|
172
|
+
if (typeof node.time === 'number' && Number.isFinite(node.time)) return node.time;
|
|
173
|
+
const timing = node.timing;
|
|
174
|
+
if (timing !== undefined && typeof timing.completedTime === 'number') return timing.completedTime;
|
|
175
|
+
return Date.now();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 稳定的空数组:选择器返回它以避免每次产生新引用导致的重渲染循环。 */
|
|
179
|
+
const EMPTY_NODES = [];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 从会话 store 里取聊天节点,跨 dsh 版本:
|
|
183
|
+
* - **0.1.1**:座位是 `useSession`,store 形状是 `s.chat.legacy.nodes`
|
|
184
|
+
* - **0.1.5+**:统计行移到 ui-chat,座位改名 `useChat`,形状变成 `s.legacy.nodes`
|
|
185
|
+
* 返回的必须是稳定引用(真实数组或 EMPTY_NODES),否则会触发无休止重渲染。
|
|
186
|
+
* @param state - 会话 store 快照。
|
|
187
|
+
* @returns 节点数组。
|
|
188
|
+
*/
|
|
189
|
+
function selectNodes(state) {
|
|
190
|
+
if (state === null || typeof state !== 'object') return EMPTY_NODES;
|
|
191
|
+
let legacy = state.legacy;
|
|
192
|
+
if (legacy === undefined) {
|
|
193
|
+
const chat = state.chat;
|
|
194
|
+
legacy = chat !== null && typeof chat === 'object' ? chat.legacy : undefined;
|
|
195
|
+
}
|
|
196
|
+
if (legacy === null || typeof legacy !== 'object') return EMPTY_NODES;
|
|
197
|
+
return Array.isArray(legacy.nodes) ? legacy.nodes : EMPTY_NODES;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 把已加载节点按轮次折叠成用量桶。
|
|
202
|
+
* @param nodes - `chat.legacy.nodes`。
|
|
203
|
+
* @returns `{ perTurn: Map<number, {at, buckets}>, lastTurn: number|null, sessionBuckets }`。
|
|
204
|
+
*/
|
|
205
|
+
function foldTurns(nodes) {
|
|
206
|
+
const perTurn = new Map();
|
|
207
|
+
if (!Array.isArray(nodes)) nodes = [];
|
|
208
|
+
let lastTurn = null;
|
|
209
|
+
let sessionBuckets = { cacheMiss: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
210
|
+
for (const node of nodes) {
|
|
211
|
+
if (node === null || typeof node !== 'object' || node.kind !== 'assistant') continue;
|
|
212
|
+
const turn = node.turn;
|
|
213
|
+
if (typeof turn !== 'number' || !Number.isFinite(turn)) continue;
|
|
214
|
+
const buckets = bucketsOf(node.usage);
|
|
215
|
+
sessionBuckets = addBuckets(sessionBuckets, buckets);
|
|
216
|
+
let entry = perTurn.get(turn);
|
|
217
|
+
if (entry === undefined) {
|
|
218
|
+
entry = { at: nodeTime(node), buckets: buckets };
|
|
219
|
+
perTurn.set(turn, entry);
|
|
220
|
+
} else {
|
|
221
|
+
entry.at = Math.max(entry.at, nodeTime(node));
|
|
222
|
+
entry.buckets = addBuckets(entry.buckets, buckets);
|
|
223
|
+
}
|
|
224
|
+
if (lastTurn === null || turn > lastTurn) lastTurn = turn;
|
|
225
|
+
}
|
|
226
|
+
return { perTurn: perTurn, lastTurn: lastTurn, sessionBuckets: sessionBuckets };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 金额显示:小额保留足够有效位,避免“本轮 ¥0.00”这种无信息输出。
|
|
231
|
+
* @param value - 非负金额。
|
|
232
|
+
* @returns 形如 `¥0.0321`。
|
|
233
|
+
*/
|
|
234
|
+
function formatMoney(value) {
|
|
235
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '¥0';
|
|
236
|
+
if (value < 0.01) return '¥' + value.toFixed(4);
|
|
237
|
+
if (value < 1) return '¥' + value.toFixed(3);
|
|
238
|
+
return '¥' + value.toFixed(2);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** token 数紧凑显示:1234 → 1.2K。 */
|
|
242
|
+
function formatTokens(value) {
|
|
243
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '0';
|
|
244
|
+
if (value < 1000) return String(Math.round(value));
|
|
245
|
+
if (value < 1e6) {
|
|
246
|
+
const k = value / 1000;
|
|
247
|
+
return (k < 100 ? Math.round(k * 10) / 10 : Math.round(k)) + 'K';
|
|
248
|
+
}
|
|
249
|
+
const m = value / 1e6;
|
|
250
|
+
return (m < 100 ? Math.round(m * 10) / 10 : Math.round(m)) + 'M';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 单价显示保留 3 位,避免 0.05 被写成 0.1。 */
|
|
254
|
+
function formatRate(value) {
|
|
255
|
+
return String(Math.round(value * 1000) / 1000);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 拉取余额摘要;轮次变化时立即重拉,另外按 `REFRESH_MS` 轮询。
|
|
260
|
+
*
|
|
261
|
+
* 带上 `session=<会话 id>`,让 Host 侧能按**该会话的真实路由**计价,而不是部署默认路由
|
|
262
|
+
* ——本机上默认路由是 v4-pro、本会话跑的是 v4-flash,单价差 3 倍。
|
|
263
|
+
* @param sessionId - 当前会话 id(来自 slot 的 inject 回调);缺省时不带该参数。
|
|
264
|
+
* @param turnKey - 轮次指纹,变化即触发重拉。
|
|
265
|
+
* @returns `{ phase, data|message }`。
|
|
266
|
+
*/
|
|
267
|
+
function useSummary(sessionId, turnKey) {
|
|
268
|
+
const [state, setState] = react.useState({ phase: 'loading' });
|
|
269
|
+
// 价格表被更新后立刻重算,而不是等下一轮 60 秒轮询。
|
|
270
|
+
const epoch = usePricesEpoch();
|
|
271
|
+
react.useEffect(
|
|
272
|
+
function () {
|
|
273
|
+
let alive = true;
|
|
274
|
+
const url =
|
|
275
|
+
typeof sessionId === 'string' && sessionId !== ''
|
|
276
|
+
? SUMMARY_URL + '?session=' + encodeURIComponent(sessionId)
|
|
277
|
+
: SUMMARY_URL;
|
|
278
|
+
const load = function () {
|
|
279
|
+
fetch(url, { headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
280
|
+
.then(function (response) {
|
|
281
|
+
return response.json();
|
|
282
|
+
})
|
|
283
|
+
.then(function (data) {
|
|
284
|
+
if (alive) setState({ phase: 'ready', data: data });
|
|
285
|
+
})
|
|
286
|
+
.catch(function (error) {
|
|
287
|
+
if (alive) setState({ phase: 'error', message: String((error && error.message) || error) });
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
load();
|
|
291
|
+
const timer = setInterval(load, REFRESH_MS);
|
|
292
|
+
return function () {
|
|
293
|
+
alive = false;
|
|
294
|
+
clearInterval(timer);
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
[sessionId, turnKey, epoch],
|
|
298
|
+
);
|
|
299
|
+
return state;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 视图装配(纯函数,便于自检覆盖):把折叠后的用量与余额摘要转成
|
|
304
|
+
* 「一行分组 + hover 明细」。
|
|
305
|
+
* @param folded - `foldTurns()` 的结果。
|
|
306
|
+
* @param summary - `useSummary()` 的状态:`{ phase, data?, message? }`。
|
|
307
|
+
* @param t - 绑定到本插件命名空间的翻译函数。
|
|
308
|
+
* @returns `{ groups, detail }`;两者为空表示整行不渲染。
|
|
309
|
+
*/
|
|
310
|
+
function composeView(folded, summary, t) {
|
|
311
|
+
// Host 侧报告的当前路由模型,用它查单价表。
|
|
312
|
+
const data = summary.phase === 'ready' && summary.data && summary.data.ok === true ? summary.data : null;
|
|
313
|
+
const model =
|
|
314
|
+
data !== null && data.model !== null && typeof data.model === 'object' ? data.model.model : null;
|
|
315
|
+
|
|
316
|
+
const turnEntry = folded.lastTurn === null ? null : folded.perTurn.get(folded.lastTurn) || null;
|
|
317
|
+
// 价格表:优先用 Host 下发的(可热更新),缺失时用 bundle 内联的兜底表。
|
|
318
|
+
const table = priceTable(data);
|
|
319
|
+
const turnRates = turnEntry === null || model === null ? undefined : ratesFor(model, turnEntry.at, table);
|
|
320
|
+
const turnCost = turnEntry === null || turnRates === undefined ? null : costOf(turnEntry.buckets, turnRates);
|
|
321
|
+
|
|
322
|
+
// 会话总额:逐轮按各自时刻的单价计价,跨高低峰也准确。
|
|
323
|
+
let sessionCost = null;
|
|
324
|
+
let sessionRates;
|
|
325
|
+
if (model !== null && folded.perTurn.size > 0) {
|
|
326
|
+
let total = 0;
|
|
327
|
+
let priced = true;
|
|
328
|
+
folded.perTurn.forEach(function (entry) {
|
|
329
|
+
const rates = ratesFor(model, entry.at, table);
|
|
330
|
+
if (rates === undefined) {
|
|
331
|
+
priced = false;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
sessionRates = rates;
|
|
335
|
+
total += costOf(entry.buckets, rates);
|
|
336
|
+
});
|
|
337
|
+
sessionCost = priced ? total : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const groups = [];
|
|
341
|
+
|
|
342
|
+
// 余额组:接口不可用时保留占位,让「配置没生效」看得见,而不是静默消失。
|
|
343
|
+
if (data !== null && data.balance && data.balance.state === 'ok') {
|
|
344
|
+
groups.push(t('balance', { amount: formatMoney(Number(data.balance.total)) }));
|
|
345
|
+
} else {
|
|
346
|
+
groups.push(t('balance.unavailable'));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (turnCost !== null) groups.push(t('turn', { amount: formatMoney(turnCost) }));
|
|
350
|
+
else if (turnEntry !== null && model !== null) groups.push(t('turn', { amount: t('turn.unpriced') }));
|
|
351
|
+
|
|
352
|
+
if (sessionCost !== null && folded.perTurn.size > 1) {
|
|
353
|
+
groups.push(t('session', { amount: formatMoney(sessionCost) }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const detail = [];
|
|
357
|
+
if (data !== null && data.balance && data.balance.state === 'ok') {
|
|
358
|
+
detail.push(
|
|
359
|
+
t('detail.balance', {
|
|
360
|
+
total: data.balance.total,
|
|
361
|
+
granted: data.balance.granted,
|
|
362
|
+
toppedUp: data.balance.toppedUp,
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
} else if (summary.phase === 'ready' && data === null && summary.data && summary.data.error) {
|
|
366
|
+
detail.push(t('detail.balanceUnavailable', { reason: summary.data.error.message }));
|
|
367
|
+
} else if (summary.phase === 'error') {
|
|
368
|
+
detail.push(t('detail.balanceUnavailable', { reason: summary.message }));
|
|
369
|
+
} else if (summary.phase === 'loading') {
|
|
370
|
+
detail.push(t('balance.unavailable'));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (model !== null) {
|
|
374
|
+
// 标明计价依据:会话路由是精确值,部署默认路由只是兜底。
|
|
375
|
+
const fromSession = data !== null && data.model && data.model.source === 'session';
|
|
376
|
+
detail.push(t(fromSession ? 'detail.modelSession' : 'detail.modelDefault', { model: model }));
|
|
377
|
+
}
|
|
378
|
+
// 注意用 `!= null`:未登记单价的模型会让两个 rates 都是 undefined/null,
|
|
379
|
+
// 只判 `!== undefined` 会在 null 上取属性而抛错,从而整行渲染失败。
|
|
380
|
+
const rateFor = turnRates !== undefined && turnRates !== null ? turnRates : sessionRates;
|
|
381
|
+
if (rateFor != null && data !== null) {
|
|
382
|
+
detail.push(
|
|
383
|
+
t('detail.rate', {
|
|
384
|
+
period: rateFor.peak ? t('detail.ratePeak') : t('detail.rateOffPeak'),
|
|
385
|
+
currency: data.currency || CURRENCY,
|
|
386
|
+
hit: formatRate(rateFor.cacheHit),
|
|
387
|
+
miss: formatRate(rateFor.cacheMiss),
|
|
388
|
+
out: formatRate(rateFor.output),
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (turnEntry !== null) {
|
|
393
|
+
detail.push(
|
|
394
|
+
t('detail.turnTokens', {
|
|
395
|
+
miss: formatTokens(turnEntry.buckets.cacheMiss + turnEntry.buckets.cacheWrite),
|
|
396
|
+
hit: formatTokens(turnEntry.buckets.cacheRead),
|
|
397
|
+
out: formatTokens(turnEntry.buckets.output),
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (folded.perTurn.size > 1) {
|
|
402
|
+
detail.push(
|
|
403
|
+
t('detail.sessionTokens', {
|
|
404
|
+
miss: formatTokens(folded.sessionBuckets.cacheMiss + folded.sessionBuckets.cacheWrite),
|
|
405
|
+
hit: formatTokens(folded.sessionBuckets.cacheRead),
|
|
406
|
+
out: formatTokens(folded.sessionBuckets.output),
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
detail.push(t('detail.scope'));
|
|
410
|
+
}
|
|
411
|
+
detail.push(
|
|
412
|
+
t('detail.source', {
|
|
413
|
+
source: (data !== null && typeof data.priceSource === 'string' && data.priceSource !== '' ? data.priceSource : PRICE_SOURCE),
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return { groups: groups, detail: detail };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 席位没给 `t` 时的兜底翻译:直接用 zh 字典。
|
|
422
|
+
* 宁可这一行显示中文,也不要因为少一个席位就整行不渲染。
|
|
423
|
+
* @param key - 字典键。
|
|
424
|
+
* @param params - 插值参数。
|
|
425
|
+
* @returns 文案;键不存在时原样返回键名。
|
|
426
|
+
*/
|
|
427
|
+
function fallbackTranslate(key, params) {
|
|
428
|
+
const template = Object.prototype.hasOwnProperty.call(zh, key) ? zh[key] : key;
|
|
429
|
+
if (params === undefined) return template;
|
|
430
|
+
return String(template).replace(/\{(\w+)\}/g, function (match, name) {
|
|
431
|
+
return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 余额与金额行。
|
|
437
|
+
* @param props - slot 标准席位:`useChat`(0.1.5+)或 `useSession`(0.1.1)、`t`,以及注册时
|
|
438
|
+
* `inject` 回调注入的 `sessionId`。
|
|
439
|
+
* @returns 单行金额条;无任何可显示内容时为 null。
|
|
440
|
+
*/
|
|
441
|
+
function BillingLine(props) {
|
|
442
|
+
// 座位名跨版本不同:0.1.1 是 useSession,0.1.5+ 是 useChat(同一次会话数据)。
|
|
443
|
+
const useChat = typeof props.useChat === 'function' ? props.useChat : props.useSession;
|
|
444
|
+
const t = typeof props.t === 'function' ? props.t : fallbackTranslate;
|
|
445
|
+
// 两个座位都没有时静默不渲染,而不是抛错把这一行变成红色错误边界。
|
|
446
|
+
if (typeof useChat !== 'function') return null;
|
|
447
|
+
// 订阅节点数组,轮次与用量变化都会重渲染(与 StatsLine 同源)。
|
|
448
|
+
const nodes = useChat(selectNodes);
|
|
449
|
+
const folded = react.useMemo(
|
|
450
|
+
function () {
|
|
451
|
+
return foldTurns(nodes);
|
|
452
|
+
},
|
|
453
|
+
[nodes],
|
|
454
|
+
);
|
|
455
|
+
const summary = useSummary(props.sessionId, folded.lastTurn);
|
|
456
|
+
const view = composeView(folded, summary, t);
|
|
457
|
+
|
|
458
|
+
if (view.groups.length === 0) return null;
|
|
459
|
+
|
|
460
|
+
const children = [];
|
|
461
|
+
view.groups.forEach(function (group, index) {
|
|
462
|
+
if (index > 0) {
|
|
463
|
+
// 原生胶囊内部字段之间就是这个中点,间距也照抄 `margin:0 6px`。
|
|
464
|
+
children.push(
|
|
465
|
+
jsxRuntime.jsx('span', { className: SEP_CLASS, 'aria-hidden': true, children: '·' }, 'sep-' + index),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
children.push(jsxRuntime.jsx('span', { className: GROUP_CLASS, children: group }, 'group-' + index));
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return jsxRuntime.jsx(primitives.Tooltip, {
|
|
472
|
+
label: view.detail.join(' · '),
|
|
473
|
+
side: 'top',
|
|
474
|
+
delayMs: 400,
|
|
475
|
+
children: jsxRuntime.jsx('div', {
|
|
476
|
+
className: ROOT_CLASS,
|
|
477
|
+
children: jsxRuntime.jsx('span', { className: PILL_CLASS, children: children }, 'pill'),
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 设置行:显示当前单价来源,并提供**一键更新**。
|
|
484
|
+
*
|
|
485
|
+
* 为什么把 CLI 的能力搬到这里:价格是"会过期"的东西,而"开终端、进目录、跑脚本、
|
|
486
|
+
* 看差异、再决定"对日常来说太重了。按钮点一下,Host 侧做同一套抓取与四条结构自校验,
|
|
487
|
+
* 成功就写本机覆盖文件(不动仓库),失败就把原因原样显示在这一行上。
|
|
488
|
+
* @param props - 座位提供的 props;`t` 由 `locale` 席位给出。
|
|
489
|
+
* @returns 设置行元素。
|
|
490
|
+
*/
|
|
491
|
+
function PricesRow(props) {
|
|
492
|
+
const t = typeof props.t === 'function' ? props.t : fallbackTranslate;
|
|
493
|
+
const [state, setState] = react.useState({ phase: 'loading', info: null, result: null });
|
|
494
|
+
|
|
495
|
+
react.useEffect(function () {
|
|
496
|
+
let alive = true;
|
|
497
|
+
fetch(PRICES_URL, { headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
498
|
+
.then(function (response) {
|
|
499
|
+
return response.json();
|
|
500
|
+
})
|
|
501
|
+
.then(function (data) {
|
|
502
|
+
if (alive) setState({ phase: 'ready', info: data, result: null });
|
|
503
|
+
})
|
|
504
|
+
.catch(function (error) {
|
|
505
|
+
if (alive) {
|
|
506
|
+
setState({ phase: 'ready', info: null, result: { ok: false, message: String(error.message || error) } });
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
return function () {
|
|
510
|
+
alive = false;
|
|
511
|
+
};
|
|
512
|
+
}, []);
|
|
513
|
+
|
|
514
|
+
const refresh = react.useCallback(function () {
|
|
515
|
+
setState(function (prev) {
|
|
516
|
+
return { phase: 'busy', info: prev.info, result: null };
|
|
517
|
+
});
|
|
518
|
+
// 返回 promise:调用方(测试、上层按钮包装)能等到真正完成。
|
|
519
|
+
return fetch(PRICES_REFRESH_URL, { method: 'POST', headers: { accept: 'application/json' } })
|
|
520
|
+
.then(function (response) {
|
|
521
|
+
return response.json().then(function (data) {
|
|
522
|
+
return { status: response.status, data: data };
|
|
523
|
+
});
|
|
524
|
+
})
|
|
525
|
+
.then(function (outcome) {
|
|
526
|
+
const data = outcome.data || {};
|
|
527
|
+
if (data.ok === true) {
|
|
528
|
+
setState({
|
|
529
|
+
phase: 'ready',
|
|
530
|
+
info: data,
|
|
531
|
+
result: { ok: true, changed: Array.isArray(data.changed) ? data.changed : [] },
|
|
532
|
+
});
|
|
533
|
+
// 让金额行立刻用新单价重算,不必等它自己那一分钟一次的轮询。
|
|
534
|
+
bumpPricesEpoch();
|
|
535
|
+
} else {
|
|
536
|
+
setState({
|
|
537
|
+
phase: 'ready',
|
|
538
|
+
info: data,
|
|
539
|
+
result: { ok: false, message: String(data.message || 'HTTP ' + outcome.status) },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
.catch(function (error) {
|
|
544
|
+
setState({
|
|
545
|
+
phase: 'ready',
|
|
546
|
+
info: null,
|
|
547
|
+
result: { ok: false, message: String(error.message || error) },
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}, []);
|
|
551
|
+
|
|
552
|
+
const info = state.info;
|
|
553
|
+
const busy = state.phase === 'busy';
|
|
554
|
+
const lines = [];
|
|
555
|
+
if (info !== null && typeof info.source === 'string' && info.source !== '') {
|
|
556
|
+
lines.push(
|
|
557
|
+
jsxRuntime.jsx(
|
|
558
|
+
'div',
|
|
559
|
+
{ className: ROW_DESC_CLASS, children: t('prices.description', { source: info.source }) },
|
|
560
|
+
'source',
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (info !== null && typeof info.updatedAt === 'string' && info.updatedAt !== '') {
|
|
565
|
+
lines.push(
|
|
566
|
+
jsxRuntime.jsx('div', { className: ROW_DESC_CLASS, children: t('prices.updatedAt', { date: info.updatedAt }) }, 'date'),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (state.result !== null) {
|
|
570
|
+
const result = state.result;
|
|
571
|
+
const text =
|
|
572
|
+
result.ok === true
|
|
573
|
+
? result.changed.length > 0
|
|
574
|
+
? t('prices.ok', { models: result.changed.join('、') })
|
|
575
|
+
: t('prices.none')
|
|
576
|
+
: t('prices.failed', { reason: result.message });
|
|
577
|
+
lines.push(
|
|
578
|
+
jsxRuntime.jsx(
|
|
579
|
+
'div',
|
|
580
|
+
{ className: result.ok === true ? ROW_DESC_CLASS : ROW_ERROR_CLASS, role: 'status', children: text },
|
|
581
|
+
'result',
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return jsxRuntime.jsxs('div', {
|
|
587
|
+
className: ROW_CLASS,
|
|
588
|
+
children: [
|
|
589
|
+
jsxRuntime.jsxs('div', {
|
|
590
|
+
className: ROW_TEXT_CLASS,
|
|
591
|
+
children: [jsxRuntime.jsx('div', { className: ROW_TITLE_CLASS, children: t('prices.title') }, 'title'), ...lines],
|
|
592
|
+
}),
|
|
593
|
+
jsxRuntime.jsx(
|
|
594
|
+
primitives.Button,
|
|
595
|
+
{
|
|
596
|
+
variant: 'outline',
|
|
597
|
+
disabled: busy,
|
|
598
|
+
onClick: refresh,
|
|
599
|
+
children: busy ? t('prices.busy') : t('prices.action'),
|
|
600
|
+
},
|
|
601
|
+
'button',
|
|
602
|
+
),
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** 必需服务:slot 注册表与 locale(文案席位)。 */
|
|
608
|
+
const inject = ['slots', 'locale'];
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 注册文案字典、金额行席位与价格表设置行。
|
|
612
|
+
* @param ctx - 浏览器侧 cordis 上下文。
|
|
613
|
+
*/
|
|
614
|
+
function apply(ctx) {
|
|
615
|
+
injectStyles();
|
|
616
|
+
ctx.effect(function () {
|
|
617
|
+
return ctx.locale.register(NS, { zh: zh, en: en });
|
|
618
|
+
}, 'billing: dictionaries');
|
|
619
|
+
ctx.slots.inject('conversation.composer.dock', function () {
|
|
620
|
+
return ctx.slots.register(
|
|
621
|
+
{
|
|
622
|
+
name: 'conversation.composer.dock',
|
|
623
|
+
id: 'billing',
|
|
624
|
+
order: 20,
|
|
625
|
+
locale: NS,
|
|
626
|
+
// slot 把当前会话 id 传给 inject 回调;转成 props 交给组件,
|
|
627
|
+
// 用于向 Host 索要「该会话真实路由」而不是部署默认路由。
|
|
628
|
+
inject: function (sessionId) {
|
|
629
|
+
return { sessionId: typeof sessionId === 'string' ? sessionId : undefined };
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
BillingLine,
|
|
633
|
+
);
|
|
634
|
+
});
|
|
635
|
+
// 设置里的价格行:排在 外观 / 字号大小 / 护眼 之后。
|
|
636
|
+
ctx.slots.inject('settings.general.item', function () {
|
|
637
|
+
return ctx.slots.register(
|
|
638
|
+
{ name: 'settings.general.item', id: 'billing-prices', order: 13, locale: NS },
|
|
639
|
+
PricesRow,
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
}
|