@chenmiao8563/dsh-token-ledger 0.1.1 → 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/CHANGELOG.md +27 -1
- package/README.md +38 -1
- package/README.zh.md +31 -1
- package/docs/VERIFICATION.md +77 -0
- package/lib/client.js +678 -0
- package/lib/index.js +24 -0
- package/lib/overview.js +228 -0
- package/lib/route.js +141 -0
- package/package.json +16 -1
package/lib/client.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@chenmiao8563/dsh-token-ledger` browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a settings section that draws an overview of the ledger: range
|
|
5
|
+
* totals, today, and a calendar.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this file has no build step
|
|
8
|
+
*
|
|
9
|
+
* The client module system is a global loader that hands the factory a
|
|
10
|
+
* `require`, so a hand-written file can ask for React directly. That keeps the
|
|
11
|
+
* package's central promise — install with no build — true for the browser half
|
|
12
|
+
* as well as the host half, at the cost of writing `createElement` calls
|
|
13
|
+
* instead of JSX.
|
|
14
|
+
*
|
|
15
|
+
* Every string is localised, every number is formatted here rather than by the
|
|
16
|
+
* host, and nothing in this file may assume the shape of the payload beyond
|
|
17
|
+
* what `lib/overview.js` documents. A missing field renders as a dash; it never
|
|
18
|
+
* throws, because a settings page that crashes takes the whole settings view
|
|
19
|
+
* with it.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-token-ledger/client
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
window.__ModuleLoader__.load({
|
|
25
|
+
id: '@chenmiao8563/dsh-token-ledger',
|
|
26
|
+
factory: (require) => {
|
|
27
|
+
var module = { exports: {} }
|
|
28
|
+
var exports = module.exports
|
|
29
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
30
|
+
|
|
31
|
+
const react = require('react')
|
|
32
|
+
const h = react.createElement
|
|
33
|
+
|
|
34
|
+
/** Locale namespace registered with the client locale service. */
|
|
35
|
+
const NS = 'token-ledger'
|
|
36
|
+
/** Where the host half serves the overview. */
|
|
37
|
+
const ENDPOINT = '/api/token-ledger/summary'
|
|
38
|
+
/** How often to re-read the route. The host debounces its own writes at 2s. */
|
|
39
|
+
const POLL_MS = 15000
|
|
40
|
+
/** Style element id, so a reload replaces its own styles and nothing else. */
|
|
41
|
+
const STYLE_ID = 'dsh-token-ledger-styles'
|
|
42
|
+
|
|
43
|
+
const zh = {
|
|
44
|
+
nav: '用量账本',
|
|
45
|
+
title: 'Token 用量概览',
|
|
46
|
+
subtitle: '数据来自本机会话日志,与 dsh-token-ledger CLI 的重算结果一致',
|
|
47
|
+
rangeMonth: '本月',
|
|
48
|
+
rangeYear: '本年',
|
|
49
|
+
rangeWeek: '7 天',
|
|
50
|
+
totalTokens: 'Token 总计',
|
|
51
|
+
cacheHitRate: '缓存命中率',
|
|
52
|
+
calls: '调用次数',
|
|
53
|
+
activeDays: '活跃天数',
|
|
54
|
+
inputTokens: '未命中输入',
|
|
55
|
+
outputTokens: '输出',
|
|
56
|
+
cacheReadTokens: '缓存命中读入',
|
|
57
|
+
reasoningTokens: '其中推理',
|
|
58
|
+
today: '今日实时',
|
|
59
|
+
todayHint: '每一步完成后立即更新(成功完成的调用才计入)',
|
|
60
|
+
todayEmpty: '今天还没有产生用量',
|
|
61
|
+
calendar: '用量热力图',
|
|
62
|
+
viewYear: '年',
|
|
63
|
+
viewMonth: '月',
|
|
64
|
+
viewWeek: '一周',
|
|
65
|
+
weekChart: '近 7 天每日 Token',
|
|
66
|
+
less: '少',
|
|
67
|
+
more: '多',
|
|
68
|
+
byModel: '按模型',
|
|
69
|
+
model: '模型',
|
|
70
|
+
updatedAt: '数据更新于',
|
|
71
|
+
loading: '加载中…',
|
|
72
|
+
unavailable: '无法读取用量数据',
|
|
73
|
+
unavailableReason: '设置面板读不到 /api/token-ledger/summary。通常是因为当前 profile 没有挂载 web 服务器,或插件刚更新还没重启。',
|
|
74
|
+
stale: '暂时读取失败,显示的是上一次成功的数据',
|
|
75
|
+
noData: '暂无数据',
|
|
76
|
+
day: '日',
|
|
77
|
+
cells: '格',
|
|
78
|
+
mon: '一',
|
|
79
|
+
tue: '二',
|
|
80
|
+
wed: '三',
|
|
81
|
+
thu: '四',
|
|
82
|
+
fri: '五',
|
|
83
|
+
sat: '六',
|
|
84
|
+
sun: '日',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const en = {
|
|
88
|
+
nav: 'Token ledger',
|
|
89
|
+
title: 'Token usage overview',
|
|
90
|
+
subtitle: 'Folded from local session logs; identical to a CLI recomputation',
|
|
91
|
+
rangeMonth: 'This month',
|
|
92
|
+
rangeYear: 'This year',
|
|
93
|
+
rangeWeek: '7 days',
|
|
94
|
+
totalTokens: 'Total tokens',
|
|
95
|
+
cacheHitRate: 'Cache hit rate',
|
|
96
|
+
calls: 'Calls',
|
|
97
|
+
activeDays: 'Active days',
|
|
98
|
+
inputTokens: 'Uncached input',
|
|
99
|
+
outputTokens: 'Output',
|
|
100
|
+
cacheReadTokens: 'Cache read',
|
|
101
|
+
reasoningTokens: 'of which reasoning',
|
|
102
|
+
today: 'Today, live',
|
|
103
|
+
todayHint: 'Updates as each step completes (only successful calls count)',
|
|
104
|
+
todayEmpty: 'No usage yet today',
|
|
105
|
+
calendar: 'Usage calendar',
|
|
106
|
+
viewYear: 'Year',
|
|
107
|
+
viewMonth: 'Month',
|
|
108
|
+
viewWeek: 'Week',
|
|
109
|
+
weekChart: 'Tokens per day, last 7 days',
|
|
110
|
+
less: 'Less',
|
|
111
|
+
more: 'More',
|
|
112
|
+
byModel: 'By model',
|
|
113
|
+
model: 'Model',
|
|
114
|
+
updatedAt: 'Updated',
|
|
115
|
+
loading: 'Loading…',
|
|
116
|
+
unavailable: 'Usage data is unavailable',
|
|
117
|
+
unavailableReason: 'The settings panel could not read /api/token-ledger/summary. The active profile usually has no web server mounted, or the plugin was updated without a restart.',
|
|
118
|
+
stale: 'Last read failed; showing the previous data',
|
|
119
|
+
noData: 'No data yet',
|
|
120
|
+
day: 'day',
|
|
121
|
+
cells: 'cells',
|
|
122
|
+
mon: 'Mon',
|
|
123
|
+
tue: 'Tue',
|
|
124
|
+
wed: 'Wed',
|
|
125
|
+
thu: 'Thu',
|
|
126
|
+
fri: 'Fri',
|
|
127
|
+
sat: 'Sat',
|
|
128
|
+
sun: 'Sun',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const CSS = `
|
|
132
|
+
.tl-root { display: flex; flex-direction: column; gap: 18px; color: var(--dsh-text-primary, inherit); }
|
|
133
|
+
.tl-head { display: flex; flex-direction: column; gap: 4px; }
|
|
134
|
+
.tl-title { font-size: 15px; font-weight: 600; margin: 0; }
|
|
135
|
+
.tl-sub { font-size: 12px; opacity: .65; margin: 0; }
|
|
136
|
+
.tl-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
137
|
+
.tl-spacer { flex: 1 1 auto; }
|
|
138
|
+
.tl-tabs { display: inline-flex; border: 1px solid var(--dsh-border, rgba(127,127,127,.32)); border-radius: 8px; overflow: hidden; }
|
|
139
|
+
.tl-tab { appearance: none; border: 0; background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 5px 12px; cursor: pointer; opacity: .7; }
|
|
140
|
+
.tl-tab:hover { background: var(--dsh-bg-hover, rgba(127,127,127,.12)); opacity: 1; }
|
|
141
|
+
.tl-tab[data-active='true'] { background: var(--dsh-bg-selected, rgba(64,140,255,.16)); opacity: 1; font-weight: 600; }
|
|
142
|
+
.tl-card { border: 1px solid var(--dsh-border, rgba(127,127,127,.28)); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
143
|
+
.tl-card-title { font-size: 13px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
|
|
144
|
+
.tl-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
|
145
|
+
.tl-metric { display: flex; flex-direction: column; gap: 2px; }
|
|
146
|
+
.tl-metric-value { font-size: 22px; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
|
147
|
+
.tl-metric-value-sm { font-size: 17px; }
|
|
148
|
+
.tl-metric-label { font-size: 11px; opacity: .65; }
|
|
149
|
+
.tl-metric-sub { font-size: 11px; opacity: .5; font-variant-numeric: tabular-nums; }
|
|
150
|
+
.tl-detail { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; opacity: .8; }
|
|
151
|
+
.tl-detail span { font-variant-numeric: tabular-nums; }
|
|
152
|
+
.tl-note { font-size: 11px; opacity: .55; margin: 0; }
|
|
153
|
+
.tl-scroll { overflow-x: auto; padding-bottom: 4px; }
|
|
154
|
+
.tl-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 12px); gap: 3px; width: max-content; }
|
|
155
|
+
.tl-month-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
|
|
156
|
+
.tl-cell { width: 12px; height: 12px; border-radius: 2px; background: rgba(127,127,127,.14); }
|
|
157
|
+
.tl-month-cell { position: relative; aspect-ratio: 1 / 1; border-radius: 3px; background: rgba(127,127,127,.14); }
|
|
158
|
+
.tl-month-cell[data-today='true'] { outline: 1px solid var(--dsh-accent, #408cff); outline-offset: 1px; }
|
|
159
|
+
.tl-weekday { font-size: 10px; opacity: .5; text-align: center; }
|
|
160
|
+
.tl-legend { display: flex; align-items: center; gap: 6px; font-size: 11px; opacity: .6; }
|
|
161
|
+
.tl-bars { display: flex; align-items: flex-end; gap: 8px; height: 132px; }
|
|
162
|
+
.tl-bar-col { flex: 1 1 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 6px; height: 100%; min-width: 0; }
|
|
163
|
+
.tl-bar { border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #408cff, #2f6fd0); min-height: 2px; }
|
|
164
|
+
.tl-bar-value { font-size: 10px; text-align: center; opacity: .75; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
165
|
+
.tl-bar-label { font-size: 10px; text-align: center; opacity: .55; white-space: nowrap; }
|
|
166
|
+
.tl-models { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
|
167
|
+
.tl-model-line { display: flex; align-items: center; gap: 10px; }
|
|
168
|
+
.tl-model-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
169
|
+
.tl-model-bar { flex: 0 0 96px; height: 6px; border-radius: 3px; background: rgba(127,127,127,.16); overflow: hidden; }
|
|
170
|
+
.tl-model-fill { height: 100%; background: #408cff; }
|
|
171
|
+
.tl-model-value { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .75; }
|
|
172
|
+
.tl-status { font-size: 12px; opacity: .6; }
|
|
173
|
+
.tl-error { font-size: 12px; color: var(--dsh-danger, #d9534f); }
|
|
174
|
+
`
|
|
175
|
+
|
|
176
|
+
/** Install the section's stylesheet, returning a remover. */
|
|
177
|
+
function installStyles() {
|
|
178
|
+
const existing = document.getElementById(STYLE_ID)
|
|
179
|
+
if (existing !== null) existing.remove()
|
|
180
|
+
const style = document.createElement('style')
|
|
181
|
+
style.id = STYLE_ID
|
|
182
|
+
style.dataset.plugin = 'dsh-token-ledger'
|
|
183
|
+
style.textContent = CSS
|
|
184
|
+
document.head.appendChild(style)
|
|
185
|
+
return () => {
|
|
186
|
+
style.remove()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Format a token count the way a person reads it.
|
|
192
|
+
*
|
|
193
|
+
* Chinese orders of magnitude are used for the compact form because the
|
|
194
|
+
* primary audience reads 万 and 亿, not M and B.
|
|
195
|
+
*
|
|
196
|
+
* @param {unknown} value - the count.
|
|
197
|
+
* @returns {string} a compact string, or a dash when unusable.
|
|
198
|
+
*/
|
|
199
|
+
function compact(value) {
|
|
200
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
|
201
|
+
const abs = Math.abs(value)
|
|
202
|
+
if (abs >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`
|
|
203
|
+
if (abs >= 1e4) return `${(value / 1e4).toFixed(2)} 万`
|
|
204
|
+
return String(Math.round(value))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Format a count with thousands separators.
|
|
209
|
+
*
|
|
210
|
+
* @param {unknown} value - the count.
|
|
211
|
+
* @returns {string} the full string, or a dash when unusable.
|
|
212
|
+
*/
|
|
213
|
+
function full(value) {
|
|
214
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
|
215
|
+
return Math.round(value).toLocaleString('en-US')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Format a fraction as a percentage.
|
|
220
|
+
*
|
|
221
|
+
* @param {unknown} value - a fraction between 0 and 1, or null.
|
|
222
|
+
* @returns {string} the percentage, or a dash when undefined.
|
|
223
|
+
*/
|
|
224
|
+
function percent(value) {
|
|
225
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
|
226
|
+
return `${(value * 100).toFixed(1)}%`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Split a `YYYY-MM-DD` key into its parts without timezone drift.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} key - the day key.
|
|
233
|
+
* @returns {{ year: number, month: number, day: number, weekday: number }} local parts.
|
|
234
|
+
*/
|
|
235
|
+
function dayParts(key) {
|
|
236
|
+
const [year, month, day] = String(key).split('-').map((part) => Number.parseInt(part, 10))
|
|
237
|
+
const date = new Date(year, (month ?? 1) - 1, day ?? 1)
|
|
238
|
+
// JS weeks start on Sunday; the calendar reads better starting Monday.
|
|
239
|
+
return { year, month, day, weekday: (date.getDay() + 6) % 7 }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Map a day's tokens onto one of five intensity levels.
|
|
244
|
+
*
|
|
245
|
+
* The scale is relative to the busiest day in the window and square-rooted,
|
|
246
|
+
* because a single very large day would otherwise flatten every other cell
|
|
247
|
+
* to the faintest level.
|
|
248
|
+
*
|
|
249
|
+
* @param {number} value - the day's tokens.
|
|
250
|
+
* @param {number} max - the window's busiest day.
|
|
251
|
+
* @returns {number} 0 for an empty day, otherwise 1 to 4.
|
|
252
|
+
*/
|
|
253
|
+
function level(value, max) {
|
|
254
|
+
if (!(value > 0) || !(max > 0)) return 0
|
|
255
|
+
const ratio = Math.sqrt(value / max)
|
|
256
|
+
return Math.max(1, Math.min(4, Math.ceil(ratio * 4)))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The five heat levels, readable on both light and dark backgrounds. */
|
|
260
|
+
const LEVEL_BG = [
|
|
261
|
+
'rgba(127,127,127,.14)',
|
|
262
|
+
'rgba(64,140,255,.28)',
|
|
263
|
+
'rgba(64,140,255,.48)',
|
|
264
|
+
'rgba(64,140,255,.7)',
|
|
265
|
+
'rgba(64,140,255,.95)',
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A labelled metric.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} props - the metric.
|
|
272
|
+
* @returns {object} the element.
|
|
273
|
+
*/
|
|
274
|
+
function Metric({ label, value, sub, small }) {
|
|
275
|
+
return h(
|
|
276
|
+
'div',
|
|
277
|
+
{ className: 'tl-metric' },
|
|
278
|
+
h('div', { className: small === true ? 'tl-metric-value tl-metric-value-sm' : 'tl-metric-value' }, value),
|
|
279
|
+
h('div', { className: 'tl-metric-label' }, label),
|
|
280
|
+
sub === undefined || sub === null ? null : h('div', { className: 'tl-metric-sub' }, sub),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A two- or three-way segmented control.
|
|
286
|
+
*
|
|
287
|
+
* @param {object} props - the control.
|
|
288
|
+
* @returns {object} the element.
|
|
289
|
+
*/
|
|
290
|
+
function Tabs({ options, value, onChange }) {
|
|
291
|
+
return h(
|
|
292
|
+
'div',
|
|
293
|
+
{ className: 'tl-tabs', role: 'tablist' },
|
|
294
|
+
options.map((option) =>
|
|
295
|
+
h(
|
|
296
|
+
'button',
|
|
297
|
+
{
|
|
298
|
+
key: option.value,
|
|
299
|
+
type: 'button',
|
|
300
|
+
role: 'tab',
|
|
301
|
+
className: 'tl-tab',
|
|
302
|
+
'data-active': option.value === value ? 'true' : 'false',
|
|
303
|
+
'aria-selected': option.value === value,
|
|
304
|
+
onClick: () => onChange(option.value),
|
|
305
|
+
},
|
|
306
|
+
option.label,
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The year or month calendar, drawn as a grid of day cells.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} props - the calendar.
|
|
316
|
+
* @returns {object} the element.
|
|
317
|
+
*/
|
|
318
|
+
function Calendar({ series, view, t }) {
|
|
319
|
+
const byDate = new Map(series.map((day) => [day.date, day]))
|
|
320
|
+
const max = series.reduce((peak, day) => Math.max(peak, day.totalTokens ?? 0), 0)
|
|
321
|
+
|
|
322
|
+
if (view === 'month') {
|
|
323
|
+
// Always show the month of the last day in the series, which is today.
|
|
324
|
+
const last = series.length > 0 ? dayParts(series[series.length - 1].date) : dayParts('1970-01-01')
|
|
325
|
+
const daysInMonth = new Date(last.year, last.month, 0).getDate()
|
|
326
|
+
const leading = dayParts(`${last.year}-${String(last.month).padStart(2, '0')}-01`).weekday
|
|
327
|
+
const cells = []
|
|
328
|
+
for (let index = 0; index < leading; index += 1) cells.push(h('div', { key: `pad-${index}` }))
|
|
329
|
+
for (let day = 1; day <= daysInMonth; day += 1) {
|
|
330
|
+
const key = `${last.year}-${String(last.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
331
|
+
const entry = byDate.get(key)
|
|
332
|
+
const tokens = entry?.totalTokens ?? 0
|
|
333
|
+
cells.push(
|
|
334
|
+
h('div', {
|
|
335
|
+
key,
|
|
336
|
+
className: 'tl-month-cell',
|
|
337
|
+
'data-today': key === series[series.length - 1]?.date ? 'true' : 'false',
|
|
338
|
+
style: { background: LEVEL_BG[level(tokens, max)] },
|
|
339
|
+
title: `${key} · ${compact(tokens)}`,
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
return h(
|
|
344
|
+
'div',
|
|
345
|
+
{ className: 'tl-month-grid' },
|
|
346
|
+
['一', '二', '三', '四', '五', '六', '日'].map((label, index) =>
|
|
347
|
+
h('div', { key: `h-${index}`, className: 'tl-weekday' }, label),
|
|
348
|
+
),
|
|
349
|
+
cells,
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Year: one column per week, one row per weekday, oldest first.
|
|
354
|
+
return h(
|
|
355
|
+
'div',
|
|
356
|
+
{ className: 'tl-grid' },
|
|
357
|
+
series.map((day) =>
|
|
358
|
+
h('div', {
|
|
359
|
+
key: day.date,
|
|
360
|
+
className: 'tl-cell',
|
|
361
|
+
style: { background: LEVEL_BG[level(day.totalTokens ?? 0, max)] },
|
|
362
|
+
title: `${day.date} · ${compact(day.totalTokens ?? 0)} · ${full(day.calls ?? 0)} ${t('calls')}`,
|
|
363
|
+
}),
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The last seven days as bars.
|
|
370
|
+
*
|
|
371
|
+
* @param {object} props - the chart.
|
|
372
|
+
* @returns {object} the element.
|
|
373
|
+
*/
|
|
374
|
+
function WeekBars({ series }) {
|
|
375
|
+
const lastSeven = series.slice(-7)
|
|
376
|
+
const max = lastSeven.reduce((peak, day) => Math.max(peak, day.totalTokens ?? 0), 0)
|
|
377
|
+
return h(
|
|
378
|
+
'div',
|
|
379
|
+
{ className: 'tl-bars' },
|
|
380
|
+
lastSeven.map((day) => {
|
|
381
|
+
const tokens = day.totalTokens ?? 0
|
|
382
|
+
const height = max > 0 ? Math.max(2, Math.round((tokens / max) * 88)) : 2
|
|
383
|
+
return h(
|
|
384
|
+
'div',
|
|
385
|
+
{ key: day.date, className: 'tl-bar-col', title: `${day.date} · ${full(tokens)}` },
|
|
386
|
+
h('div', { className: 'tl-bar-value' }, compact(tokens)),
|
|
387
|
+
h('div', { className: 'tl-bar', style: { height: `${height}px` } }),
|
|
388
|
+
h('div', { className: 'tl-bar-label' }, day.date.slice(5)),
|
|
389
|
+
)
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The overview, as pure presentation.
|
|
396
|
+
*
|
|
397
|
+
* Split out from the section deliberately. Everything visible here is a
|
|
398
|
+
* function of these props — no fetching, no state, no clock — which is what
|
|
399
|
+
* makes it renderable under a real React in a test, with no browser, no DOM
|
|
400
|
+
* and no network. The fetching half is `TokenLedgerSection` below.
|
|
401
|
+
*
|
|
402
|
+
* @param {object} props - the view inputs.
|
|
403
|
+
* @param {(key: string) => string} props.t - the bound translator.
|
|
404
|
+
* @param {{ status: string, data: object|null, error: string|null }} props.state - what to show.
|
|
405
|
+
* @param {'month'|'year'|'week'} props.range - the selected range.
|
|
406
|
+
* @param {'year'|'month'|'week'} props.view - the selected calendar view.
|
|
407
|
+
* @param {(value: string) => void} props.onRange - range change handler.
|
|
408
|
+
* @param {(value: string) => void} props.onView - calendar view change handler.
|
|
409
|
+
* @returns {object} the element.
|
|
410
|
+
*/
|
|
411
|
+
function OverviewView({ t, state, range, view, onRange, onView }) {
|
|
412
|
+
const header = h(
|
|
413
|
+
'div',
|
|
414
|
+
{ className: 'tl-head' },
|
|
415
|
+
h('h2', { className: 'tl-title' }, t('title')),
|
|
416
|
+
h('p', { className: 'tl-sub' }, t('subtitle')),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if (state.data === null) {
|
|
420
|
+
return h(
|
|
421
|
+
'div',
|
|
422
|
+
{ className: 'tl-root' },
|
|
423
|
+
header,
|
|
424
|
+
h(
|
|
425
|
+
'div',
|
|
426
|
+
{ className: 'tl-card' },
|
|
427
|
+
h('h3', { className: 'tl-card-title' }, state.status === 'error' ? t('unavailable') : t('loading')),
|
|
428
|
+
state.status === 'error' ? h('p', { className: 'tl-note' }, t('unavailableReason')) : null,
|
|
429
|
+
state.error === null ? null : h('p', { className: 'tl-error' }, state.error),
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const data = state.data
|
|
435
|
+
const summary = data.ranges?.[range] ?? { totals: {}, calls: 0, activeDays: 0, cacheHitRate: null }
|
|
436
|
+
const totals = summary.totals ?? {}
|
|
437
|
+
const today = data.today ?? { totals: {}, calls: 0, cacheHitRate: null, date: '' }
|
|
438
|
+
const series = Array.isArray(data.series) ? data.series : []
|
|
439
|
+
const models = Array.isArray(data.models) ? data.models : []
|
|
440
|
+
const updated =
|
|
441
|
+
typeof data.generatedAt === 'number' ? new Date(data.generatedAt).toLocaleTimeString() : '—'
|
|
442
|
+
const busiest = models.reduce((peak, model) => Math.max(peak, model.totalTokens ?? 0), 0)
|
|
443
|
+
|
|
444
|
+
return h(
|
|
445
|
+
'div',
|
|
446
|
+
{ className: 'tl-root' },
|
|
447
|
+
|
|
448
|
+
h(
|
|
449
|
+
'div',
|
|
450
|
+
{ className: 'tl-row' },
|
|
451
|
+
header,
|
|
452
|
+
h('div', { className: 'tl-spacer' }),
|
|
453
|
+
h(Tabs, {
|
|
454
|
+
value: range,
|
|
455
|
+
onChange: onRange,
|
|
456
|
+
options: [
|
|
457
|
+
{ value: 'month', label: t('rangeMonth') },
|
|
458
|
+
{ value: 'year', label: t('rangeYear') },
|
|
459
|
+
{ value: 'week', label: t('rangeWeek') },
|
|
460
|
+
],
|
|
461
|
+
}),
|
|
462
|
+
),
|
|
463
|
+
|
|
464
|
+
state.status === 'stale' ? h('p', { className: 'tl-note' }, t('stale')) : null,
|
|
465
|
+
|
|
466
|
+
// Range totals.
|
|
467
|
+
h(
|
|
468
|
+
'div',
|
|
469
|
+
{ className: 'tl-card' },
|
|
470
|
+
h(
|
|
471
|
+
'div',
|
|
472
|
+
{ className: 'tl-metrics' },
|
|
473
|
+
h(Metric, { label: t('totalTokens'), value: compact(totals.totalTokens), sub: full(totals.totalTokens) }),
|
|
474
|
+
h(Metric, { label: t('cacheHitRate'), value: percent(summary.cacheHitRate), sub: `${compact(totals.cacheReadTokens)} / ${compact((totals.cacheReadTokens ?? 0) + (totals.inputTokens ?? 0))}` }),
|
|
475
|
+
h(Metric, { label: t('calls'), value: full(summary.calls), sub: `${full(summary.activeDays)} ${t('day')}` }),
|
|
476
|
+
),
|
|
477
|
+
h(
|
|
478
|
+
'div',
|
|
479
|
+
{ className: 'tl-detail' },
|
|
480
|
+
h('span', null, `${t('inputTokens')} ${full(totals.inputTokens)}`),
|
|
481
|
+
h('span', null, `${t('cacheReadTokens')} ${full(totals.cacheReadTokens)}`),
|
|
482
|
+
h('span', null, `${t('outputTokens')} ${full(totals.outputTokens)}`),
|
|
483
|
+
totals.reasoningTokens > 0 ? h('span', null, `${t('reasoningTokens')} ${full(totals.reasoningTokens)}`) : null,
|
|
484
|
+
),
|
|
485
|
+
),
|
|
486
|
+
|
|
487
|
+
// Today, live.
|
|
488
|
+
h(
|
|
489
|
+
'div',
|
|
490
|
+
{ className: 'tl-card' },
|
|
491
|
+
h(
|
|
492
|
+
'h3',
|
|
493
|
+
{ className: 'tl-card-title' },
|
|
494
|
+
t('today'),
|
|
495
|
+
h('span', { className: 'tl-status' }, today.date ?? ''),
|
|
496
|
+
),
|
|
497
|
+
(today.totals?.totalTokens ?? 0) === 0
|
|
498
|
+
? h('p', { className: 'tl-note' }, t('todayEmpty'))
|
|
499
|
+
: h(
|
|
500
|
+
'div',
|
|
501
|
+
{ className: 'tl-metrics' },
|
|
502
|
+
h(Metric, { label: t('totalTokens'), value: compact(today.totals.totalTokens), sub: full(today.totals.totalTokens), small: true }),
|
|
503
|
+
h(Metric, { label: t('cacheHitRate'), value: percent(today.cacheHitRate), small: true }),
|
|
504
|
+
h(Metric, { label: t('calls'), value: full(today.calls), small: true }),
|
|
505
|
+
),
|
|
506
|
+
h('p', { className: 'tl-note' }, t('todayHint')),
|
|
507
|
+
),
|
|
508
|
+
|
|
509
|
+
// Calendar.
|
|
510
|
+
h(
|
|
511
|
+
'div',
|
|
512
|
+
{ className: 'tl-card' },
|
|
513
|
+
h(
|
|
514
|
+
'div',
|
|
515
|
+
{ className: 'tl-row' },
|
|
516
|
+
h('h3', { className: 'tl-card-title' }, t('calendar')),
|
|
517
|
+
h('div', { className: 'tl-spacer' }),
|
|
518
|
+
h(Tabs, {
|
|
519
|
+
value: view,
|
|
520
|
+
onChange: onView,
|
|
521
|
+
options: [
|
|
522
|
+
{ value: 'year', label: t('viewYear') },
|
|
523
|
+
{ value: 'month', label: t('viewMonth') },
|
|
524
|
+
{ value: 'week', label: t('viewWeek') },
|
|
525
|
+
],
|
|
526
|
+
}),
|
|
527
|
+
),
|
|
528
|
+
series.length === 0
|
|
529
|
+
? h('p', { className: 'tl-note' }, t('noData'))
|
|
530
|
+
: view === 'week'
|
|
531
|
+
? h(
|
|
532
|
+
'div',
|
|
533
|
+
null,
|
|
534
|
+
h('p', { className: 'tl-note' }, t('weekChart')),
|
|
535
|
+
h(WeekBars, { series }),
|
|
536
|
+
)
|
|
537
|
+
: h('div', { className: 'tl-scroll' }, h(Calendar, { series, view, t })),
|
|
538
|
+
view === 'week'
|
|
539
|
+
? null
|
|
540
|
+
: h(
|
|
541
|
+
'div',
|
|
542
|
+
{ className: 'tl-legend' },
|
|
543
|
+
h('span', null, t('less')),
|
|
544
|
+
LEVEL_BG.map((background, index) => h('span', { key: index, className: 'tl-cell', style: { background } })),
|
|
545
|
+
h('span', null, t('more')),
|
|
546
|
+
),
|
|
547
|
+
),
|
|
548
|
+
|
|
549
|
+
// Models.
|
|
550
|
+
models.length === 0
|
|
551
|
+
? null
|
|
552
|
+
: h(
|
|
553
|
+
'div',
|
|
554
|
+
{ className: 'tl-card' },
|
|
555
|
+
h('h3', { className: 'tl-card-title' }, t('byModel')),
|
|
556
|
+
h(
|
|
557
|
+
'div',
|
|
558
|
+
{ className: 'tl-models' },
|
|
559
|
+
models.map((model) =>
|
|
560
|
+
h(
|
|
561
|
+
'div',
|
|
562
|
+
{ key: model.model, className: 'tl-model-line' },
|
|
563
|
+
h('span', { className: 'tl-model-name', title: model.model }, model.model),
|
|
564
|
+
h(
|
|
565
|
+
'span',
|
|
566
|
+
{ className: 'tl-model-bar' },
|
|
567
|
+
h('span', {
|
|
568
|
+
className: 'tl-model-fill',
|
|
569
|
+
style: { width: `${busiest > 0 ? Math.round(((model.totalTokens ?? 0) / busiest) * 100) : 0}%` },
|
|
570
|
+
}),
|
|
571
|
+
),
|
|
572
|
+
h('span', { className: 'tl-model-value' }, `${compact(model.totalTokens)} · ${percent(model.cacheHitRate)}`),
|
|
573
|
+
),
|
|
574
|
+
),
|
|
575
|
+
),
|
|
576
|
+
),
|
|
577
|
+
|
|
578
|
+
h('p', { className: 'tl-note' }, `${t('updatedAt')} ${updated}`),
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const inject = ['slots', 'locale']
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The settings section: fetch the overview, then render the view.
|
|
586
|
+
*
|
|
587
|
+
* The fetch is the whole reason this exists separately from
|
|
588
|
+
* {@link OverviewView}. It polls rather than subscribing because the host
|
|
589
|
+
* answers over a plain route, and a failed poll with data already in hand
|
|
590
|
+
* degrades to `stale` instead of blanking the page.
|
|
591
|
+
*
|
|
592
|
+
* @param {object} props - props injected by the registration.
|
|
593
|
+
* @param {(key: string) => string} props.t - the bound translator.
|
|
594
|
+
* @returns {object} the element.
|
|
595
|
+
*/
|
|
596
|
+
function TokenLedgerSection({ t }) {
|
|
597
|
+
const [range, setRange] = react.useState('month')
|
|
598
|
+
const [view, setView] = react.useState('year')
|
|
599
|
+
const [state, setState] = react.useState({ status: 'loading', data: null, error: null })
|
|
600
|
+
|
|
601
|
+
react.useEffect(() => {
|
|
602
|
+
let cancelled = false
|
|
603
|
+
const load = () => {
|
|
604
|
+
fetch(ENDPOINT, { cache: 'no-store' })
|
|
605
|
+
.then((response) => {
|
|
606
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
607
|
+
return response.json()
|
|
608
|
+
})
|
|
609
|
+
.then((data) => {
|
|
610
|
+
if (!cancelled) setState({ status: 'ready', data, error: null })
|
|
611
|
+
})
|
|
612
|
+
.catch((error) => {
|
|
613
|
+
if (cancelled) return
|
|
614
|
+
setState((previous) =>
|
|
615
|
+
previous.data === null
|
|
616
|
+
? { status: 'error', data: null, error: String(error?.message ?? error) }
|
|
617
|
+
: { status: 'stale', data: previous.data, error: String(error?.message ?? error) },
|
|
618
|
+
)
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
load()
|
|
622
|
+
const timer = setInterval(load, POLL_MS)
|
|
623
|
+
return () => {
|
|
624
|
+
cancelled = true
|
|
625
|
+
clearInterval(timer)
|
|
626
|
+
}
|
|
627
|
+
}, [])
|
|
628
|
+
|
|
629
|
+
return h(OverviewView, { t, state, range, view, onRange: setRange, onView: setView })
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Register the settings section.
|
|
634
|
+
*
|
|
635
|
+
* Nothing in here may throw. A fault during `apply` aborts the whole mount,
|
|
636
|
+
* and the section then simply never appears — a failure mode this plugin has
|
|
637
|
+
* already hit twice on the host side, where a `TypeError` from one
|
|
638
|
+
* registration silently took the rest of `apply` with it. `locale` is a
|
|
639
|
+
* declared dependency and should always be present, but the section is
|
|
640
|
+
* readable without it, so it degrades to untranslated labels instead of
|
|
641
|
+
* disappearing.
|
|
642
|
+
*
|
|
643
|
+
* @param {object} ctx - the Cordis context for this plugin's client fiber.
|
|
644
|
+
* @returns {void}
|
|
645
|
+
*/
|
|
646
|
+
function apply(ctx) {
|
|
647
|
+
const hasLocale = typeof ctx.locale?.bind === 'function'
|
|
648
|
+
const t = hasLocale ? ctx.locale.bind(NS) : (key) => key
|
|
649
|
+
if (hasLocale && typeof ctx.locale.register === 'function') {
|
|
650
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-token-ledger: dictionaries')
|
|
651
|
+
}
|
|
652
|
+
ctx.effect(() => installStyles(), 'dsh-token-ledger: styles')
|
|
653
|
+
|
|
654
|
+
const registration = {
|
|
655
|
+
name: 'settings.section',
|
|
656
|
+
id: 'token-ledger',
|
|
657
|
+
order: 40,
|
|
658
|
+
label: () => t('nav'),
|
|
659
|
+
inject: () => ({ t }),
|
|
660
|
+
}
|
|
661
|
+
// DSH's own models section omits `locale` when it has no dictionary to
|
|
662
|
+
// resolve against, so the field is optional and only meaningful when the
|
|
663
|
+
// namespace was actually registered.
|
|
664
|
+
if (hasLocale) registration.locale = NS
|
|
665
|
+
|
|
666
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(registration, TokenLedgerSection))
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
exports.apply = apply
|
|
670
|
+
exports.inject = inject
|
|
671
|
+
// The view is exported so it can be rendered on its own. The client loader
|
|
672
|
+
// only reads `apply` and `inject`, and a presentation component that can be
|
|
673
|
+
// rendered without a fetch is what makes the visible half testable at all —
|
|
674
|
+
// the alternative is a DOM and a browser in CI.
|
|
675
|
+
exports.OverviewView = OverviewView
|
|
676
|
+
return module.exports
|
|
677
|
+
},
|
|
678
|
+
})
|