dsh-token-total 1.1.0 → 1.1.2
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 +7 -4
- package/lib/client.js +407 -523
- package/lib/index.js +209 -6
- package/lib/types/client/UsageCards.d.ts +28 -0
- package/lib/types/client/context-window-core.d.ts +9 -0
- package/lib/types/client/session-usage-locales.d.ts +17 -0
- package/lib/types/context-window.d.ts +5 -0
- package/lib/types/usage-buckets.d.ts +14 -0
- package/lib/types/usage-composition.d.ts +12 -0
- package/lib/types/wire.d.ts +30 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.tsx +6 -6
- package/src/client/StatsLineEnhanced.tsx +30 -22
- package/src/client/UsageCards.module.css +74 -0
- package/src/client/UsageCards.tsx +244 -0
- package/src/client/context-window-core.ts +39 -0
- package/src/client/session-usage-locales.ts +27 -3
- package/src/client/stats-line-core.ts +14 -5
- package/src/context-window.ts +15 -5
- package/src/session-detail.ts +3 -2
- package/src/usage-buckets.ts +27 -0
- package/src/usage-composition.ts +203 -0
- package/src/wire.ts +37 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two cards that replaced the context-window panel.
|
|
3
|
+
*
|
|
4
|
+
* Left: what the window holds RIGHT NOW (the context snapshot). Right: what the
|
|
5
|
+
* session's requests actually spent, split by content (the spend composition).
|
|
6
|
+
* They answer two different questions — "what is in the prompt" and "what did
|
|
7
|
+
* the whole session pay for" — so they sit side by side at equal weight rather
|
|
8
|
+
* than one above the other.
|
|
9
|
+
*
|
|
10
|
+
* Everything is drawn from the detail payload the view already polls every
|
|
11
|
+
* 1.5 s (`useSessionDetail`), so both cards refresh with no extra timer. The
|
|
12
|
+
* charts are hand-rolled SVG/CSS: the plugin ships no chart dependency, and a
|
|
13
|
+
* ring plus a stacked bar is little enough markup to keep it that way.
|
|
14
|
+
*
|
|
15
|
+
* The palette is the plugin's tone scale, matching the stat cards above and the
|
|
16
|
+
* dashboard charts; dark values flip under `body[data-ds-dark-theme]` (the
|
|
17
|
+
* host's own theme switch), and every surface uses the host's semantic tokens
|
|
18
|
+
* so the cards sit on the same canvas as the tables.
|
|
19
|
+
*/
|
|
20
|
+
import type {
|
|
21
|
+
SessionContextWindow,
|
|
22
|
+
SessionUsageBucket,
|
|
23
|
+
SessionUsageComposition,
|
|
24
|
+
SessionUsageDetail,
|
|
25
|
+
} from '../wire.ts'
|
|
26
|
+
import { bucketPercent, contextBucketTotals, contextReading } from './context-window-core.ts'
|
|
27
|
+
import { cacheRate, formatCompact, formatPercent, formatTokens } from './format.ts'
|
|
28
|
+
import { cacheHitPercentOf } from './session-usage-core.ts'
|
|
29
|
+
import type { SessionUsageKey } from './session-usage-locales.ts'
|
|
30
|
+
import { CONTEXT_BUCKET_ORDER, USAGE_BUCKET_ORDER } from '../usage-buckets.ts'
|
|
31
|
+
import css from './UsageCards.module.css'
|
|
32
|
+
|
|
33
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string
|
|
34
|
+
|
|
35
|
+
/** Legend names, one per bucket (the context-window panel's category names are
|
|
36
|
+
* a different split — files live inside tool results here). */
|
|
37
|
+
const BUCKET_LABEL: Record<SessionUsageBucket, SessionUsageKey> = {
|
|
38
|
+
systemPrompt: 'bucketSystemPrompt',
|
|
39
|
+
user: 'bucketUser',
|
|
40
|
+
skill: 'bucketSkill',
|
|
41
|
+
toolResult: 'bucketToolResult',
|
|
42
|
+
toolDef: 'bucketToolDef',
|
|
43
|
+
injected: 'bucketInjected',
|
|
44
|
+
assistant: 'bucketAssistant',
|
|
45
|
+
output: 'bucketOutput',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Each bucket brings its own accent as a CSS custom property, so the swatch,
|
|
49
|
+
* the ring segment, the bar segment and the value text all read one variable. */
|
|
50
|
+
const BUCKET_TONE: Record<SessionUsageBucket, string> = {
|
|
51
|
+
systemPrompt: css.toneSystemPrompt,
|
|
52
|
+
user: css.toneUser,
|
|
53
|
+
skill: css.toneSkill,
|
|
54
|
+
toolResult: css.toneToolResult,
|
|
55
|
+
toolDef: css.toneToolDef,
|
|
56
|
+
injected: css.toneInjected,
|
|
57
|
+
assistant: css.toneAssistant,
|
|
58
|
+
output: css.toneOutput,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Row {
|
|
62
|
+
key: SessionUsageBucket
|
|
63
|
+
tokens: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Approximate value as the card prints it: "≈299.1k". */
|
|
67
|
+
const approx = (n: number): string => `≈${formatCompact(n)}`
|
|
68
|
+
|
|
69
|
+
/** Ring geometry: a 116 px donut with a 15 px stroke. */
|
|
70
|
+
const RING_SIZE = 116
|
|
71
|
+
const RING_STROKE = 15
|
|
72
|
+
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2 - 2
|
|
73
|
+
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
|
|
74
|
+
|
|
75
|
+
/** Donut of bucket shares with the grand total in the middle. Segments are
|
|
76
|
+
* stroked along one circle and offset by the running length, so the ring is
|
|
77
|
+
* DPI-independent and needs no chart library. `total` sizes the segments (the
|
|
78
|
+
* replay estimate); `center` is the exact billed figure the card headlines. */
|
|
79
|
+
function Ring({ rows, total, center, centerTitle, caption }: {
|
|
80
|
+
rows: readonly Row[]
|
|
81
|
+
total: number
|
|
82
|
+
center: string
|
|
83
|
+
centerTitle: string
|
|
84
|
+
caption: string
|
|
85
|
+
}): JSX.Element {
|
|
86
|
+
const drawn = rows.filter(row => row.tokens > 0)
|
|
87
|
+
let offset = 0
|
|
88
|
+
return <div className={css.ringWrap}>
|
|
89
|
+
<svg className={css.ring} width={RING_SIZE} height={RING_SIZE} viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`} role="img" aria-label={caption}>
|
|
90
|
+
<title>{caption}</title>
|
|
91
|
+
<circle className={css.ringTrack} cx={RING_SIZE / 2} cy={RING_SIZE / 2} r={RING_RADIUS} fill="none" strokeWidth={RING_STROKE} />
|
|
92
|
+
<g transform={`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`}>
|
|
93
|
+
{drawn.map(row => {
|
|
94
|
+
const length = total > 0 ? (row.tokens / total) * RING_CIRCUMFERENCE : 0
|
|
95
|
+
// A hairline gap keeps adjacent hues from merging into one band; the
|
|
96
|
+
// segment never shrinks below 0.6 px even when it is a rounding crumb.
|
|
97
|
+
const visible = Math.max(length - 1.2, 0.6)
|
|
98
|
+
const dash = `${visible} ${RING_CIRCUMFERENCE - visible}`
|
|
99
|
+
const circle = <circle
|
|
100
|
+
key={row.key}
|
|
101
|
+
className={BUCKET_TONE[row.key]}
|
|
102
|
+
cx={RING_SIZE / 2}
|
|
103
|
+
cy={RING_SIZE / 2}
|
|
104
|
+
r={RING_RADIUS}
|
|
105
|
+
fill="none"
|
|
106
|
+
stroke="var(--bucket)"
|
|
107
|
+
strokeWidth={RING_STROKE}
|
|
108
|
+
strokeDasharray={dash}
|
|
109
|
+
strokeDashoffset={-offset}
|
|
110
|
+
/>
|
|
111
|
+
offset += length
|
|
112
|
+
return circle
|
|
113
|
+
})}
|
|
114
|
+
</g>
|
|
115
|
+
</svg>
|
|
116
|
+
<div className={css.ringCenter}>
|
|
117
|
+
<strong className={css.ringTotal} title={centerTitle}>{center}</strong>
|
|
118
|
+
<span className={css.ringCaption}>{caption}</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One legend entry: swatch, name, share, approximate value. */
|
|
124
|
+
function LegendRow({ row, percent, suffix, t }: {
|
|
125
|
+
row: Row
|
|
126
|
+
percent: number
|
|
127
|
+
suffix?: string
|
|
128
|
+
t: T
|
|
129
|
+
}): JSX.Element {
|
|
130
|
+
return <li className={`${css.legendRow} ${BUCKET_TONE[row.key]}`}>
|
|
131
|
+
<i className={css.swatch} aria-hidden="true" />
|
|
132
|
+
<span className={css.legendName}>{t(BUCKET_LABEL[row.key])}</span>
|
|
133
|
+
<span className={css.legendPercent}>{percent.toFixed(1)}%</span>
|
|
134
|
+
<span className={css.legendValue} title={formatTokens(row.tokens)}>
|
|
135
|
+
{approx(row.tokens)}{suffix === undefined ? '' : <span className={css.legendSuffix}> · {suffix}</span>}
|
|
136
|
+
</span>
|
|
137
|
+
</li>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Left card: the live window's composition, its occupancy and the hard cap. */
|
|
141
|
+
function ContextCard({ context, t }: { context: SessionContextWindow; t: T }): JSX.Element {
|
|
142
|
+
const reading = contextReading(context)
|
|
143
|
+
const rows = contextBucketTotals(context.items)
|
|
144
|
+
// Percentages answer "what share of the window's content", so they divide by
|
|
145
|
+
// the sum of the drawn buckets — not by the billed prompt, which can differ
|
|
146
|
+
// when upstream content rewriting happens. Occupancy keeps its own reading.
|
|
147
|
+
const total = rows.reduce((sum, row) => sum + row.tokens, 0)
|
|
148
|
+
// The strip spans the model's window, never just the used part: each segment
|
|
149
|
+
// takes its share of the drawn content times the share of the window in use,
|
|
150
|
+
// so the tinted run stops exactly where "used" stops and the track behind it
|
|
151
|
+
// is the room still free. Without a reported window there is no scale to draw
|
|
152
|
+
// against, so the strip falls back to the drawn content itself.
|
|
153
|
+
const capacity = context.contextWindow !== null && context.contextWindow > 0 ? context.contextWindow : total
|
|
154
|
+
const occupied = Math.min(100, bucketPercent(reading.usedTokens, capacity))
|
|
155
|
+
const windowText = context.contextWindow !== null && context.contextWindow > 0
|
|
156
|
+
? t('contextWindowTotal', { used: formatCompact(reading.usedTokens), window: formatCompact(context.contextWindow) })
|
|
157
|
+
: t('contextWindowTotalUnknown', { used: formatCompact(reading.usedTokens) })
|
|
158
|
+
return <section className={`${css.card} ${css.contextCard}`}>
|
|
159
|
+
<header className={css.head}>
|
|
160
|
+
<h2 className={css.title}>{t('currentContextTitle')}</h2>
|
|
161
|
+
<span className={css.model} title={`${context.provider} / ${context.model}`}>
|
|
162
|
+
{[context.provider, context.model].filter(Boolean).join(' · ') || '—'}
|
|
163
|
+
</span>
|
|
164
|
+
</header>
|
|
165
|
+
<div className={css.body}>
|
|
166
|
+
<div className={css.totalRow}>
|
|
167
|
+
<strong className={css.total} title={formatTokens(reading.usedTokens)}>{windowText}</strong>
|
|
168
|
+
{reading.percent !== null && <span className={css.totalShare}>
|
|
169
|
+
<b>{formatPercent(reading.percent)}</b> {t('contextUsedShort')}
|
|
170
|
+
</span>}
|
|
171
|
+
</div>
|
|
172
|
+
{/* One stacked strip across the whole window: a bucket's width is its
|
|
173
|
+
share of what the next request carries, scaled by how full the window
|
|
174
|
+
is, so the remainder reads as the room still free. */}
|
|
175
|
+
<div className={css.bar} role="img" aria-label={t('currentContextTitle')}>
|
|
176
|
+
{rows.map(row => row.tokens > 0 && total > 0
|
|
177
|
+
? <span key={row.key} className={`${css.barSeg} ${BUCKET_TONE[row.key]}`} style={{ width: `${bucketPercent(row.tokens, total) * occupied / 100}%` }} />
|
|
178
|
+
: null)}
|
|
179
|
+
{total <= 0 && <span className={css.barEmpty} />}
|
|
180
|
+
</div>
|
|
181
|
+
<ul className={css.legend}>
|
|
182
|
+
{rows.map(row => <LegendRow key={row.key} row={row} percent={bucketPercent(row.tokens, total)} t={t} />)}
|
|
183
|
+
</ul>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Right card: every request's replayed content plus the billed output. */
|
|
189
|
+
function StatsCard({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
190
|
+
const composition = detail.composition ?? null
|
|
191
|
+
const rows = composition?.buckets ?? []
|
|
192
|
+
const total = rows.reduce((sum, row) => sum + row.tokens, 0)
|
|
193
|
+
const billed = composition?.billedTokens ?? 0
|
|
194
|
+
// Prefer the session's exact cache split; the live window's snapshot is the
|
|
195
|
+
// fallback for an older host payload that predates the composition.
|
|
196
|
+
const hit = composition !== null
|
|
197
|
+
? cacheHitPercentOf(composition.totals)
|
|
198
|
+
: detail.context?.cacheKnown
|
|
199
|
+
? cacheRate(detail.context.cacheHitTokens, detail.context.cacheMissTokens)
|
|
200
|
+
: null
|
|
201
|
+
return <section className={`${css.card} ${css.statsCard}`}>
|
|
202
|
+
<header className={css.head}>
|
|
203
|
+
<h2 className={css.title}>{t('statsTitle')}</h2>
|
|
204
|
+
{hit !== null && <span className={css.cacheBadge}>
|
|
205
|
+
{t('statsCacheHit')} <b>{hit.toFixed(2)}%</b>
|
|
206
|
+
</span>}
|
|
207
|
+
</header>
|
|
208
|
+
<div className={css.body}>
|
|
209
|
+
{composition === null
|
|
210
|
+
? <p className={css.empty}>{t('statsEmpty')}</p>
|
|
211
|
+
: <>
|
|
212
|
+
<div className={css.statsMain}>
|
|
213
|
+
<Ring rows={rows} total={total} center={formatCompact(billed)} centerTitle={formatTokens(billed)} caption={t('statsTotal')} />
|
|
214
|
+
<ul className={css.legend}>
|
|
215
|
+
{USAGE_BUCKET_ORDER.map(key => {
|
|
216
|
+
const row = rows.find(item => item.key === key) ?? { key, tokens: 0 }
|
|
217
|
+
return <LegendRow
|
|
218
|
+
key={key}
|
|
219
|
+
row={row}
|
|
220
|
+
percent={bucketPercent(row.tokens, total)}
|
|
221
|
+
suffix={key === 'output' ? t('statsIncludesThinking') : undefined}
|
|
222
|
+
t={t}
|
|
223
|
+
/>
|
|
224
|
+
})}
|
|
225
|
+
</ul>
|
|
226
|
+
</div>
|
|
227
|
+
</>}
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The pair, rendered where the context-window panel used to sit. */
|
|
233
|
+
export function SessionUsageCards({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element | null {
|
|
234
|
+
if (detail.context === null || detail.context === undefined) {
|
|
235
|
+
if (detail.composition === null || detail.composition === undefined) return null
|
|
236
|
+
return <div className={css.grid}>
|
|
237
|
+
<StatsCard detail={detail} t={t} />
|
|
238
|
+
</div>
|
|
239
|
+
}
|
|
240
|
+
return <div className={css.grid}>
|
|
241
|
+
<ContextCard context={detail.context} t={t} />
|
|
242
|
+
<StatsCard detail={detail} t={t} />
|
|
243
|
+
</div>
|
|
244
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* separate from the component so both are testable without a DOM.
|
|
5
5
|
*/
|
|
6
6
|
import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts'
|
|
7
|
+
import type { SessionUsageBucket } from '../wire.ts'
|
|
8
|
+
import { CONTEXT_BUCKET_ORDER } from '../usage-buckets.ts'
|
|
7
9
|
|
|
8
10
|
/** Legend order, also the color assignment order (see the CSS module). */
|
|
9
11
|
export const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[] = [
|
|
@@ -30,6 +32,43 @@ export function contextCategoryTotals(
|
|
|
30
32
|
.filter(row => row.tokens > 0)
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/** Which legend bucket one window row belongs to.
|
|
36
|
+
*
|
|
37
|
+
* The row categories are the treemap's (they group files separately); the
|
|
38
|
+
* cards speak the eight-bucket legend instead. A folded `rest` row lost its
|
|
39
|
+
* kind, so a conversation tail lands with the assistant — the larger half of
|
|
40
|
+
* what a turn replays.
|
|
41
|
+
*/
|
|
42
|
+
function contextBucketOf(item: SessionContextItem): SessionUsageBucket {
|
|
43
|
+
switch (item.category) {
|
|
44
|
+
case 'system': return 'systemPrompt'
|
|
45
|
+
case 'tools': return 'toolDef'
|
|
46
|
+
case 'memory': return item.kind === 'skill-catalog' ? 'skill' : 'injected'
|
|
47
|
+
case 'files':
|
|
48
|
+
case 'results': return 'toolResult'
|
|
49
|
+
case 'conversation': return item.kind === 'user' ? 'user' : 'assistant'
|
|
50
|
+
default: return 'injected'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The live window folded into the same legend the spend card prints, in
|
|
55
|
+
* legend order and keeping zero buckets so both cards stay aligned. */
|
|
56
|
+
export function contextBucketTotals(
|
|
57
|
+
items: readonly SessionContextItem[],
|
|
58
|
+
): Array<{ key: SessionUsageBucket; tokens: number }> {
|
|
59
|
+
const totals = new Map<SessionUsageBucket, number>()
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const key = contextBucketOf(item)
|
|
62
|
+
totals.set(key, (totals.get(key) ?? 0) + item.tokens)
|
|
63
|
+
}
|
|
64
|
+
return CONTEXT_BUCKET_ORDER.map(key => ({ key, tokens: totals.get(key) ?? 0 }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a share of the whole is, for the legend column. */
|
|
68
|
+
export function bucketPercent(tokens: number, total: number): number {
|
|
69
|
+
return total > 0 ? (tokens / total) * 100 : 0
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
/** Window occupancy in whole percent, or null when the host never reported the
|
|
34
73
|
* model's window (the panel then says "unknown" instead of guessing one). */
|
|
35
74
|
export function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null {
|
|
@@ -16,7 +16,7 @@ export const usageZh = {
|
|
|
16
16
|
answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
17
17
|
cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
|
|
18
18
|
live: '实时更新', asOf: '统计截至', refresh: '刷新',
|
|
19
|
-
contextTitle: '上下文窗口', contextUsed: '已用上下文', contextLive: '实时',
|
|
19
|
+
contextTitle: '上下文窗口', currentContextTitle: '当前上下文', contextUsed: '已用上下文', contextLive: '实时',
|
|
20
20
|
contextWindowKnown: '模型窗口 {value} · 已用 {percent}%', contextWindowUnknown: '模型的窗口大小未知',
|
|
21
21
|
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 窗口未知',
|
|
22
22
|
contextFree: '空闲',
|
|
@@ -30,6 +30,14 @@ export const usageZh = {
|
|
|
30
30
|
contextKindRest: '另外 {count} 项', contextTimes: '×{count}',
|
|
31
31
|
contextEmpty: '本会话还没有可统计的上下文', contextTipShare: '占上下文 {percent}', contextTipWindow: '占窗口 {percent}',
|
|
32
32
|
contextEstimateNote: '色块按历史重放估算 {estimate};供应商实测提示词 {billed}(上游已压缩或裁剪)',
|
|
33
|
+
// ── The two cards that replaced the context-window panel ──────────────────
|
|
34
|
+
statsTitle: 'Token 统计', statsTotal: '总用量', statsCacheHit: '缓存命中',
|
|
35
|
+
statsIncludesThinking: '含思考', statsEmpty: '本会话还没有可统计的 token',
|
|
36
|
+
contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
|
|
37
|
+
contextUsedShort: '上下文已用',
|
|
38
|
+
bucketSystemPrompt: '系统提示词', bucketUser: '用户消息', bucketSkill: '技能注入',
|
|
39
|
+
bucketToolResult: '工具结果', bucketToolDef: '工具定义', bucketInjected: '注入内容',
|
|
40
|
+
bucketAssistant: '助手消息', bucketOutput: '输出',
|
|
33
41
|
}
|
|
34
42
|
export type SessionUsageKey = keyof typeof usageZh
|
|
35
43
|
export const usageEn: Record<SessionUsageKey, string> = {
|
|
@@ -48,7 +56,7 @@ export const usageEn: Record<SessionUsageKey, string> = {
|
|
|
48
56
|
answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
|
|
49
57
|
cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
|
|
50
58
|
live: 'Live', asOf: 'as of', refresh: 'Refresh',
|
|
51
|
-
contextTitle: 'Context window', contextUsed: 'Context used', contextLive: 'Live',
|
|
59
|
+
contextTitle: 'Context window', currentContextTitle: 'Current context', contextUsed: 'Context used', contextLive: 'Live',
|
|
52
60
|
contextWindowKnown: 'Window {value} · {percent}% used', contextWindowUnknown: 'Model window size unknown',
|
|
53
61
|
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / window unknown',
|
|
54
62
|
contextFree: 'free',
|
|
@@ -62,6 +70,14 @@ export const usageEn: Record<SessionUsageKey, string> = {
|
|
|
62
70
|
contextKindRest: '{count} more', contextTimes: '×{count}',
|
|
63
71
|
contextEmpty: 'Nothing in this session’s context yet', contextTipShare: '{percent} of context', contextTipWindow: '{percent} of window',
|
|
64
72
|
contextEstimateNote: 'Blocks estimate {estimate} by replay; the provider billed {billed} after upstream compression',
|
|
73
|
+
// ── The two cards that replaced the context-window panel ──────────────────
|
|
74
|
+
statsTitle: 'Token stats', statsTotal: 'Total usage', statsCacheHit: 'Cache hit',
|
|
75
|
+
statsIncludesThinking: 'incl. thinking', statsEmpty: 'No token spend recorded for this session yet',
|
|
76
|
+
contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
|
|
77
|
+
contextUsedShort: 'context used',
|
|
78
|
+
bucketSystemPrompt: 'System prompt', bucketUser: 'User messages', bucketSkill: 'Skill injection',
|
|
79
|
+
bucketToolResult: 'Tool results', bucketToolDef: 'Tool definitions', bucketInjected: 'Injected content',
|
|
80
|
+
bucketAssistant: 'Assistant messages', bucketOutput: 'Output',
|
|
65
81
|
}
|
|
66
82
|
export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
67
83
|
...usageZh,
|
|
@@ -78,7 +94,7 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
|
78
94
|
answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
79
95
|
cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
|
|
80
96
|
live: '即時更新', asOf: '統計截至', refresh: '重新整理',
|
|
81
|
-
contextTitle: '上下文視窗', contextUsed: '已用上下文', contextLive: '即時',
|
|
97
|
+
contextTitle: '上下文視窗', currentContextTitle: '當前上下文', contextUsed: '已用上下文', contextLive: '即時',
|
|
82
98
|
contextWindowKnown: '模型視窗 {value} · 已用 {percent}%', contextWindowUnknown: '模型的視窗大小未知',
|
|
83
99
|
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 視窗未知',
|
|
84
100
|
contextFree: '空閒',
|
|
@@ -92,4 +108,12 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
|
92
108
|
contextKindRest: '另外 {count} 項', contextTimes: '×{count}',
|
|
93
109
|
contextEmpty: '本會話還沒有可統計的上下文', contextTipShare: '佔上下文 {percent}', contextTipWindow: '佔視窗 {percent}',
|
|
94
110
|
contextEstimateNote: '色塊按歷史重放估算 {estimate};供應商實測提示詞 {billed}(上游已壓縮或裁剪)',
|
|
111
|
+
// ── The two cards that replaced the context-window panel ──────────────────
|
|
112
|
+
statsTitle: 'Token 統計', statsTotal: '總用量', statsCacheHit: '快取命中',
|
|
113
|
+
statsIncludesThinking: '含思考', statsEmpty: '本會話還沒有可統計的 token',
|
|
114
|
+
contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
|
|
115
|
+
contextUsedShort: '上下文已用',
|
|
116
|
+
bucketSystemPrompt: '系統提示詞', bucketUser: '使用者訊息', bucketSkill: '技能注入',
|
|
117
|
+
bucketToolResult: '工具結果', bucketToolDef: '工具定義', bucketInjected: '注入內容',
|
|
118
|
+
bucketAssistant: '助手訊息', bucketOutput: '輸出',
|
|
95
119
|
}
|
|
@@ -314,16 +314,25 @@ const CJK_TOKENS_PER_CHAR = 0.6
|
|
|
314
314
|
const ASCII_TOKENS_PER_CHAR = 0.3
|
|
315
315
|
|
|
316
316
|
/**
|
|
317
|
-
* Shortest observable decode window before a live rate is published, in ms
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
317
|
+
* Shortest observable decode window before a live rate is published, in ms.
|
|
318
|
+
* Below it the first chunk's burst would read as hundreds of tok/s; the
|
|
319
|
+
* sampler also publishes its FIRST reading at exactly this mark rather than at
|
|
320
|
+
* the next steady cadence.
|
|
321
|
+
*
|
|
322
|
+
* The floor is deliberately short — 200 ms, down from the 500 ms MiMo-Code's
|
|
323
|
+
* sidebar tps holds — because every step opens a window of its own, and at
|
|
324
|
+
* 500 ms a step that finishes inside the floor never published at all: a run
|
|
325
|
+
* of short steps (one burst of tool-call arguments, then settle) kept showing
|
|
326
|
+
* a figure measured somewhere else while the screen was full of live output.
|
|
327
|
+
* At this length the opening burst is judged by the plausibility check below
|
|
328
|
+
* rather than by waiting it out, and the smoothing constant still carries the
|
|
329
|
+
* first reading only part of the way into an established figure.
|
|
321
330
|
*
|
|
322
331
|
* The floor is a publishing gate, NOT a rejection gate: it is applied after the
|
|
323
332
|
* plausibility check below, so a backlog frame can never dodge that check by
|
|
324
333
|
* arriving early (see {@link StreamingRateSampler.observe}).
|
|
325
334
|
*/
|
|
326
|
-
const MIN_STREAM_WINDOW_MS =
|
|
335
|
+
const MIN_STREAM_WINDOW_MS = 200
|
|
327
336
|
|
|
328
337
|
/**
|
|
329
338
|
* Longest window a live rate may span, in ms. This is the bound the previous
|
package/src/context-window.ts
CHANGED
|
@@ -132,6 +132,18 @@ function promptTokensOf(usage: unknown): number | null {
|
|
|
132
132
|
return input + cached > 0 ? input + cached : null
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Prompt tokens one tool definition costs: its name, description and JSON
|
|
136
|
+
* schema, plus the framing every schema node pays. 0 for a nameless entry.
|
|
137
|
+
* Shared with the spend composition, which replays the same catalogue on
|
|
138
|
+
* every request. */
|
|
139
|
+
export function toolTokens(tool: unknown): number {
|
|
140
|
+
const record = tool as { name?: unknown; description?: unknown; parameters?: unknown }
|
|
141
|
+
const name = String(record?.name ?? '')
|
|
142
|
+
if (name === '') return 0
|
|
143
|
+
return estimateTokens(name) + valueTokens(record?.description)
|
|
144
|
+
+ estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS
|
|
145
|
+
}
|
|
146
|
+
|
|
135
147
|
/** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
|
|
136
148
|
* never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
|
|
137
149
|
* a session with 200 tool calls still yields a readable treemap. */
|
|
@@ -279,11 +291,9 @@ export function buildSessionContextWindow(events: readonly UsageSessionEvent[]):
|
|
|
279
291
|
}
|
|
280
292
|
// The tool catalogue the next call sends: name + description + JSON schema.
|
|
281
293
|
for (const tool of tools) {
|
|
282
|
-
const
|
|
283
|
-
const name = String(
|
|
284
|
-
if (name === '') continue
|
|
285
|
-
const tokens = estimateTokens(name) + valueTokens(record?.description)
|
|
286
|
-
+ estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS
|
|
294
|
+
const tokens = toolTokens(tool)
|
|
295
|
+
const name = String((tool as { name?: unknown })?.name ?? '')
|
|
296
|
+
if (name === '' || tokens === 0) continue
|
|
287
297
|
add('tools', name, tokens)
|
|
288
298
|
}
|
|
289
299
|
const items = foldDrafts([...drafts.values()])
|
package/src/session-detail.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from './context-types.ts'
|
|
10
10
|
import { UsageFold } from './collector.ts'
|
|
11
11
|
import { buildSessionContextWindow } from './context-window.ts'
|
|
12
|
+
import { buildSessionUsageComposition, mergeUsageCompositions } from './usage-composition.ts'
|
|
12
13
|
import { resolveModelNames, resolveProviderNames } from './model-names.ts'
|
|
13
14
|
import { decodeRateOf } from './shared.ts'
|
|
14
15
|
import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
|
|
@@ -209,7 +210,7 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
|
|
|
209
210
|
// a cancelled step is not a wait the reader experienced as an answer.
|
|
210
211
|
const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
211
212
|
for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
|
|
212
|
-
return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), context: buildSessionContextWindow(own) }
|
|
213
|
+
return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), context: buildSessionContextWindow(own), composition: buildSessionUsageComposition(own) }
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
|
|
@@ -247,5 +248,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
|
|
|
247
248
|
// The card is scoped to this session and every subagent, so its speed and
|
|
248
249
|
// first-token average fold the same records its token totals do.
|
|
249
250
|
const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
250
|
-
return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null }
|
|
251
|
+
return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
|
|
251
252
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The legend order of the Token stats card, shared by the host accumulator and
|
|
3
|
+
* the client card so the two halves can never disagree about which bucket sits
|
|
4
|
+
* where. Left column first (what filled the prompt), right column second: the
|
|
5
|
+
* layout mirrors the reference design — system/user/skill/results on the left,
|
|
6
|
+
* tool definitions/injected/assistant/output on the right.
|
|
7
|
+
*
|
|
8
|
+
* Kept in its own dependency-free module: the host builds the numbers and the
|
|
9
|
+
* client paints them, and neither should pull the other's graph in for this.
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionUsageBucket } from './wire.ts'
|
|
12
|
+
|
|
13
|
+
export const USAGE_BUCKET_ORDER: readonly SessionUsageBucket[] = [
|
|
14
|
+
'systemPrompt',
|
|
15
|
+
'user',
|
|
16
|
+
'skill',
|
|
17
|
+
'toolResult',
|
|
18
|
+
'toolDef',
|
|
19
|
+
'injected',
|
|
20
|
+
'assistant',
|
|
21
|
+
'output',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** The buckets that describe the live window (output never occupies it). */
|
|
25
|
+
export const CONTEXT_BUCKET_ORDER: readonly SessionUsageBucket[] = USAGE_BUCKET_ORDER.filter(
|
|
26
|
+
(key) => key !== 'output',
|
|
27
|
+
)
|