dsh-token-total 1.0.4 → 1.0.5
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 +16 -2
- package/lib/client.js +995 -62
- package/lib/index.js +387 -0
- package/lib/types/client/SessionUsageView.d.ts +14 -0
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/format.d.ts +1 -1
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/session-usage-locales.d.ts +62 -0
- package/lib/types/client/use-session-detail.d.ts +8 -0
- package/lib/types/routes.d.ts +2 -2
- package/lib/types/session-detail.d.ts +3 -0
- package/lib/types/wire.d.ts +55 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +117 -0
- package/src/client/SessionUsageView.tsx +217 -0
- package/src/client/TokenTotalPanel.tsx +31 -19
- package/src/client/api.ts +8 -2
- package/src/client/format.ts +4 -2
- package/src/client/index.tsx +20 -1
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* A light, self-contained Usage surface. Tables fit the available width:
|
|
2
|
+
* no horizontal scroll containers, fixed column contracts, titles for names.
|
|
3
|
+
* Stat cards follow the reference dashboard: tinted left edge + icon chip,
|
|
4
|
+
* label, big value, small subtitle. Request log folds by conversation turn. */
|
|
5
|
+
.scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
|
|
6
|
+
.page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
|
|
7
|
+
.page * { box-sizing:border-box; }
|
|
8
|
+
.scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
|
|
9
|
+
.stats { display:grid; gap:12px; margin-bottom:18px; }
|
|
10
|
+
.statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
|
|
11
|
+
.stat { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }
|
|
12
|
+
.statHead { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
|
|
13
|
+
.statIcon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }
|
|
14
|
+
.statMark { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }
|
|
15
|
+
.statLabel { font-size:12px; color:#7d8495; }
|
|
16
|
+
.statValue { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }
|
|
17
|
+
.statSub { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
18
|
+
.toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
|
|
19
|
+
.tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
|
|
20
|
+
.toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
|
|
21
|
+
.toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
|
|
22
|
+
.toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
|
|
23
|
+
.tonePink { --tone:#d9538c; --tone-soft:#fbeaf1; }
|
|
24
|
+
.card { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }
|
|
25
|
+
.cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }
|
|
26
|
+
.cardHead h2 { font-size:14px; font-weight:600; margin:0; }
|
|
27
|
+
.cardHead > span { display:flex; gap:7px; align-items:center; font-size:11px; }
|
|
28
|
+
.count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }
|
|
29
|
+
.mid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
|
|
30
|
+
.mid .card { height:100%; margin-bottom:0; }
|
|
31
|
+
.mid { margin-bottom:18px; }
|
|
32
|
+
.table { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }
|
|
33
|
+
.table th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
|
|
34
|
+
.table td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
35
|
+
.table th:first-child,.table td:first-child { text-align:left; padding-left:16px; }
|
|
36
|
+
.table th:last-child,.table td:last-child { padding-right:16px; }
|
|
37
|
+
.childName { width:28%; }
|
|
38
|
+
.childRequests { width:11%; }
|
|
39
|
+
.modelName { width:30%; }
|
|
40
|
+
.modelRequests { width:11%; }
|
|
41
|
+
.modelHit { width:16%; }
|
|
42
|
+
.ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }
|
|
43
|
+
/* Gray provider label under a model name shown by several rows (同名模型). */
|
|
44
|
+
.modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
45
|
+
.number { font-variant-numeric:tabular-nums; }
|
|
46
|
+
.speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
|
|
47
|
+
.speedCell small { font-size:9px; color:#929aaa; }
|
|
48
|
+
.good { color:#21a17b; }
|
|
49
|
+
.warn { color:#d69a21; }
|
|
50
|
+
.bad { color:#e06571; }
|
|
51
|
+
.live { color:#169bb3; }
|
|
52
|
+
.muted { color:#939aaa; }
|
|
53
|
+
.empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
|
|
54
|
+
.action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
|
|
55
|
+
.action:hover { color:#279cb6; }
|
|
56
|
+
.action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
|
|
57
|
+
.turnList { padding:0 14px 14px; }
|
|
58
|
+
.turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
|
|
59
|
+
.turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
|
|
60
|
+
.chevron { color:#99a1af; transition:transform 0.2s; }
|
|
61
|
+
.rotated { transform:rotate(90deg); }
|
|
62
|
+
.turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
|
|
63
|
+
/* Turn topic is the group title now (the page header owns the session name),
|
|
64
|
+
* so it carries the primary text color and truncates before the metrics. */
|
|
65
|
+
.turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
66
|
+
/* A subagent's turns look like this session's turns apart from the agent tag;
|
|
67
|
+
* the tag keeps the full session name in its tooltip. */
|
|
68
|
+
.turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
|
|
69
|
+
.turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
|
|
70
|
+
.turnSummary > span:last-child { margin-left:8px; }
|
|
71
|
+
.turnBody { border-top:1px solid #eef0f4; }
|
|
72
|
+
.turnBody[hidden] { display:none; }
|
|
73
|
+
/* A request row is one line: time, what answered, and its metrics pinned to the
|
|
74
|
+
* row end. The metrics never wrap; they truncate with a tooltip before the head
|
|
75
|
+
* gives up its model name. */
|
|
76
|
+
.turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
|
|
77
|
+
.turnRequest:first-child { border-top:0; }
|
|
78
|
+
.turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
|
|
79
|
+
.requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
|
|
80
|
+
.statusDot { width:7px; height:7px; border-radius:50%; background:currentColor; flex-shrink:0; }
|
|
81
|
+
.routeDot { width:6px; height:6px; border-radius:50%; background:#21a17b; flex-shrink:0; }
|
|
82
|
+
.whale { color:#4d6bfe; flex-shrink:0; }
|
|
83
|
+
.recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
|
|
84
|
+
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
|
|
85
|
+
.effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
|
|
86
|
+
.recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }
|
|
87
|
+
.note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
|
|
88
|
+
.error { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
|
|
89
|
+
@container (max-width:1050px) {
|
|
90
|
+
.mid { grid-template-columns:minmax(0,1fr); }
|
|
91
|
+
.statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
92
|
+
.statValue { font-size:23px; }
|
|
93
|
+
.table { font-size:12px; }
|
|
94
|
+
.childName { width:34%; }
|
|
95
|
+
}
|
|
96
|
+
@container (max-width:700px) {
|
|
97
|
+
.page { padding:18px 14px 24px; }
|
|
98
|
+
.stat { padding:12px 12px 11px; }
|
|
99
|
+
.statValue { font-size:20px; }
|
|
100
|
+
.table { font-size:10px; }
|
|
101
|
+
.table th,.table td { padding:10px 3px; }
|
|
102
|
+
.table th:first-child,.table td:first-child { padding-left:10px; }
|
|
103
|
+
.table th:last-child,.table td:last-child { padding-right:10px; }
|
|
104
|
+
.childName { width:25%; }
|
|
105
|
+
.childRequests { width:11%; }
|
|
106
|
+
.modelName { width:25%; }
|
|
107
|
+
.modelRequests { width:11%; }
|
|
108
|
+
.modelHit { width:17%; }
|
|
109
|
+
.turnHead { gap:8px; padding:11px 10px; }
|
|
110
|
+
.turnSummary { grid-column:2; font-size:10px; }
|
|
111
|
+
.turnRequest { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }
|
|
112
|
+
.turnRequestHead { gap:6px; }
|
|
113
|
+
.routeText { display:none; }
|
|
114
|
+
}
|
|
115
|
+
@media (prefers-reduced-motion:reduce) {
|
|
116
|
+
.chevron { transition:none; }
|
|
117
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import type { SessionUsageDetail, SessionUsageTotals } from '../wire.ts'
|
|
4
|
+
import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
|
|
5
|
+
import { formatCompact, formatTokens, normalizeModelName } from './format.ts'
|
|
6
|
+
import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
|
|
7
|
+
import { useSessionDetail } from './use-session-detail.ts'
|
|
8
|
+
import css from './SessionUsageView.module.css'
|
|
9
|
+
|
|
10
|
+
type T = (key: SessionUsageKey) => string
|
|
11
|
+
type Row = SessionUsageDetail['records'][number]
|
|
12
|
+
export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
|
|
13
|
+
|
|
14
|
+
const seconds = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : '—'
|
|
15
|
+
// Request-log style metrics follow the reference log: seconds without trailing
|
|
16
|
+
// ".0", short lowercase tokens (80.0k / 1.5M), integer percentages.
|
|
17
|
+
const secText = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? String(Math.round(ms / 100) / 10) : '—'
|
|
18
|
+
const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
|
|
19
|
+
const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
|
|
20
|
+
const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
|
|
21
|
+
const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
22
|
+
const whale = 'M8 39.5C13.5 27.6 26.8 19.4 42.5 19.1c-1.6 2.4-2.3 5-2 7.9-5.6-1.7-11.7-.4-16.2 3.4 4.7 1.1 8.4 4.3 10.2 8.7 1.3-.4 2.7-1 4-1.9.4 2.1-.2 4.3-1.6 5.9-7.7.6-15.5-1.6-20.7-6.6-.7 1.9-.9 3.9-.6 5.9-3.1-1.2-5.6-3-7.6-2.9z'
|
|
23
|
+
const modelShort = (ref: string): string => ref.includes('/') ? ref.slice(ref.indexOf('/') + 1) : ref
|
|
24
|
+
const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
25
|
+
const sum = (rows: Row[]): SessionUsageTotals => rows.reduce((acc, r) => ({
|
|
26
|
+
uncachedInputTokens: acc.uncachedInputTokens + r.totals.uncachedInputTokens,
|
|
27
|
+
outputTokens: acc.outputTokens + r.totals.outputTokens,
|
|
28
|
+
cacheReadTokens: acc.cacheReadTokens + r.totals.cacheReadTokens,
|
|
29
|
+
cacheWriteTokens: acc.cacheWriteTokens + r.totals.cacheWriteTokens,
|
|
30
|
+
}), zero())
|
|
31
|
+
|
|
32
|
+
function Token({ n }: { n: number }): JSX.Element {
|
|
33
|
+
return <span className={css.number} title={formatTokens(n)}>{formatCompact(n)}</span>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Hit({ totals }: { totals: SessionUsageTotals }): JSX.Element {
|
|
37
|
+
const rate = cacheHitPercentOf(totals)
|
|
38
|
+
return <span className={rate === null ? css.muted : rate >= 80 ? css.good : rate >= 50 ? css.warn : css.bad}>{rate === null ? '—' : `${rate.toFixed(1)}%`}</span>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const icons = {
|
|
42
|
+
requests: 'M4 9h16M4 15h16M10 3 8 21M16 3l-2 18',
|
|
43
|
+
tokens: 'M4 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 0v8c0 2.2 3.6 4 8 4s8-1.8 8-4V8',
|
|
44
|
+
hit: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
|
|
45
|
+
speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
|
|
46
|
+
input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
|
|
47
|
+
output: 'M12 21V9M7 14l5-5 5 5M4 3h16',
|
|
48
|
+
read: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
|
|
49
|
+
write: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Stat({ tone, name, label, value, sub, title }: { tone: string; name: keyof typeof icons; label: string; value: string | JSX.Element; sub?: string; title?: string }): JSX.Element {
|
|
53
|
+
const icon = icons[name]
|
|
54
|
+
return <article className={`${css.stat} ${tone}`}>
|
|
55
|
+
{/* Oversized gradient watermark filling the card's empty right side. */}
|
|
56
|
+
<svg className={css.statMark} viewBox="0 0 24 24" aria-hidden="true">
|
|
57
|
+
<defs><linearGradient id={`usage-mark-${name}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity="0.3" /><stop offset="1" stopColor="currentColor" stopOpacity="0.03" /></linearGradient></defs>
|
|
58
|
+
<path d={icon} fill={`url(#usage-mark-${name})`} stroke={`url(#usage-mark-${name})`} strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
|
|
59
|
+
</svg>
|
|
60
|
+
<div className={css.statHead}><span className={css.statIcon}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span><span className={css.statLabel}>{label}</span></div>
|
|
61
|
+
<strong className={css.statValue} title={title}>{value}</strong>
|
|
62
|
+
{sub !== undefined && <span className={css.statSub}>{sub}</span>}
|
|
63
|
+
</article>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
67
|
+
const billed = billedTotal(detail.totals)
|
|
68
|
+
const failed = detail.records.filter(row => row.status === 'failed').length
|
|
69
|
+
const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
|
|
70
|
+
return <div className={css.stats}>
|
|
71
|
+
<div className={css.statRow}>
|
|
72
|
+
<Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
|
|
73
|
+
<Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
|
|
74
|
+
<Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
|
|
75
|
+
<Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className={css.statRow}>
|
|
78
|
+
<Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
|
|
79
|
+
<Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
|
|
80
|
+
<Stat tone={css.tonePurple} name="read" label={t('readFull')} value={formatCompact(detail.totals.cacheReadTokens)} title={formatTokens(detail.totals.cacheReadTokens)} sub={formatTokens(detail.totals.cacheReadTokens)} />
|
|
81
|
+
<Stat tone={css.tonePink} name="write" label={t('writeFull')} value={formatCompact(detail.totals.cacheWriteTokens)} title={formatTokens(detail.totals.cacheWriteTokens)} sub={formatTokens(detail.totals.cacheWriteTokens)} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Tables({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
87
|
+
// The by-model card labels rows with the model selector's display name, never
|
|
88
|
+
// the route id (which stays in the tooltip). When two rows end up sharing one
|
|
89
|
+
// display name, each of those rows additionally shows its provider in gray
|
|
90
|
+
// below the name so they stay tellable apart.
|
|
91
|
+
const nameCounts = new Map<string, number>()
|
|
92
|
+
for (const row of detail.models) {
|
|
93
|
+
const key = normalizeModelName(row.name?.trim() || row.model)
|
|
94
|
+
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1)
|
|
95
|
+
}
|
|
96
|
+
return <div className={css.mid}>
|
|
97
|
+
<section className={css.card}><header className={css.cardHead}><h2>{t('children')} <span className={css.count}>{detail.subagents.length}</span></h2></header>
|
|
98
|
+
<table className={`${css.table} ${css.childTable}`}><colgroup><col className={css.childName} /><col className={css.childRequests} /><col /><col /><col /><col /></colgroup>
|
|
99
|
+
<thead><tr><th>{t('session')}</th><th>{t('requests')}</th><th>{t('tokens')}</th><th>{t('hit')}</th><th>{t('first')}</th><th>{t('speed')}</th></tr></thead>
|
|
100
|
+
<tbody>{detail.subagents.map(row => <tr key={row.sessionId}><td><span className={css.ellipsis} title={row.name || row.sessionId}>{row.name || row.sessionId}</span></td><td>{formatTokens(row.requests)}</td><td><Token n={billedTotal(row.totals)} /></td><td><Hit totals={row.totals} /></td><td>{seconds(row.averageFirstTokenMs)}</td><td><span className={css.speedCell}>{row.tokensPerSecond === null ? '—' : <>{Math.round(row.tokensPerSecond)}<small>tok/s</small></>}</span></td></tr>)}</tbody>
|
|
101
|
+
</table>{detail.subagents.length === 0 && <p className={css.empty}>{t('noChildren')}</p>}
|
|
102
|
+
</section>
|
|
103
|
+
<section className={css.card}><header className={css.cardHead}><h2>{t('models')} <span className={css.count}>{detail.models.length}</span></h2></header>
|
|
104
|
+
<table className={`${css.table} ${css.modelTable}`}><colgroup><col className={css.modelName} /><col className={css.modelRequests} /><col /><col /><col /><col /><col className={css.modelHit} /></colgroup>
|
|
105
|
+
<thead><tr><th>{t('model')}</th><th>{t('requests')}</th><th>{t('input')}</th><th>{t('output')}</th><th>{t('read')}</th><th>{t('write')}</th><th>{t('hit')}</th></tr></thead>
|
|
106
|
+
<tbody>{detail.models.map(row => {
|
|
107
|
+
const name = row.name?.trim() || row.model
|
|
108
|
+
const providers = row.providers ?? []
|
|
109
|
+
const shared = (nameCounts.get(normalizeModelName(name)) ?? 0) > 1
|
|
110
|
+
return <tr key={row.model}>
|
|
111
|
+
<td><span className={css.ellipsis} title={row.model}>{name}</span>
|
|
112
|
+
{shared && providers.length > 0 ? <small className={css.modelProvider} title={providers.join(' / ')}>{providers.join(' / ')}</small> : null}
|
|
113
|
+
</td>
|
|
114
|
+
<td>{formatTokens(row.requests)}</td><td><Token n={row.totals.uncachedInputTokens} /></td><td><Token n={row.totals.outputTokens} /></td><td><Token n={row.totals.cacheReadTokens} /></td><td><Token n={row.totals.cacheWriteTokens} /></td><td><Hit totals={row.totals} /></td>
|
|
115
|
+
</tr>
|
|
116
|
+
})}</tbody>
|
|
117
|
+
</table>{detail.models.length === 0 && <p className={css.empty}>{t('noModels')}</p>}
|
|
118
|
+
</section>
|
|
119
|
+
</div>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
123
|
+
const [opened, setOpened] = useState<Set<string>>(() => new Set())
|
|
124
|
+
const [allOpen, setAllOpen] = useState(false)
|
|
125
|
+
const toggleAll = (): void => { setAllOpen(!allOpen); setOpened(new Set()) }
|
|
126
|
+
const toggle = (id: string): void => setOpened(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next })
|
|
127
|
+
const providerNames = new Map(detail.routes.map(route => [route.provider, route.name]))
|
|
128
|
+
// Fold by conversation turn: one collapsible group per session turn, holding
|
|
129
|
+
// every request of that turn. A group is titled by what the turn did (the
|
|
130
|
+
// turn's first real user prompt, turnName from the backend), never by the
|
|
131
|
+
// session name: the page header already names this session, so repeating it on
|
|
132
|
+
// every row would only crowd out the topic. A subagent's turn gets a compact
|
|
133
|
+
// "subagent" tag instead, with the full session name kept in its tooltip —
|
|
134
|
+
// that tag is the only thing distinguishing it from this session's own turns.
|
|
135
|
+
const groups: Array<{ key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }> = []
|
|
136
|
+
const index = new Map<string, { key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }>()
|
|
137
|
+
for (const record of detail.records) {
|
|
138
|
+
const key = `${record.sessionId}:${record.turn}`
|
|
139
|
+
let group = index.get(key)
|
|
140
|
+
if (!group) {
|
|
141
|
+
group = {
|
|
142
|
+
key,
|
|
143
|
+
turn: record.turn,
|
|
144
|
+
name: record.sessionName || record.sessionId,
|
|
145
|
+
topic: record.turnName || `${t('turn')} ${record.turn}`,
|
|
146
|
+
main: record.sessionId === detail.sessionId,
|
|
147
|
+
rows: [],
|
|
148
|
+
}
|
|
149
|
+
index.set(key, group); groups.push(group)
|
|
150
|
+
}
|
|
151
|
+
group.rows.push(record)
|
|
152
|
+
}
|
|
153
|
+
return <section className={css.card}><header className={css.cardHead}><h2>{t('records')} <span className={css.count}>{detail.records.length}</span></h2><button type="button" className={css.action} onClick={toggleAll}>{t(allOpen ? 'collapse' : 'expand')}</button></header>
|
|
154
|
+
<div className={css.turnList}>{groups.map(group => {
|
|
155
|
+
const open = allOpen !== opened.has(group.key)
|
|
156
|
+
const totals = sum(group.rows)
|
|
157
|
+
const billed = billedTotal(totals)
|
|
158
|
+
const running = group.rows.filter(row => row.status === 'running').length
|
|
159
|
+
const rates = group.rows.map(row => row.tokensPerSecond).filter((n): n is number => n !== null)
|
|
160
|
+
const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null
|
|
161
|
+
return <article key={group.key} className={css.turn}>
|
|
162
|
+
<button type="button" className={css.turnHead} onClick={() => toggle(group.key)} aria-expanded={open} aria-controls={`usage-turn-${encodeURIComponent(group.key)}`}>
|
|
163
|
+
<svg className={`${css.chevron} ${open ? css.rotated : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
|
|
164
|
+
<span className={css.turnTitle}>
|
|
165
|
+
{group.main ? null : <span className={css.turnAgent} title={group.name}>{t('children')}</span>}
|
|
166
|
+
<span className={css.turnTopic} title={group.turn === 0 ? t('compaction') : group.topic}>{group.turn === 0 ? t('compaction') : group.topic}</span>
|
|
167
|
+
</span>
|
|
168
|
+
<span className={css.turnSummary}>
|
|
169
|
+
<span>{group.rows.length} {t('reqTimes')} · <span className={css.number} title={formatTokens(billed)}>{tokShort(billed)}</span> {t('tokenWord')}{running > 0 ? ` · ${running} ${t('inProgress')}` : ''}</span>
|
|
170
|
+
<span>{t('avgCache')} {pctInt(cacheHitPercentOf(totals))}</span>
|
|
171
|
+
<span>{t('avgRate')} {avgRate === null ? '—' : avgRate} {t('perSec')}</span>
|
|
172
|
+
</span>
|
|
173
|
+
</button>
|
|
174
|
+
<div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
|
|
175
|
+
{group.rows.map(record => {
|
|
176
|
+
const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
|
|
177
|
+
const billed = billedTotal(record.totals)
|
|
178
|
+
const hit = cacheHitPercentOf(record.totals)
|
|
179
|
+
const meta: Array<{ text: string; title?: string }> = []
|
|
180
|
+
if (duration !== null) meta.push({ text: `${secText(duration)} ${t('sec')}` })
|
|
181
|
+
if (record.firstTokenMs !== null) meta.push({ text: `${t('firstWait')} ${secText(record.firstTokenMs)} ${t('sec')}` })
|
|
182
|
+
meta.push({ text: `${tokShort(billed)} ${t('tokenWord')}`, title: formatTokens(billed) })
|
|
183
|
+
if (hit !== null) meta.push({ text: `${t('cacheShort')} ${pctInt(hit)}` })
|
|
184
|
+
if (record.tokensPerSecond !== null) meta.push({ text: `${Math.round(record.tokensPerSecond)} ${t('perSec')}` })
|
|
185
|
+
const statusText = record.status === 'running' ? t('answering') : record.status === 'completed' ? '' : t(record.status)
|
|
186
|
+
return <div key={record.id} className={css.turnRequest}>
|
|
187
|
+
<span className={css.requestTime}>{clock(record.startedAt)}</span>
|
|
188
|
+
<div className={css.turnRequestHead}>
|
|
189
|
+
<span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
|
|
190
|
+
<span className={css.recordModel} title={`${record.provider} / ${record.model}`}><svg className={css.whale} width="13" height="13" viewBox="0 0 64 64" aria-hidden="true"><path d={whale} fill="currentColor" /></svg>{record.model || '—'}</span>
|
|
191
|
+
<span className={css.routeDot} />
|
|
192
|
+
<span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
|
|
193
|
+
{statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
|
|
194
|
+
{record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
|
|
195
|
+
</div>
|
|
196
|
+
{record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
|
|
197
|
+
</div>
|
|
198
|
+
})}
|
|
199
|
+
</div>
|
|
200
|
+
</article>
|
|
201
|
+
})}</div>{detail.records.length === 0 && <p className={css.empty}>{t('empty')}</p>}
|
|
202
|
+
</section>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
206
|
+
export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
207
|
+
return <><p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} /><Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p></>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
|
|
211
|
+
const { detail, loading, error, retry } = useSessionDetail(sessionId)
|
|
212
|
+
return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
|
|
213
|
+
{loading && <p className={css.empty} role="status">{t('loading')}</p>}
|
|
214
|
+
{error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
|
|
215
|
+
{detail && <SessionUsageContent detail={detail} t={t} />}
|
|
216
|
+
</div></div>
|
|
217
|
+
}
|
|
@@ -670,30 +670,36 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
670
670
|
const maxTotal = Math.max(1, ...visible.map((d) => d.total))
|
|
671
671
|
const ticks = niceTicks(maxTotal, 4)
|
|
672
672
|
// What the X axis reads as depends on the WINDOW, not just the granularity:
|
|
673
|
-
// a multi-day hourly span (3天) is a DATE axis — one short date per day,
|
|
674
|
-
//
|
|
675
|
-
// a single-day hourly span (今日) is a CLOCK axis — the date is
|
|
676
|
-
// would only repeat. Daily ranges stay date labels.
|
|
673
|
+
// a multi-day hourly span (3天) is a DATE axis — one short date per day,
|
|
674
|
+
// centred over that day's bar cluster, times would be noise across three
|
|
675
|
+
// day-clusters; a single-day hourly span (今日) is a CLOCK axis — the date is
|
|
676
|
+
// constant and would only repeat. Daily ranges stay date labels.
|
|
677
677
|
const dayCount = new Set(visible.map((d) => d.day.slice(0, 10))).size
|
|
678
678
|
const multiDayHourly = granularity === 'hour' && dayCount > 1
|
|
679
679
|
const axisMode: 'day' | 'hour' = multiDayHourly ? 'day' : granularity
|
|
680
|
-
// Label
|
|
681
|
-
|
|
680
|
+
// Label groups: on a multi-day hourly axis each DAY is one group whose label
|
|
681
|
+
// sits at the MIDDLE of that day's bars (centred on the day, not left-
|
|
682
|
+
// aligned to its first bar); otherwise every bucket is its own group and
|
|
683
|
+
// the label sits on its bar.
|
|
684
|
+
const labelGroups: Array<{ start: number; end: number }> = []
|
|
682
685
|
for (let i = 0; i < n; i++) {
|
|
683
|
-
|
|
686
|
+
const last = labelGroups[labelGroups.length - 1]
|
|
687
|
+
if (multiDayHourly && last && visible[last.end].day.slice(0, 10) === visible[i].day.slice(0, 10)) last.end = i
|
|
688
|
+
else labelGroups.push({ start: i, end: i })
|
|
684
689
|
}
|
|
685
|
-
//
|
|
686
|
-
// rightmost
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
+
// Groups are then thinned by POSITION, walking from the right edge: the
|
|
691
|
+
// rightmost group always keeps its label, an earlier one is kept only when
|
|
692
|
+
// it clears the previously kept label by the label width. (A fixed "every
|
|
693
|
+
// Nth" count plus a forced last label drew two labels on top of each other
|
|
694
|
+
// — the "18:00/23:00" overlap.) Both label shapes are ~30px.
|
|
690
695
|
const labelMinGap = 42
|
|
691
|
-
const
|
|
696
|
+
const labeledGroups = new Set<number>()
|
|
692
697
|
let lastLabelX = Number.POSITIVE_INFINITY
|
|
693
|
-
for (let k =
|
|
694
|
-
const
|
|
698
|
+
for (let k = labelGroups.length - 1; k >= 0; k--) {
|
|
699
|
+
const g = labelGroups[k]!
|
|
700
|
+
const x = padL + barHalf + ((g.start + g.end) / 2) * step
|
|
695
701
|
if (lastLabelX - x >= labelMinGap) {
|
|
696
|
-
|
|
702
|
+
labeledGroups.add(k)
|
|
697
703
|
lastLabelX = x
|
|
698
704
|
}
|
|
699
705
|
}
|
|
@@ -776,12 +782,18 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
776
782
|
}}
|
|
777
783
|
onMouseLeave={() => setTip(null)}
|
|
778
784
|
/>
|
|
779
|
-
{labeledIdx.has(i) && (
|
|
780
|
-
<text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, axisMode)}</text>
|
|
781
|
-
)}
|
|
782
785
|
</g>
|
|
783
786
|
)
|
|
784
787
|
})}
|
|
788
|
+
{labelGroups.map((g, k) => labeledGroups.has(k) && (
|
|
789
|
+
<text
|
|
790
|
+
key={`axis-${k}`}
|
|
791
|
+
className={css.axis}
|
|
792
|
+
x={padL + barHalf + ((g.start + g.end) / 2) * step}
|
|
793
|
+
y={H - 8}
|
|
794
|
+
textAnchor="middle"
|
|
795
|
+
>{bucketAxisLabel(visible[g.start].day, axisMode)}</text>
|
|
796
|
+
))}
|
|
785
797
|
<path className={css.trend} d={trendPath} fill="none" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
|
786
798
|
{trendTipPt && (
|
|
787
799
|
<circle className={css.trendDot} cx={trendTipPt.x} cy={trendTipPt.y} r={4} />
|
package/src/client/api.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* prefix-stripping proxy; the host's own route key stays absolute
|
|
12
12
|
* (`/token-total/api` in src/routes.ts), because only the browser half is relative.
|
|
13
13
|
*/
|
|
14
|
-
import type { BackfillStatus, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
14
|
+
import type { BackfillStatus, SessionUsageDetail, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
15
15
|
|
|
16
16
|
/** One wire failure. */
|
|
17
17
|
export class UsageApiError extends Error {
|
|
@@ -28,13 +28,14 @@ interface WireResponse<T> {
|
|
|
28
28
|
value: T
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function post<T>(method: string, body: unknown): Promise<T> {
|
|
31
|
+
async function post<T>(method: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
|
32
32
|
let res: Response
|
|
33
33
|
try {
|
|
34
34
|
res = await fetch(`token-total/api/${method}`, {
|
|
35
35
|
method: 'POST',
|
|
36
36
|
headers: { 'content-type': 'application/json' },
|
|
37
37
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
signal,
|
|
38
39
|
})
|
|
39
40
|
} catch (err) {
|
|
40
41
|
throw new UsageApiError('network', err instanceof Error ? err.message : String(err))
|
|
@@ -73,3 +74,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
|
|
|
73
74
|
export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
|
|
74
75
|
return post<SessionUsageTree>('session', { sessionId })
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Log-owned requests and live route states for the per-session Usage view. */
|
|
79
|
+
export async function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail> {
|
|
80
|
+
return post<SessionUsageDetail>('session-detail', { sessionId }, signal)
|
|
81
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
// Shared with the host half (src/shared.ts has no Node/DOM types, so the
|
|
12
12
|
// client bundle may import it at runtime).
|
|
13
|
-
export { providerOf, modelNameOf, daysInRange as daysBetween } from '../shared.ts'
|
|
13
|
+
export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts'
|
|
14
14
|
|
|
15
15
|
/** Exact token counts with thousands separators — no 万/亿/k/M units, the
|
|
16
16
|
* user reads the precise number (long values shrink to fit via FitText).
|
|
@@ -62,7 +62,9 @@ export function indexOfDay(day: string): number {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function shortDay(day: string): string {
|
|
65
|
-
|
|
65
|
+
// Bucket keys may carry an hour suffix ("YYYY-MM-DDTHH"); only the date
|
|
66
|
+
// prefix is a date — Number('22T14') is NaN and used to render "10/NaN".
|
|
67
|
+
const parts = day.slice(0, 10).split('-')
|
|
66
68
|
const m = parts[1]
|
|
67
69
|
const d = parts[2]
|
|
68
70
|
return `${Number(m)}/${Number(d)}`
|
package/src/client/index.tsx
CHANGED
|
@@ -38,6 +38,8 @@ import { ContextUsageEntry, CONTEXT_LOCALE_NS, CONTEXT_LOCALES } from './Context
|
|
|
38
38
|
import { installContextTakeover } from './context-takeover.ts'
|
|
39
39
|
import { LOCALE_NS, en, zh, zhTW, type TokenTotalKey } from './locales.ts'
|
|
40
40
|
import { isCompatibleGlass, whenGlassReady } from './glass.ts'
|
|
41
|
+
import { SessionUsageView } from './SessionUsageView.tsx'
|
|
42
|
+
import { SESSION_USAGE_NS, usageEn, usageZh, usageZhTW, type SessionUsageKey } from './session-usage-locales.ts'
|
|
41
43
|
import css from './TokenTotalPanel.module.css'
|
|
42
44
|
|
|
43
45
|
/** This bundle's npm package name — the key the Plugins page matches on. */
|
|
@@ -77,6 +79,7 @@ export type TokenTotalPanelPageProps = PropsLocale<typeof LOCALE_NS> & {
|
|
|
77
79
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
78
80
|
interface LocaleNamespaceMap {
|
|
79
81
|
'tokenTotal': TokenTotalKey
|
|
82
|
+
'sessionUsage': SessionUsageKey
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -172,7 +175,12 @@ export function apply(ctx: Context): void {
|
|
|
172
175
|
const offZhTw = ctx.locale.register(LOCALE_NS, 'zh-TW', zhTW)
|
|
173
176
|
const offContext = Object.entries(CONTEXT_LOCALES).map(([locale, dict]) =>
|
|
174
177
|
ctx.locale.register(CONTEXT_LOCALE_NS, locale, dict as Record<string, string>))
|
|
175
|
-
|
|
178
|
+
const offSession = [
|
|
179
|
+
ctx.locale.register(SESSION_USAGE_NS, 'zh', usageZh),
|
|
180
|
+
ctx.locale.register(SESSION_USAGE_NS, 'en', usageEn),
|
|
181
|
+
ctx.locale.register(SESSION_USAGE_NS, 'zh-TW', usageZhTW),
|
|
182
|
+
]
|
|
183
|
+
return () => { offZh(); offEn(); offZhTw(); for (const off of [...offContext, ...offSession]) off() }
|
|
176
184
|
}, 'dsh-token-total: dictionaries')
|
|
177
185
|
|
|
178
186
|
// Join the background plugin's frosted-glass sheet when it is installed,
|
|
@@ -274,6 +282,17 @@ export function apply(ctx: Context): void {
|
|
|
274
282
|
locale: LOCALE_NS,
|
|
275
283
|
}, StatsPanelIcon))
|
|
276
284
|
|
|
285
|
+
// Native session-scoped View: the host owns its tab, selection and composer.
|
|
286
|
+
// Waiting for declaration keeps older hosts without this seat harmless.
|
|
287
|
+
const sessionT = ctx.locale.bind(SESSION_USAGE_NS)
|
|
288
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
289
|
+
name: 'conversation.view',
|
|
290
|
+
id: 'token-total-usage',
|
|
291
|
+
order: 20,
|
|
292
|
+
label: () => sessionT('tab'),
|
|
293
|
+
locale: SESSION_USAGE_NS,
|
|
294
|
+
}, SessionUsageView))
|
|
295
|
+
|
|
277
296
|
// The bottom-bar takeover: shadow the official stats pills (same id, lower
|
|
278
297
|
// priority — the slot's lowest live entry renders) with this plugin's
|
|
279
298
|
// always-on precision, token breakdown and live-speed readouts.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Session Usage view translations, independent of the global dashboard. */
|
|
2
|
+
export const SESSION_USAGE_NS = 'sessionUsage'
|
|
3
|
+
export const usageZh = {
|
|
4
|
+
tab: '用量', requests: '请求数', tokens: '总 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
|
|
5
|
+
input: '入', output: '出', read: '读', write: '写',
|
|
6
|
+
requestsCount: '请求数量', inputFull: '输入', outputFull: '输出', readFull: '缓存读取', writeFull: '缓存写入',
|
|
7
|
+
cacheHit: '缓存命中', compaction: '压缩调用', reqTimes: '个请求',
|
|
8
|
+
children: '子代理', session: '会话', models: '按模型', model: '模型',
|
|
9
|
+
records: '请求记录', expand: '全部展开', collapse: '全部收起', loading: '正在读取会话用量…',
|
|
10
|
+
empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
|
|
11
|
+
scope: '本会话及所有子代理 · 历史日志累计',
|
|
12
|
+
note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
|
|
13
|
+
failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
|
|
14
|
+
main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
|
|
15
|
+
provider: '供应商', status: '状态', exact: '完整 token 数',
|
|
16
|
+
answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
17
|
+
cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
|
|
18
|
+
}
|
|
19
|
+
export type SessionUsageKey = keyof typeof usageZh
|
|
20
|
+
export const usageEn: Record<SessionUsageKey, string> = {
|
|
21
|
+
tab: 'Usage', requests: 'Requests', tokens: 'Total tokens', hit: 'Cache hit rate', first: 'Avg. first token', speed: 'Token speed',
|
|
22
|
+
input: 'In', output: 'Out', read: 'Read', write: 'Write',
|
|
23
|
+
requestsCount: 'Requests', inputFull: 'Input', outputFull: 'Output', readFull: 'Cache read', writeFull: 'Cache write',
|
|
24
|
+
cacheHit: 'cache hits', compaction: 'Compaction', reqTimes: 'requests',
|
|
25
|
+
children: 'Subagents', session: 'Session', models: 'By model', model: 'Model',
|
|
26
|
+
records: 'Request log', expand: 'Expand all', collapse: 'Collapse all', loading: 'Reading session usage…',
|
|
27
|
+
empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
|
|
28
|
+
scope: 'This session and all subagents · Historical log totals',
|
|
29
|
+
note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
|
|
30
|
+
failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
|
|
31
|
+
main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
|
|
32
|
+
provider: 'Provider', status: 'Status', exact: 'Exact token count',
|
|
33
|
+
answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
|
|
34
|
+
cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
|
|
35
|
+
}
|
|
36
|
+
export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
37
|
+
...usageZh,
|
|
38
|
+
tab: '用量', requests: '請求數', tokens: '總 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
|
|
39
|
+
input: '入', output: '出', read: '讀', write: '寫',
|
|
40
|
+
requestsCount: '請求數量', inputFull: '輸入', outputFull: '輸出', readFull: '快取讀取', writeFull: '快取寫入',
|
|
41
|
+
cacheHit: '快取命中', compaction: '壓縮呼叫', reqTimes: '個請求',
|
|
42
|
+
children: '子代理', session: '會話', models: '按模型', records: '請求記錄', expand: '全部展開', collapse: '全部收起',
|
|
43
|
+
loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
|
|
44
|
+
scope: '本會話及所有子代理 · 歷史日誌累計',
|
|
45
|
+
note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
|
|
46
|
+
failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
|
|
47
|
+
main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
|
|
48
|
+
answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
49
|
+
cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Serialized polling for one visible Usage view, never leaking another
|
|
2
|
+
* session's answer. Abort and stop all timers when the tab unmounts. */
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import type { SessionUsageDetail } from '../wire.ts'
|
|
5
|
+
import { fetchSessionDetail } from './api.ts'
|
|
6
|
+
|
|
7
|
+
export const DETAIL_POLL_MS = 1_500
|
|
8
|
+
|
|
9
|
+
export function useSessionDetail(sessionId: string): {
|
|
10
|
+
detail: SessionUsageDetail | null
|
|
11
|
+
loading: boolean
|
|
12
|
+
error: boolean
|
|
13
|
+
retry: () => void
|
|
14
|
+
} {
|
|
15
|
+
const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
|
|
16
|
+
const refresh = useRef<(() => void) | null>(null)
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let active = true
|
|
19
|
+
let pending = false
|
|
20
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
21
|
+
let controller: AbortController | undefined
|
|
22
|
+
const request = (): void => {
|
|
23
|
+
if (!active || pending || !sessionId) return
|
|
24
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
25
|
+
pending = true
|
|
26
|
+
controller = new AbortController()
|
|
27
|
+
void fetchSessionDetail(sessionId, controller.signal).then(detail => {
|
|
28
|
+
if (active) setReading({ sessionId, detail, error: false })
|
|
29
|
+
}).catch(() => {
|
|
30
|
+
if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
|
|
31
|
+
}).finally(() => {
|
|
32
|
+
pending = false
|
|
33
|
+
if (active) timer = setTimeout(request, DETAIL_POLL_MS)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
const wake = (): void => { if (document.visibilityState === 'visible') request() }
|
|
37
|
+
refresh.current = request
|
|
38
|
+
document.addEventListener('visibilitychange', wake)
|
|
39
|
+
request()
|
|
40
|
+
return () => {
|
|
41
|
+
active = false
|
|
42
|
+
refresh.current = null
|
|
43
|
+
controller?.abort()
|
|
44
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
45
|
+
document.removeEventListener('visibilitychange', wake)
|
|
46
|
+
}
|
|
47
|
+
}, [sessionId])
|
|
48
|
+
const current = reading?.sessionId === sessionId ? reading : null
|
|
49
|
+
return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
|
|
50
|
+
}
|