dsh-token-total 1.0.4 → 1.0.6
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 +1163 -62
- package/lib/index.js +392 -1
- package/lib/types/client/ModelLogo.d.ts +18 -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/model-logos-core.d.ts +11 -0
- package/lib/types/client/model-logos-data.d.ts +8 -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/ModelLogo.tsx +29 -0
- package/src/client/SessionUsageView.module.css +122 -0
- package/src/client/SessionUsageView.tsx +216 -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/model-logos-core.ts +101 -0
- package/src/client/model-logos-data.ts +58 -0
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/collector.ts +7 -0
- package/src/index.ts +8 -1
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { modelLogoMarkup, resolveModelLogo } from './model-logos-core.ts'
|
|
2
|
+
|
|
3
|
+
/** Neutral glyph for models no bundled brand matches: a soft four-point AI
|
|
4
|
+
* spark in the panel's muted gray, so an unknown model reads as "unbranded"
|
|
5
|
+
* rather than as some other vendor. */
|
|
6
|
+
const FALLBACK = '<path d="M12 1.6l2.14 5.96a2 2 0 0 0 1.22 1.22l5.96 2.14-5.96 2.14a2 2 0 0 0-1.22 1.22L12 20.24l-2.14-5.96a2 2 0 0 0-1.22-1.22L2.68 10.92l5.96-2.14a2 2 0 0 0 1.22-1.22L12 1.6z" fill="#9aa3b3"/>'
|
|
7
|
+
|
|
8
|
+
export interface ModelLogoProps {
|
|
9
|
+
/** Raw model id; may itself be a "provider/model" ref. */
|
|
10
|
+
model: string
|
|
11
|
+
/** Resolved model display name (the by-model row's label) — matched with
|
|
12
|
+
* the raw id, so an opaque route id beside a brandful name still brands. */
|
|
13
|
+
name?: string
|
|
14
|
+
/** Provider route id — a secondary match signal for opaque model ids. */
|
|
15
|
+
provider?: string
|
|
16
|
+
/** Resolved provider display names (the by-model row's split), matched last. */
|
|
17
|
+
providers?: ReadonlyArray<string>
|
|
18
|
+
/** Rendered square size; the record rows read at 13px. */
|
|
19
|
+
size?: number
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One model's brand logo inline. The markup is a bundled constant (never
|
|
24
|
+
* request data) rendered as-is so each brand keeps its own gradients,
|
|
25
|
+
* circles and multi-path fills; ids inside stay unique across brands. */
|
|
26
|
+
export function ModelLogo({ model, name, provider, providers, size = 13, className }: ModelLogoProps): JSX.Element {
|
|
27
|
+
const markup = modelLogoMarkup(resolveModelLogo(provider, model, name, providers)) ?? FALLBACK
|
|
28
|
+
return <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" className={className} dangerouslySetInnerHTML={{ __html: markup }} />
|
|
29
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
/* By-model rows lead with the model's brand logo; the name keeps truncating
|
|
44
|
+
* inside the flexible remainder of the cell. */
|
|
45
|
+
.modelNameCell { display:flex; align-items:center; gap:6px; min-width:0; }
|
|
46
|
+
.modelNameCell .ellipsis { flex:1; min-width:0; }
|
|
47
|
+
/* Gray provider label under a model name shown by several rows (同名模型). */
|
|
48
|
+
.modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
49
|
+
.number { font-variant-numeric:tabular-nums; }
|
|
50
|
+
.speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
|
|
51
|
+
.speedCell small { font-size:9px; color:#929aaa; }
|
|
52
|
+
.good { color:#21a17b; }
|
|
53
|
+
.warn { color:#d69a21; }
|
|
54
|
+
.bad { color:#e06571; }
|
|
55
|
+
.live { color:#169bb3; }
|
|
56
|
+
.muted { color:#939aaa; }
|
|
57
|
+
.empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
|
|
58
|
+
.action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
|
|
59
|
+
.action:hover { color:#279cb6; }
|
|
60
|
+
.action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
|
|
61
|
+
.turnList { padding:0 14px 14px; }
|
|
62
|
+
.turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
|
|
63
|
+
.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; }
|
|
64
|
+
.chevron { color:#99a1af; transition:transform 0.2s; }
|
|
65
|
+
.rotated { transform:rotate(90deg); }
|
|
66
|
+
.turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
|
|
67
|
+
/* Turn topic is the group title now (the page header owns the session name),
|
|
68
|
+
* so it carries the primary text color and truncates before the metrics. */
|
|
69
|
+
.turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
70
|
+
/* A subagent's turns look like this session's turns apart from the agent tag;
|
|
71
|
+
* the tag keeps the full session name in its tooltip. */
|
|
72
|
+
.turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
|
|
73
|
+
.turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
|
|
74
|
+
.turnSummary > span:last-child { margin-left:8px; }
|
|
75
|
+
.turnBody { border-top:1px solid #eef0f4; }
|
|
76
|
+
.turnBody[hidden] { display:none; }
|
|
77
|
+
/* A request row is one line: time, what answered, and its metrics pinned to the
|
|
78
|
+
* row end. The metrics never wrap; they truncate with a tooltip before the head
|
|
79
|
+
* gives up its model name. */
|
|
80
|
+
.turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
|
|
81
|
+
.turnRequest:first-child { border-top:0; }
|
|
82
|
+
.turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
|
|
83
|
+
.requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
|
|
84
|
+
.statusDot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }
|
|
85
|
+
/* Per-model brand logo: colors are baked into each logo's markup, only the
|
|
86
|
+
* layout contract lives here. Unknown models get the neutral gray fallback. */
|
|
87
|
+
.modelLogo { flex-shrink:0; }
|
|
88
|
+
.recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
|
|
89
|
+
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
|
|
90
|
+
.effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
|
|
91
|
+
.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; }
|
|
92
|
+
.note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
|
|
93
|
+
.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; }
|
|
94
|
+
@container (max-width:1050px) {
|
|
95
|
+
.mid { grid-template-columns:minmax(0,1fr); }
|
|
96
|
+
.statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
97
|
+
.statValue { font-size:23px; }
|
|
98
|
+
.table { font-size:12px; }
|
|
99
|
+
.childName { width:34%; }
|
|
100
|
+
}
|
|
101
|
+
@container (max-width:700px) {
|
|
102
|
+
.page { padding:18px 14px 24px; }
|
|
103
|
+
.stat { padding:12px 12px 11px; }
|
|
104
|
+
.statValue { font-size:20px; }
|
|
105
|
+
.table { font-size:10px; }
|
|
106
|
+
.table th,.table td { padding:10px 3px; }
|
|
107
|
+
.table th:first-child,.table td:first-child { padding-left:10px; }
|
|
108
|
+
.table th:last-child,.table td:last-child { padding-right:10px; }
|
|
109
|
+
.childName { width:25%; }
|
|
110
|
+
.childRequests { width:11%; }
|
|
111
|
+
.modelName { width:25%; }
|
|
112
|
+
.modelRequests { width:11%; }
|
|
113
|
+
.modelHit { width:17%; }
|
|
114
|
+
.turnHead { gap:8px; padding:11px 10px; }
|
|
115
|
+
.turnSummary { grid-column:2; font-size:10px; }
|
|
116
|
+
.turnRequest { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }
|
|
117
|
+
.turnRequestHead { gap:6px; }
|
|
118
|
+
.routeText { display:none; }
|
|
119
|
+
}
|
|
120
|
+
@media (prefers-reduced-motion:reduce) {
|
|
121
|
+
.chevron { transition:none; }
|
|
122
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
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 { ModelLogo } from './ModelLogo.tsx'
|
|
9
|
+
import css from './SessionUsageView.module.css'
|
|
10
|
+
|
|
11
|
+
type T = (key: SessionUsageKey) => string
|
|
12
|
+
type Row = SessionUsageDetail['records'][number]
|
|
13
|
+
export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
|
|
14
|
+
|
|
15
|
+
const seconds = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : '—'
|
|
16
|
+
// Request-log style metrics follow the reference log: seconds without trailing
|
|
17
|
+
// ".0", short lowercase tokens (80.0k / 1.5M), integer percentages.
|
|
18
|
+
const secText = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? String(Math.round(ms / 100) / 10) : '—'
|
|
19
|
+
const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
|
|
20
|
+
const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
|
|
21
|
+
const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
|
|
22
|
+
const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
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.modelNameCell}><ModelLogo model={row.model} name={row.name} providers={row.providers} className={css.modelLogo} /><span className={css.ellipsis} title={row.model}>{name}</span></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}`}><ModelLogo provider={record.provider} model={record.model} className={css.modelLogo} />{record.model || '—'}</span>
|
|
191
|
+
<span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
|
|
192
|
+
{statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
|
|
193
|
+
{record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
|
|
194
|
+
</div>
|
|
195
|
+
{record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
|
|
196
|
+
</div>
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
</article>
|
|
200
|
+
})}</div>{detail.records.length === 0 && <p className={css.empty}>{t('empty')}</p>}
|
|
201
|
+
</section>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
205
|
+
export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
206
|
+
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></>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
|
|
210
|
+
const { detail, loading, error, retry } = useSessionDetail(sessionId)
|
|
211
|
+
return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
|
|
212
|
+
{loading && <p className={css.empty} role="status">{t('loading')}</p>}
|
|
213
|
+
{error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
|
|
214
|
+
{detail && <SessionUsageContent detail={detail} t={t} />}
|
|
215
|
+
</div></div>
|
|
216
|
+
}
|
|
@@ -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.
|