@iammralmost/usage-hud 0.0.0-stage → 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 +21 -0
- package/README.md +45 -2
- package/client.js +863 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +11 -0
- package/index.js +498 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -4
- package/prices.json +17 -0
package/client.js
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage HUD — a permanently visible readout in the composer dock.
|
|
3
|
+
*
|
|
4
|
+
* It answers the question "how much have I spent, and how full is the context"
|
|
5
|
+
* without opening Settings or hovering a pill: session token totals, cache-hit
|
|
6
|
+
* ratio, context occupancy and — when an account credential is stored — the
|
|
7
|
+
* Platform balance, all on one line under the composer.
|
|
8
|
+
*
|
|
9
|
+
* Every number is read from the Client services that already own it:
|
|
10
|
+
* `tokenUsage` and `contextPressure` are session projections, and the balance is
|
|
11
|
+
* a Host Remote call shared through a small module-level cache so that switching
|
|
12
|
+
* sessions or remounting the dock does not re-hit the network.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: '@iammralmost/usage-hud',
|
|
17
|
+
factory(require) {
|
|
18
|
+
const React = require('react');
|
|
19
|
+
const h = React.createElement;
|
|
20
|
+
|
|
21
|
+
const NS = '@iammralmost/usage-hud';
|
|
22
|
+
const STYLE_TAG = NS + '/usage-hud.css';
|
|
23
|
+
const BALANCE_TTL_MS = 60000;
|
|
24
|
+
const TODAY_TTL_MS = 90000;
|
|
25
|
+
const FALLBACK_CLIENT_VERSION = '0.2.1-rc.5';
|
|
26
|
+
|
|
27
|
+
/** Copy for both shipped locales; slot copy re-reads it on a language switch. */
|
|
28
|
+
const COPY = {
|
|
29
|
+
en: {
|
|
30
|
+
'hud.tokens': '{value} tok',
|
|
31
|
+
'hud.cache': 'cache {percent}%',
|
|
32
|
+
'hud.context': 'context {percent}%',
|
|
33
|
+
'hud.balance': 'balance {amount}',
|
|
34
|
+
'hud.placeholder': 'usage —',
|
|
35
|
+
'hud.balanceUnavailable': 'balance —',
|
|
36
|
+
'hud.aria': 'Account balance. Activate to refresh.',
|
|
37
|
+
'panel.title': 'Today',
|
|
38
|
+
'panel.requests': '{count} requests',
|
|
39
|
+
'panel.peak': 'Peak',
|
|
40
|
+
'panel.peakWindow': 'the rest of the day',
|
|
41
|
+
'panel.offPeakNote': '{percent}% of peak rate',
|
|
42
|
+
'panel.offPeak': 'Off-peak',
|
|
43
|
+
'panel.total': 'Total',
|
|
44
|
+
'panel.hours': 'Usage by hour',
|
|
45
|
+
'panel.hourTip': '{hour}:00 · {tokens} tok',
|
|
46
|
+
'panel.hourCost': '{amount} · {window}',
|
|
47
|
+
'panel.in': 'Uncached input',
|
|
48
|
+
'panel.cacheRead': 'Cached input',
|
|
49
|
+
'panel.cacheWrite': 'Cache write',
|
|
50
|
+
'panel.out': 'Output',
|
|
51
|
+
'panel.empty': 'No usage recorded today.',
|
|
52
|
+
'panel.loading': 'Loading…',
|
|
53
|
+
'panel.updated': 'updated {time}',
|
|
54
|
+
'panel.error': 'Could not read today’s usage.',
|
|
55
|
+
'panel.refresh': 'Refresh',
|
|
56
|
+
'panel.close': 'Close',
|
|
57
|
+
'panel.fallback': 'Fees use {model} prices. Add your model to prices.json for exact cost.',
|
|
58
|
+
'turn.cost': '{amount}',
|
|
59
|
+
'turn.costTitle': 'This turn cost {amount} ({tokens} tok)',
|
|
60
|
+
'detail.title': 'Usage',
|
|
61
|
+
'detail.tokens': 'Session total: {count} tok',
|
|
62
|
+
'detail.input': 'Uncached input: {count}',
|
|
63
|
+
'detail.cacheRead': 'Cached input: {count}',
|
|
64
|
+
'detail.cacheWrite': 'Cache write: {count}',
|
|
65
|
+
'detail.output': 'Output: {count}',
|
|
66
|
+
'detail.cacheHit': 'Cache hit: {percent}%',
|
|
67
|
+
'detail.context': 'Context: {percent}% ({used} / {window})',
|
|
68
|
+
'detail.balance': 'Balance: {amount}',
|
|
69
|
+
'detail.bonus': 'Bonus credit: {amount}',
|
|
70
|
+
},
|
|
71
|
+
zh: {
|
|
72
|
+
'hud.tokens': '{value} tok',
|
|
73
|
+
'hud.cache': '缓存 {percent}%',
|
|
74
|
+
'hud.context': '上下文 {percent}%',
|
|
75
|
+
'hud.balance': '余额 {amount}',
|
|
76
|
+
'hud.placeholder': '用量 —',
|
|
77
|
+
'hud.balanceUnavailable': '余额 —',
|
|
78
|
+
'hud.aria': '账户余额,点击查看今日用量。',
|
|
79
|
+
'panel.title': '今日用量',
|
|
80
|
+
'panel.requests': '{count} 次请求',
|
|
81
|
+
'panel.peak': '忙时',
|
|
82
|
+
'panel.peakWindow': '其余时段',
|
|
83
|
+
'panel.offPeakNote': '{percent}% 价',
|
|
84
|
+
'panel.offPeak': '闲时',
|
|
85
|
+
'panel.total': '合计',
|
|
86
|
+
'panel.hours': '按小时分布',
|
|
87
|
+
'panel.hourTip': '{hour}:00 · {tokens} tok',
|
|
88
|
+
'panel.hourCost': '{amount} · {window}',
|
|
89
|
+
'panel.in': '未缓存输入',
|
|
90
|
+
'panel.cacheRead': '缓存读取',
|
|
91
|
+
'panel.cacheWrite': '缓存写入',
|
|
92
|
+
'panel.out': '输出',
|
|
93
|
+
'panel.empty': '今天还没有用量记录。',
|
|
94
|
+
'panel.loading': '读取中…',
|
|
95
|
+
'panel.updated': '更新于 {time}',
|
|
96
|
+
'panel.error': '读取今日用量失败。',
|
|
97
|
+
'panel.refresh': '刷新',
|
|
98
|
+
'panel.close': '关闭',
|
|
99
|
+
'panel.fallback': '费用按 {model} 单价估算;在 prices.json 里加上你的模型即可精确计费。',
|
|
100
|
+
'turn.cost': '{amount}',
|
|
101
|
+
'turn.costTitle': '本轮花费 {amount}({tokens} tok)',
|
|
102
|
+
'detail.title': '用量',
|
|
103
|
+
'detail.tokens': '会话合计:{count} tok',
|
|
104
|
+
'detail.input': '未缓存输入:{count}',
|
|
105
|
+
'detail.cacheRead': '缓存读取:{count}',
|
|
106
|
+
'detail.cacheWrite': '缓存写入:{count}',
|
|
107
|
+
'detail.output': '输出:{count}',
|
|
108
|
+
'detail.cacheHit': '缓存命中:{percent}%',
|
|
109
|
+
'detail.context': '上下文:{percent}%({used} / {window})',
|
|
110
|
+
'detail.balance': '余额:{amount}',
|
|
111
|
+
'detail.bonus': '赠送额度:{amount}',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const CSS = [
|
|
116
|
+
'.uh-row{display:inline-flex;align-items:center;justify-content:center;gap:0;max-width:100%;flex-wrap:wrap}',
|
|
117
|
+
'.uh-pill{box-sizing:border-box;border-radius:var(--dsw-radius-sm);max-width:100%;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:var(--dsh-content-font-size-secondary,13px);font-variant-numeric:tabular-nums;line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:nowrap;background:0 0;border:none;align-items:center;gap:6px;padding:1px 8px;display:inline-flex;cursor:pointer}',
|
|
118
|
+
'.uh-pill:hover,.uh-pill:focus-visible{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
119
|
+
'.uh-pill:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:1px}',
|
|
120
|
+
'.uh-pill svg{flex:none;width:14px;height:14px}',
|
|
121
|
+
'.uh-label{text-overflow:ellipsis;min-width:0;overflow:hidden}',
|
|
122
|
+
'.uh-sep{color:var(--dsw-alias-label-tertiary);opacity:.55;padding:0 1px;user-select:none}',
|
|
123
|
+
'.uh-strong{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
|
|
124
|
+
'.uh-panel{position:fixed;z-index:1100;box-sizing:border-box;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;border:0;padding:12px;font-size:12px;line-height:18px;max-height:min(60vh,440px);overflow-y:auto}',
|
|
125
|
+
'.uh-panel-head{display:flex;align-items:baseline;gap:8px;margin-bottom:8px}',
|
|
126
|
+
'.uh-panel-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}',
|
|
127
|
+
'.uh-panel-sub{color:var(--dsw-alias-label-tertiary);font-size:12px;margin-left:auto;font-variant-numeric:tabular-nums}',
|
|
128
|
+
'.uh-panel-rows{display:flex;flex-direction:column;gap:4px;margin:8px 0 0}',
|
|
129
|
+
'.uh-panel-row{display:flex;align-items:baseline;justify-content:space-between;gap:12px}',
|
|
130
|
+
'.uh-panel-key{color:var(--dsw-alias-label-secondary);margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
131
|
+
'.uh-panel-strong{color:var(--dsw-alias-label-primary);font-weight:500}',
|
|
132
|
+
'.uh-panel-value{color:var(--dsw-alias-label-primary);margin:0;font-variant-numeric:tabular-nums;white-space:nowrap}',
|
|
133
|
+
'.uh-bars{display:flex;align-items:flex-end;gap:2px;height:36px;margin:10px 0 2px}',
|
|
134
|
+
'.uh-bars-wrap{position:relative}',
|
|
135
|
+
'.uh-tip{position:absolute;bottom:calc(100% + 4px);transform:translateX(-50%);pointer-events:none;white-space:nowrap;border-radius:var(--dsw-radius-md);background:var(--dsw-specific-menu);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);padding:2px 8px;font-size:12px;line-height:16px;display:flex;flex-direction:column;align-items:center;z-index:2}',
|
|
136
|
+
'.uh-tip-main{font-variant-numeric:tabular-nums}',
|
|
137
|
+
'.uh-tip-sub{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
|
|
138
|
+
/* No horizontal padding: the usage pill already pads 8px inside its own
|
|
139
|
+
box, so an unpadded fee keeps the same 16px optical gap on both sides
|
|
140
|
+
(8px pill padding + 8px row gap on the left, 8px margin + 8px row gap
|
|
141
|
+
on the right) as the clock. */
|
|
142
|
+
'.uh-cost{box-sizing:border-box;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:var(--dsh-content-font-size-secondary,13px);font-variant-numeric:tabular-nums;line-height:calc(24px + var(--dsh-content-font-delta,0px));white-space:nowrap;align-items:center;padding:1px 0;margin-right:8px;display:inline-flex;order:2}',
|
|
143
|
+
/* The host renders the usage pill and the clock inside one inline-flex box
|
|
144
|
+
that no slot reaches. Making that box box-less promotes both to flex
|
|
145
|
+
items of the action row, so `order` can seat the fee between them. */
|
|
146
|
+
'[data-clock="end"] > span:last-child{display:contents}',
|
|
147
|
+
/* Restore the 8px left offset the promoted first item lost with its parent
|
|
148
|
+
box, so the cluster keeps its original distance from the branch button. */
|
|
149
|
+
'[data-clock="end"] > span:last-child > :first-child{margin-left:8px}',
|
|
150
|
+
'[data-clock="end"] > span:last-child > :last-child{order:3}',
|
|
151
|
+
'.uh-bar{flex:1;min-width:2px;border-radius:2px;background:var(--dsw-alias-label-tertiary);opacity:.5}',
|
|
152
|
+
'.uh-bar-offpeak{background:var(--dsw-static-blue-450);opacity:.85}',
|
|
153
|
+
'.uh-panel-note{color:var(--dsw-alias-label-tertiary);margin-top:8px}',
|
|
154
|
+
'.uh-panel-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1)}',
|
|
155
|
+
'.uh-panel-button{border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);cursor:pointer;border:0;padding:2px 10px;font:inherit;line-height:20px}',
|
|
156
|
+
'.uh-panel-button:hover{color:var(--dsw-alias-label-primary)}',
|
|
157
|
+
'.uh-panel-button:disabled{opacity:.5;cursor:default}',
|
|
158
|
+
].join('');
|
|
159
|
+
|
|
160
|
+
/** @returns the Host build string used as Remote client metadata. */
|
|
161
|
+
function clientVersion() {
|
|
162
|
+
const boot = globalThis.__DSH_BOOT__;
|
|
163
|
+
const candidates = [boot?.release?.version, boot?.version, boot?.clientVersion];
|
|
164
|
+
for (const candidate of candidates) {
|
|
165
|
+
if (typeof candidate === 'string' && candidate !== '') return candidate;
|
|
166
|
+
}
|
|
167
|
+
return FALLBACK_CLIENT_VERSION;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @returns the active UI locale id, read at call time. */
|
|
171
|
+
function activeLocale(ctx) {
|
|
172
|
+
const snapshot = ctx.locale.getSnapshot();
|
|
173
|
+
return typeof snapshot.active === 'string' && snapshot.active !== '' ? snapshot.active : 'en';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const formatterCache = new Map();
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param tag - BCP 47 tag.
|
|
180
|
+
* @param options - Intl.NumberFormat options.
|
|
181
|
+
* @returns a cached formatter, falling back to English for an unknown tag.
|
|
182
|
+
*/
|
|
183
|
+
function formatter(tag, options) {
|
|
184
|
+
const key = tag + '|' + JSON.stringify(options);
|
|
185
|
+
let cached = formatterCache.get(key);
|
|
186
|
+
if (cached !== undefined) return cached;
|
|
187
|
+
try {
|
|
188
|
+
cached = new Intl.NumberFormat(tag, options);
|
|
189
|
+
} catch (_error) {
|
|
190
|
+
cached = new Intl.NumberFormat('en', options);
|
|
191
|
+
}
|
|
192
|
+
formatterCache.set(key, cached);
|
|
193
|
+
return cached;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param tag - BCP 47 tag.
|
|
198
|
+
* @param value - token count.
|
|
199
|
+
* @returns the count with locale-owned digit grouping.
|
|
200
|
+
*/
|
|
201
|
+
function exact(tag, value) {
|
|
202
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '0';
|
|
203
|
+
return formatter(tag, { maximumFractionDigits: 0 }).format(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Compact token count using the shared `common` namespace templates so the
|
|
208
|
+
* readout matches the host pills.
|
|
209
|
+
* @param value - token count.
|
|
210
|
+
* @param t - translate seat.
|
|
211
|
+
* @returns compact count, e.g. `12.3K`.
|
|
212
|
+
*/
|
|
213
|
+
function compact(value, t) {
|
|
214
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '0';
|
|
215
|
+
const scaled = (candidate) => (candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10));
|
|
216
|
+
if (value < 1000) return String(Math.round(value));
|
|
217
|
+
if (value < 1e6) return t('number.thousand', { value: scaled(value / 1e3) });
|
|
218
|
+
return t('number.million', { value: scaled(value / 1e6) });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Host-currency-formatted amount, keeping the server's own string when the
|
|
223
|
+
* currency is unknown to `Intl`.
|
|
224
|
+
* @param tag - BCP 47 tag.
|
|
225
|
+
* @param currency - ISO 4217 code reported by the Host.
|
|
226
|
+
* @param amount - decimal string reported by the Host.
|
|
227
|
+
* @returns display text.
|
|
228
|
+
*/
|
|
229
|
+
function money(tag, currency, amount) {
|
|
230
|
+
const parsed = Number(amount);
|
|
231
|
+
if (!Number.isFinite(parsed)) return String(amount);
|
|
232
|
+
try {
|
|
233
|
+
return formatter(tag, {
|
|
234
|
+
style: 'currency',
|
|
235
|
+
currency,
|
|
236
|
+
currencyDisplay: 'narrowSymbol',
|
|
237
|
+
minimumFractionDigits: 2,
|
|
238
|
+
maximumFractionDigits: 2,
|
|
239
|
+
}).format(parsed);
|
|
240
|
+
} catch (_error) {
|
|
241
|
+
return currency + ' ' + String(amount);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Balance reader shared by every mounted dock entry: one Host call serves all
|
|
247
|
+
* of them for `BALANCE_TTL_MS`, and a forced read refreshes the shared cache.
|
|
248
|
+
* @param ctx - Client root context.
|
|
249
|
+
* @returns a reader resolving to the Host balance value, or null when unavailable.
|
|
250
|
+
*/
|
|
251
|
+
function createBalanceReader(ctx) {
|
|
252
|
+
let cached;
|
|
253
|
+
let cachedAt = 0;
|
|
254
|
+
let inflight = null;
|
|
255
|
+
|
|
256
|
+
return function readBalance(force) {
|
|
257
|
+
// A Remote namespace is its own injectable service, so `remote.account`
|
|
258
|
+
// must be declared to be readable; resolve it defensively because an
|
|
259
|
+
// absent account must degrade the readout, never blank the slot entry.
|
|
260
|
+
let account;
|
|
261
|
+
try {
|
|
262
|
+
account = ctx.get('remote.account');
|
|
263
|
+
} catch (_error) {
|
|
264
|
+
return Promise.resolve(null);
|
|
265
|
+
}
|
|
266
|
+
if (account === undefined || typeof account.getBalance !== 'function') return Promise.resolve(null);
|
|
267
|
+
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
if (force !== true && cached !== undefined && now - cachedAt < BALANCE_TTL_MS) return Promise.resolve(cached);
|
|
270
|
+
if (inflight !== null) return inflight;
|
|
271
|
+
|
|
272
|
+
const metadata = {
|
|
273
|
+
version: clientVersion(),
|
|
274
|
+
locale: activeLocale(ctx),
|
|
275
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
inflight = (async () => {
|
|
279
|
+
try {
|
|
280
|
+
const result = await account.getBalance(metadata);
|
|
281
|
+
if (result === null || result === undefined || result.ok !== true) return null;
|
|
282
|
+
const value = result.value;
|
|
283
|
+
if (value === null || value === undefined || value.status !== 'ready') return null;
|
|
284
|
+
return value;
|
|
285
|
+
} catch (_error) {
|
|
286
|
+
return null;
|
|
287
|
+
} finally {
|
|
288
|
+
inflight = null;
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
|
|
292
|
+
return inflight.then((value) => {
|
|
293
|
+
cached = value;
|
|
294
|
+
cachedAt = Date.now();
|
|
295
|
+
return value;
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Per-message fees for one session, shared by every message action strip.
|
|
302
|
+
* @returns a reader resolving to `{ currency, messages }`, or null when the
|
|
303
|
+
* session log is not readable.
|
|
304
|
+
*/
|
|
305
|
+
function createSessionCostReader() {
|
|
306
|
+
const entries = new Map();
|
|
307
|
+
return function readSession(sessionId, force) {
|
|
308
|
+
if (typeof sessionId !== 'string' || sessionId === '') return Promise.resolve(null);
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
const entry = entries.get(sessionId);
|
|
311
|
+
if (force !== true && entry !== undefined) {
|
|
312
|
+
if (entry.inflight !== undefined) return entry.inflight;
|
|
313
|
+
if (now - entry.at < TODAY_TTL_MS) return Promise.resolve(entry.payload);
|
|
314
|
+
}
|
|
315
|
+
const query = '/usage-hud/session?session=' + encodeURIComponent(sessionId) + (force === true ? '&force=1' : '');
|
|
316
|
+
const request = fetch(query, { headers: { accept: 'application/json' } })
|
|
317
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
|
|
318
|
+
.then((payload) => {
|
|
319
|
+
entries.set(sessionId, { at: Date.now(), payload });
|
|
320
|
+
return payload;
|
|
321
|
+
})
|
|
322
|
+
.catch(() => {
|
|
323
|
+
entries.set(sessionId, { at: Date.now(), payload: null });
|
|
324
|
+
return null;
|
|
325
|
+
});
|
|
326
|
+
entries.set(sessionId, {
|
|
327
|
+
at: entry === undefined ? 0 : entry.at,
|
|
328
|
+
payload: entry === undefined ? null : entry.payload,
|
|
329
|
+
inflight: request,
|
|
330
|
+
});
|
|
331
|
+
return request;
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The cost chip in one assistant message's action strip. */
|
|
336
|
+
function TurnCostChip(props) {
|
|
337
|
+
const { messageId, sessionId, t, getLocale, readSession } = props;
|
|
338
|
+
const tr = typeof t === 'function' ? t : (key) => key;
|
|
339
|
+
const tag = typeof getLocale === 'function' ? getLocale() : 'en';
|
|
340
|
+
const [shown, setShown] = React.useState(null);
|
|
341
|
+
|
|
342
|
+
React.useEffect(() => {
|
|
343
|
+
if (typeof readSession !== 'function' || typeof messageId !== 'string' || typeof sessionId !== 'string') return undefined;
|
|
344
|
+
let live = true;
|
|
345
|
+
const load = async () => {
|
|
346
|
+
const first = await readSession(sessionId, false);
|
|
347
|
+
if (!live) return;
|
|
348
|
+
const hit = first?.messages?.[messageId];
|
|
349
|
+
if (hit !== undefined) {
|
|
350
|
+
setShown({ entry: hit, currency: first.currency ?? 'CNY' });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// A reply that settled after the cache was filled: read once more.
|
|
354
|
+
const forced = await readSession(sessionId, true);
|
|
355
|
+
if (!live) return;
|
|
356
|
+
const again = forced?.messages?.[messageId];
|
|
357
|
+
if (again !== undefined) setShown({ entry: again, currency: forced.currency ?? 'CNY' });
|
|
358
|
+
};
|
|
359
|
+
load().catch(() => {});
|
|
360
|
+
return () => {
|
|
361
|
+
live = false;
|
|
362
|
+
};
|
|
363
|
+
}, [messageId, sessionId]);
|
|
364
|
+
|
|
365
|
+
if (shown === null) return null;
|
|
366
|
+
const amount = fee(tag, shown.currency, shown.entry.turnFee ?? shown.entry.fee);
|
|
367
|
+
return h(
|
|
368
|
+
'span',
|
|
369
|
+
{
|
|
370
|
+
className: 'uh-cost',
|
|
371
|
+
title: tr('turn.costTitle', {
|
|
372
|
+
amount,
|
|
373
|
+
tokens: exact(tag, shown.entry.turnTokens ?? shown.entry.tokens),
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
tr('turn.cost', { amount }),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* @param usage - `tokenUsage` projection value.
|
|
382
|
+
* @returns billed prompt tokens, the denominator of the cache-hit ratio.
|
|
383
|
+
*/
|
|
384
|
+
function billedInput(usage) {
|
|
385
|
+
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param pressure - `contextPressure` projection value.
|
|
390
|
+
* @returns occupancy, or null while the window is still unknown.
|
|
391
|
+
*/
|
|
392
|
+
function occupancy(pressure) {
|
|
393
|
+
const used = pressure?.projectedTokens ?? pressure?.pressureTokens;
|
|
394
|
+
if (used === undefined || pressure?.contextWindow === undefined) return null;
|
|
395
|
+
return {
|
|
396
|
+
percent: Math.min(100, Math.round((used / pressure.contextWindow) * 100)),
|
|
397
|
+
used,
|
|
398
|
+
window: pressure.contextWindow,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** @returns a two-decimal money string for a computed fee. */
|
|
403
|
+
function fee(tag, currency, value) {
|
|
404
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
|
|
405
|
+
try {
|
|
406
|
+
return formatter(tag, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
|
|
407
|
+
} catch (_error) {
|
|
408
|
+
return currency + ' ' + value.toFixed(2);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** @returns HH:mm for a minute of the local day. */
|
|
413
|
+
function clock(minute) {
|
|
414
|
+
const hours = String(Math.floor(minute / 60) % 24).padStart(2, '0');
|
|
415
|
+
const minutes = String(minute % 60).padStart(2, '0');
|
|
416
|
+
return hours + ':' + minutes;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @returns HH:mm for an epoch instant. */
|
|
420
|
+
function clockOf(instant) {
|
|
421
|
+
const date = new Date(instant);
|
|
422
|
+
return clock(date.getHours() * 60 + date.getMinutes());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One labelled row of the day panel. */
|
|
426
|
+
function PanelRow(props) {
|
|
427
|
+
return h(
|
|
428
|
+
'div',
|
|
429
|
+
{ className: 'uh-panel-row' },
|
|
430
|
+
h('dt', { className: props.muted === true ? 'uh-panel-key' : 'uh-panel-key uh-panel-strong' }, props.label),
|
|
431
|
+
h('dd', { className: 'uh-panel-value' }, props.value),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The 24-hour distribution bars with a hover readout that floats above the
|
|
437
|
+
* chart: the native `title` tooltip renders under the pointer and gets
|
|
438
|
+
* covered by it, so the readout is drawn here instead.
|
|
439
|
+
*/
|
|
440
|
+
function HourBars(props) {
|
|
441
|
+
const { hours, tr, tag, currency } = props;
|
|
442
|
+
const [hover, setHover] = React.useState(null);
|
|
443
|
+
const peak = Math.max(1, ...hours.map((hour) => hour.tokens));
|
|
444
|
+
const label = hover === null ? null : hours[hover];
|
|
445
|
+
const left = hover === null ? 0 : Math.min(88, Math.max(12, ((hover + 0.5) / 24) * 100));
|
|
446
|
+
|
|
447
|
+
return h(
|
|
448
|
+
'div',
|
|
449
|
+
{ className: 'uh-bars-wrap' },
|
|
450
|
+
label === null
|
|
451
|
+
? null
|
|
452
|
+
: h(
|
|
453
|
+
'div',
|
|
454
|
+
{ className: 'uh-tip', style: { left: left + '%' } },
|
|
455
|
+
h('span', { className: 'uh-tip-main' }, tr('panel.hourTip', { hour: String(label.hour).padStart(2, '0'), tokens: compact(label.tokens, tr) })),
|
|
456
|
+
h(
|
|
457
|
+
'span',
|
|
458
|
+
{ className: 'uh-tip-sub' },
|
|
459
|
+
tr('panel.hourCost', {
|
|
460
|
+
amount: fee(tag, currency, label.fee),
|
|
461
|
+
window: label.window === 'offPeak' ? tr('panel.offPeak') : tr('panel.peak'),
|
|
462
|
+
}),
|
|
463
|
+
),
|
|
464
|
+
),
|
|
465
|
+
h(
|
|
466
|
+
'div',
|
|
467
|
+
{ className: 'uh-bars', role: 'img', 'aria-label': tr('panel.hours') },
|
|
468
|
+
hours.map((hour) =>
|
|
469
|
+
h('div', {
|
|
470
|
+
key: hour.hour,
|
|
471
|
+
className: hour.window === 'offPeak' ? 'uh-bar uh-bar-offpeak' : 'uh-bar',
|
|
472
|
+
style: { height: Math.max(2, Math.round((hour.tokens / peak) * 34)) + 'px' },
|
|
473
|
+
onMouseEnter: () => setHover(hour.hour),
|
|
474
|
+
onMouseLeave: () => setHover((current) => (current === hour.hour ? null : current)),
|
|
475
|
+
}),
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Expanded day panel: today's usage by window, hour and model. */
|
|
482
|
+
function TodayPanel(props) {
|
|
483
|
+
const { tr, tag, data, loading, error, updatedAt, onRefresh, onClose, style, nodeRef } = props;
|
|
484
|
+
const rows = [];
|
|
485
|
+
|
|
486
|
+
if (error !== null) {
|
|
487
|
+
rows.push(h('div', { key: 'error', className: 'uh-panel-note' }, tr('panel.error')));
|
|
488
|
+
} else if (data === null) {
|
|
489
|
+
rows.push(h('div', { key: 'loading', className: 'uh-panel-note' }, tr('panel.loading')));
|
|
490
|
+
} else if (data.error !== undefined) {
|
|
491
|
+
rows.push(h('div', { key: 'error', className: 'uh-panel-note' }, String(data.error)));
|
|
492
|
+
} else {
|
|
493
|
+
const currency = data.currency ?? 'CNY';
|
|
494
|
+
const peakWindow = data.peakWindow ?? { intervals: [[540, 720], [840, 1080]] };
|
|
495
|
+
const intervals = Array.isArray(peakWindow.intervals) ? peakWindow.intervals : [];
|
|
496
|
+
const discount = typeof data.offPeakDiscount === 'number' ? data.offPeakDiscount : 0.5;
|
|
497
|
+
const peakLabel =
|
|
498
|
+
tr('panel.peak') + (intervals.length === 0 ? '' : ' · ' + intervals.map(([from, to]) => clock(from) + '–' + clock(to)).join(' / '));
|
|
499
|
+
const offPeakLabel = tr('panel.offPeak') + ' · ' + tr('panel.offPeakNote', { percent: Math.round(discount * 100) });
|
|
500
|
+
rows.push(
|
|
501
|
+
h(
|
|
502
|
+
'div',
|
|
503
|
+
{ key: 'head', className: 'uh-panel-head' },
|
|
504
|
+
h('span', { className: 'uh-panel-title' }, tr('panel.title')),
|
|
505
|
+
h(
|
|
506
|
+
'span',
|
|
507
|
+
{ className: 'uh-panel-sub' },
|
|
508
|
+
data.day +
|
|
509
|
+
' · ' +
|
|
510
|
+
tr('panel.requests', { count: exact(tag, data.totals.requests) }) +
|
|
511
|
+
(updatedAt > 0 ? ' · ' + tr('panel.updated', { time: clockOf(updatedAt) }) : ''),
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (data.totals.requests === 0) {
|
|
517
|
+
rows.push(h('div', { key: 'empty', className: 'uh-panel-note' }, tr('panel.empty')));
|
|
518
|
+
} else {
|
|
519
|
+
rows.push(
|
|
520
|
+
h(
|
|
521
|
+
'dl',
|
|
522
|
+
{ key: 'windows', className: 'uh-panel-rows' },
|
|
523
|
+
h(PanelRow, {
|
|
524
|
+
key: 'peak',
|
|
525
|
+
label: peakLabel,
|
|
526
|
+
value: compact(data.peak.tokens, tr) + ' tok · ' + fee(tag, currency, data.peak.fee),
|
|
527
|
+
}),
|
|
528
|
+
h(PanelRow, {
|
|
529
|
+
key: 'offpeak',
|
|
530
|
+
label: offPeakLabel,
|
|
531
|
+
value: compact(data.offPeak.tokens, tr) + ' tok · ' + fee(tag, currency, data.offPeak.fee),
|
|
532
|
+
}),
|
|
533
|
+
h(PanelRow, {
|
|
534
|
+
key: 'total',
|
|
535
|
+
label: tr('panel.total'),
|
|
536
|
+
value: compact(data.totals.tokens, tr) + ' tok · ' + fee(tag, currency, data.totals.fee),
|
|
537
|
+
}),
|
|
538
|
+
),
|
|
539
|
+
h(HourBars, { key: 'bars', hours: data.hours, tr, tag, currency }),
|
|
540
|
+
h(
|
|
541
|
+
'dl',
|
|
542
|
+
{ key: 'detail', className: 'uh-panel-rows' },
|
|
543
|
+
h(PanelRow, { key: 'in', label: tr('panel.in'), value: exact(tag, data.totals.input), muted: true }),
|
|
544
|
+
h(PanelRow, { key: 'read', label: tr('panel.cacheRead'), value: exact(tag, data.totals.cacheRead), muted: true }),
|
|
545
|
+
data.totals.cacheWrite === 0
|
|
546
|
+
? null
|
|
547
|
+
: h(PanelRow, { key: 'write', label: tr('panel.cacheWrite'), value: exact(tag, data.totals.cacheWrite), muted: true }),
|
|
548
|
+
h(PanelRow, { key: 'out', label: tr('panel.out'), value: exact(tag, data.totals.output), muted: true }),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const models = data.models ?? [];
|
|
553
|
+
if (models.length > 0) {
|
|
554
|
+
rows.push(
|
|
555
|
+
h(
|
|
556
|
+
'dl',
|
|
557
|
+
{ key: 'models', className: 'uh-panel-rows' },
|
|
558
|
+
models.map((entry) =>
|
|
559
|
+
h(PanelRow, {
|
|
560
|
+
key: entry.model,
|
|
561
|
+
label: entry.model,
|
|
562
|
+
value: compact(entry.tokens, tr) + ' tok · ' + fee(tag, currency, entry.fee),
|
|
563
|
+
muted: true,
|
|
564
|
+
}),
|
|
565
|
+
),
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (data.pricing !== undefined && data.pricing.exact !== true) {
|
|
571
|
+
rows.push(
|
|
572
|
+
h(
|
|
573
|
+
'div',
|
|
574
|
+
{ key: 'pricing', className: 'uh-panel-note' },
|
|
575
|
+
tr('panel.fallback', { model: data.pricing.fallbackModel }),
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
rows.push(
|
|
583
|
+
h(
|
|
584
|
+
'div',
|
|
585
|
+
{ key: 'actions', className: 'uh-panel-actions' },
|
|
586
|
+
h('button', { type: 'button', className: 'uh-panel-button', onClick: onRefresh, disabled: loading }, tr('panel.refresh')),
|
|
587
|
+
h('button', { type: 'button', className: 'uh-panel-button', onClick: onClose }, tr('panel.close')),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
return h('div', { className: 'uh-panel', role: 'dialog', 'aria-label': tr('panel.title'), style, ref: nodeRef }, rows);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The readout itself. */
|
|
595
|
+
function UsageHud(props) {
|
|
596
|
+
const { useProjection, t, getLocale, readBalance } = props;
|
|
597
|
+
// The slot supplies these seats; a missing one must not blank the entry.
|
|
598
|
+
const project = typeof useProjection === 'function' ? useProjection : undefined;
|
|
599
|
+
const usage = project === undefined ? undefined : project('tokenUsage');
|
|
600
|
+
const pressure = project === undefined ? undefined : project('contextPressure');
|
|
601
|
+
const [balance, setBalance] = React.useState(null);
|
|
602
|
+
const [revision, setRevision] = React.useState(0);
|
|
603
|
+
|
|
604
|
+
React.useEffect(() => {
|
|
605
|
+
let live = true;
|
|
606
|
+
if (typeof readBalance !== 'function') return undefined;
|
|
607
|
+
readBalance(revision > 0).then((value) => {
|
|
608
|
+
if (live) setBalance(value);
|
|
609
|
+
});
|
|
610
|
+
return () => {
|
|
611
|
+
live = false;
|
|
612
|
+
};
|
|
613
|
+
}, [revision]);
|
|
614
|
+
|
|
615
|
+
const tag = typeof getLocale === 'function' ? getLocale() : 'en';
|
|
616
|
+
const tr = typeof t === 'function' ? t : (key) => key;
|
|
617
|
+
const buttonRef = React.useRef(null);
|
|
618
|
+
const panelRef = React.useRef(null);
|
|
619
|
+
const [open, setOpen] = React.useState(false);
|
|
620
|
+
const [position, setPosition] = React.useState(null);
|
|
621
|
+
const [today, setToday] = React.useState(null);
|
|
622
|
+
const [todayAt, setTodayAt] = React.useState(0);
|
|
623
|
+
const todayAtRef = React.useRef(0);
|
|
624
|
+
const [todayError, setTodayError] = React.useState(null);
|
|
625
|
+
const [loading, setLoading] = React.useState(false);
|
|
626
|
+
|
|
627
|
+
const loadToday = React.useCallback((force) => {
|
|
628
|
+
const now = Date.now();
|
|
629
|
+
if (force !== true && todayAtRef.current !== 0 && now - todayAtRef.current < TODAY_TTL_MS) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
setLoading(true);
|
|
633
|
+
setTodayError(null);
|
|
634
|
+
fetch(force === true ? '/usage-hud/today?force=1' : '/usage-hud/today', { headers: { accept: 'application/json' } })
|
|
635
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
|
|
636
|
+
.then((value) => {
|
|
637
|
+
setToday(value);
|
|
638
|
+
todayAtRef.current = Date.now();
|
|
639
|
+
setTodayAt(todayAtRef.current);
|
|
640
|
+
setLoading(false);
|
|
641
|
+
})
|
|
642
|
+
.catch((error) => {
|
|
643
|
+
setTodayError(String((error && error.message) || error));
|
|
644
|
+
setLoading(false);
|
|
645
|
+
});
|
|
646
|
+
}, []);
|
|
647
|
+
|
|
648
|
+
React.useEffect(() => {
|
|
649
|
+
if (!open) return undefined;
|
|
650
|
+
const onPointerDown = (event) => {
|
|
651
|
+
if (panelRef.current !== null && panelRef.current.contains(event.target)) return;
|
|
652
|
+
if (buttonRef.current !== null && buttonRef.current.contains(event.target)) return;
|
|
653
|
+
setOpen(false);
|
|
654
|
+
};
|
|
655
|
+
const onKeyDown = (event) => {
|
|
656
|
+
if (event.key === 'Escape') setOpen(false);
|
|
657
|
+
};
|
|
658
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
659
|
+
document.addEventListener('keydown', onKeyDown);
|
|
660
|
+
return () => {
|
|
661
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
662
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
663
|
+
};
|
|
664
|
+
}, [open]);
|
|
665
|
+
|
|
666
|
+
React.useEffect(() => {
|
|
667
|
+
if (!open) {
|
|
668
|
+
setPosition(null);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const rect = buttonRef.current === null ? undefined : buttonRef.current.getBoundingClientRect();
|
|
672
|
+
if (rect === undefined) return;
|
|
673
|
+
const width = Math.min(340, window.innerWidth - 24);
|
|
674
|
+
const left = Math.min(Math.max(12, rect.left + rect.width / 2 - width / 2), window.innerWidth - width - 12);
|
|
675
|
+
setPosition({ left, bottom: Math.max(12, window.innerHeight - rect.top + 8), width });
|
|
676
|
+
}, [open]);
|
|
677
|
+
const context = occupancy(pressure);
|
|
678
|
+
const cacheHit =
|
|
679
|
+
usage !== undefined && billedInput(usage) > 0 ? Math.round((usage.cacheReadTokens / billedInput(usage)) * 100) : null;
|
|
680
|
+
const total = usage === undefined ? 0 : billedInput(usage) + usage.outputTokens;
|
|
681
|
+
|
|
682
|
+
const wallet = balance?.value?.[0];
|
|
683
|
+
// The dock row already carries the session's token, cache-hit and context
|
|
684
|
+
// pills, so the readout adds only what is missing there: the account
|
|
685
|
+
// balance. Without a balance it falls back to those session figures so the
|
|
686
|
+
// entry never disappears silently.
|
|
687
|
+
const segments =
|
|
688
|
+
wallet === undefined
|
|
689
|
+
? [
|
|
690
|
+
usage === undefined ? null : { key: 'tokens', text: tr('hud.tokens', { value: compact(total, tr) }) },
|
|
691
|
+
cacheHit === null ? null : { key: 'cache', text: tr('hud.cache', { percent: cacheHit }) },
|
|
692
|
+
context === null ? null : { key: 'context', text: tr('hud.context', { percent: context.percent }) },
|
|
693
|
+
].filter(Boolean)
|
|
694
|
+
: [{ key: 'balance', text: tr('hud.balance', { amount: money(tag, wallet.currency, wallet.balance) }), strong: true }];
|
|
695
|
+
|
|
696
|
+
// With no figure to show, keep a neutral placeholder: an entry that
|
|
697
|
+
// silently disappears is indistinguishable from one that failed to load.
|
|
698
|
+
const items =
|
|
699
|
+
segments.length === 0 ? [{ key: 'placeholder', text: tr('hud.placeholder') }] : segments;
|
|
700
|
+
|
|
701
|
+
const details = [
|
|
702
|
+
tr('detail.title'),
|
|
703
|
+
usage === undefined ? null : tr('detail.tokens', { count: exact(tag, total) }),
|
|
704
|
+
usage === undefined ? null : tr('detail.input', { count: exact(tag, usage.uncachedInputTokens) }),
|
|
705
|
+
usage === undefined ? null : tr('detail.cacheRead', { count: exact(tag, usage.cacheReadTokens) }),
|
|
706
|
+
usage === undefined || usage.cacheWriteTokens === 0
|
|
707
|
+
? null
|
|
708
|
+
: tr('detail.cacheWrite', { count: exact(tag, usage.cacheWriteTokens) }),
|
|
709
|
+
usage === undefined ? null : tr('detail.output', { count: exact(tag, usage.outputTokens) }),
|
|
710
|
+
cacheHit === null ? null : tr('detail.cacheHit', { percent: cacheHit }),
|
|
711
|
+
context === null
|
|
712
|
+
? null
|
|
713
|
+
: tr('detail.context', {
|
|
714
|
+
percent: context.percent,
|
|
715
|
+
used: exact(tag, context.used),
|
|
716
|
+
window: exact(tag, context.window),
|
|
717
|
+
}),
|
|
718
|
+
wallet === undefined ? null : tr('detail.balance', { amount: money(tag, wallet.currency, wallet.balance) }),
|
|
719
|
+
balance?.bonusWallets?.[0] === undefined
|
|
720
|
+
? null
|
|
721
|
+
: tr('detail.bonus', {
|
|
722
|
+
amount: money(tag, balance.bonusWallets[0].currency, balance.bonusWallets[0].balance),
|
|
723
|
+
}),
|
|
724
|
+
]
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
.join('\n');
|
|
727
|
+
|
|
728
|
+
return h(
|
|
729
|
+
React.Fragment,
|
|
730
|
+
null,
|
|
731
|
+
h('style', { key: 'style', 'data-plugin-css': STYLE_TAG }, CSS),
|
|
732
|
+
h(
|
|
733
|
+
'div',
|
|
734
|
+
{ key: 'row', className: 'uh-row' },
|
|
735
|
+
h(
|
|
736
|
+
'button',
|
|
737
|
+
{
|
|
738
|
+
type: 'button',
|
|
739
|
+
className: 'uh-pill',
|
|
740
|
+
ref: buttonRef,
|
|
741
|
+
title: details,
|
|
742
|
+
'aria-label': tr('hud.aria'),
|
|
743
|
+
'aria-expanded': open,
|
|
744
|
+
onClick: () => {
|
|
745
|
+
if (open) {
|
|
746
|
+
setOpen(false);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
setOpen(true);
|
|
750
|
+
loadToday(false);
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
h(
|
|
754
|
+
'svg',
|
|
755
|
+
{ viewBox: '0 0 16 16', 'aria-hidden': true, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2 },
|
|
756
|
+
h('ellipse', { cx: 8, cy: 4, rx: 5, ry: 2 }),
|
|
757
|
+
h('path', { d: 'M3 4v4c0 1.1 2.24 2 5 2s5-.9 5-2V4' }),
|
|
758
|
+
h('path', { d: 'M3 8v4c0 1.1 2.24 2 5 2s5-.9 5-2V8' }),
|
|
759
|
+
),
|
|
760
|
+
items.flatMap((segment, index) => [
|
|
761
|
+
index === 0
|
|
762
|
+
? null
|
|
763
|
+
: h('span', { key: segment.key + '-sep', className: 'uh-sep', 'aria-hidden': true }, '·'),
|
|
764
|
+
h(
|
|
765
|
+
'span',
|
|
766
|
+
{ key: segment.key, className: segment.strong === true ? 'uh-label uh-strong' : 'uh-label' },
|
|
767
|
+
segment.text,
|
|
768
|
+
),
|
|
769
|
+
]),
|
|
770
|
+
),
|
|
771
|
+
),
|
|
772
|
+
open && position !== null
|
|
773
|
+
? h(TodayPanel, {
|
|
774
|
+
key: 'panel',
|
|
775
|
+
tr,
|
|
776
|
+
tag,
|
|
777
|
+
data: today,
|
|
778
|
+
loading,
|
|
779
|
+
error: todayError,
|
|
780
|
+
updatedAt: todayAt,
|
|
781
|
+
onRefresh: () => {
|
|
782
|
+
loadToday(true);
|
|
783
|
+
setRevision((value) => value + 1);
|
|
784
|
+
},
|
|
785
|
+
onClose: () => setOpen(false),
|
|
786
|
+
style: position,
|
|
787
|
+
nodeRef: panelRef,
|
|
788
|
+
})
|
|
789
|
+
: null,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Reports a render failure instead of silently blanking the slot entry. */
|
|
794
|
+
class HudBoundary extends React.Component {
|
|
795
|
+
constructor(props) {
|
|
796
|
+
super(props);
|
|
797
|
+
this.state = { failed: false };
|
|
798
|
+
}
|
|
799
|
+
static getDerivedStateFromError() {
|
|
800
|
+
return { failed: true };
|
|
801
|
+
}
|
|
802
|
+
componentDidCatch(error) {
|
|
803
|
+
console.error('[usage-hud] render failed', error);
|
|
804
|
+
}
|
|
805
|
+
render() {
|
|
806
|
+
return this.state.failed ? null : this.props.children;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Slot entry wrapper: keeps one failing render from vanishing without a trace. */
|
|
811
|
+
function BoundedUsageHud(props) {
|
|
812
|
+
return h(HudBoundary, null, h(UsageHud, props));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
inject: ['slots', 'locale', 'remote', 'remote.account'],
|
|
817
|
+
apply(ctx) {
|
|
818
|
+
try {
|
|
819
|
+
ctx.effect(() => {
|
|
820
|
+
try {
|
|
821
|
+
return ctx.locale.register(NS, COPY);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
// A hot reload can re-register without disposing the previous copy.
|
|
824
|
+
console.error('[usage-hud] locale registration failed', error);
|
|
825
|
+
return () => {};
|
|
826
|
+
}
|
|
827
|
+
}, 'usage-hud: dictionaries');
|
|
828
|
+
|
|
829
|
+
const readBalance = createBalanceReader(ctx);
|
|
830
|
+
const readSession = createSessionCostReader();
|
|
831
|
+
|
|
832
|
+
ctx.slots.inject('conversation.composer.dock', () =>
|
|
833
|
+
ctx.slots.register(
|
|
834
|
+
{
|
|
835
|
+
name: 'conversation.composer.dock',
|
|
836
|
+
id: 'usage-hud',
|
|
837
|
+
order: 2,
|
|
838
|
+
locale: NS,
|
|
839
|
+
inject: () => ({ getLocale: () => activeLocale(ctx), readBalance }),
|
|
840
|
+
},
|
|
841
|
+
BoundedUsageHud,
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
846
|
+
ctx.slots.register(
|
|
847
|
+
{
|
|
848
|
+
name: 'conversation.chat.assistant-actions',
|
|
849
|
+
id: 'usage-cost',
|
|
850
|
+
order: 1,
|
|
851
|
+
locale: NS,
|
|
852
|
+
inject: () => ({ getLocale: () => activeLocale(ctx), readSession }),
|
|
853
|
+
},
|
|
854
|
+
TurnCostChip,
|
|
855
|
+
),
|
|
856
|
+
);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
console.error('[usage-hud] activation failed', error);
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
},
|
|
863
|
+
});
|