Skip to content

Commit 87139f7

Browse files
authored
feat(console): latency block with output TPS on Logs and Access (#430)
Merge Logs Latency/TTFT into a three-row Timing column (首字 / 总耗时 / 输出 TPS) and reuse the same LatencyStats block under Access assistant replies. Derive tok/s client-side from completion_tokens over the post-TTFT generation window when streaming, with total-latency fallback. Co-authored-by: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com>
1 parent d2b65c6 commit 87139f7

17 files changed

Lines changed: 212 additions & 45 deletions

‎frontend/src/components/access/ChatMessage.tsx‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CopySimple, FileText } from '@phosphor-icons/react'
55
import { useI18n } from '@/hooks/useI18n'
66
import { copyText } from '@/lib/clipboard'
77
import { formatCompact, formatCredits } from '@/lib/format'
8+
import { LatencyStats } from '@/components/ui/LatencyStats'
89
import { duration, ease, gsap, prefersReducedMotion } from '@/lib/motion'
910
import { ThinkingBlock } from './ThinkingBlock'
1011
import { ToolCallList } from './ToolCallCard'
@@ -126,6 +127,10 @@ export function ChatMessageView({ message }: { message: ThreadMessage }) {
126127
const showSkeleton = message.status === 'streaming' && !message.content && !message.reasoning && !message.toolCalls?.length
127128
const canCopy = Boolean(message.content) && message.status === 'done'
128129
const streaming = message.status === 'streaming'
130+
const showUsage = Boolean(message.usage && message.usage.credits != null)
131+
const showLatency =
132+
message.status === 'done' &&
133+
(message.latency_ms != null || message.ttfb_ms != null || typeof message.usage?.completion_tokens === 'number')
129134

130135
async function onCopy() {
131136
if (!message.content) return
@@ -158,19 +163,35 @@ export function ChatMessageView({ message }: { message: ThreadMessage }) {
158163
<ContentSegments text={message.content} streaming={streaming} />
159164
</div>
160165
) : null}
161-
{(canCopy || (message.usage && message.usage.credits != null)) ? (
162-
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-0.5">
163-
{canCopy ? (
164-
<button
165-
type="button"
166-
className="inline-flex items-center gap-1 text-caption text-muted hover:text-foreground"
167-
onClick={() => void onCopy()}
168-
>
169-
<CopySimple size={12} />
170-
{copied ? t('copied') : t('copy')}
171-
</button>
166+
{(canCopy || showUsage || showLatency) ? (
167+
<div className="space-y-1.5 px-0.5">
168+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
169+
{canCopy ? (
170+
<button
171+
type="button"
172+
className="inline-flex items-center gap-1 text-caption text-muted hover:text-foreground"
173+
onClick={() => void onCopy()}
174+
>
175+
<CopySimple size={12} />
176+
{copied ? t('copied') : t('copy')}
177+
</button>
178+
) : null}
179+
<UsageFoot message={message} />
180+
</div>
181+
{showLatency ? (
182+
<LatencyStats
183+
latencyMs={message.latency_ms}
184+
ttfbMs={message.ttfb_ms}
185+
completionTokens={message.usage?.completion_tokens}
186+
stream
187+
labels={{
188+
heading: t('logsColLatency'),
189+
ttft: t('logsColTTFT'),
190+
total: t('logsLatencyTotal'),
191+
tps: t('logsOutputTPS'),
192+
}}
193+
/>
172194
) : null}
173-
<UsageFoot message={message} />
174195
</div>
175196
) : null}
176197
</div>

‎frontend/src/components/access/demoSeed.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export function buildDemoThread(): ThreadMessage[] {
5454
],
5555
reasoningOpen: false,
5656
usage: { credits: 0.75, prompt_tokens: 1840, completion_tokens: 126, source: 'upstream' },
57+
ttfb_ms: 380,
58+
latency_ms: 2140,
5759
status: 'done',
5860
},
5961
]

‎frontend/src/components/access/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export type ThreadMessage = {
3737
toolCalls?: ChatToolCall[]
3838
/** Per-turn usage from the stream; show credits only when set. */
3939
usage?: ThreadMessageUsage
40+
/** Client-measured TTFT (ms) from stream start to first content/reasoning chunk. */
41+
ttfb_ms?: number
42+
/** Client-measured total stream duration (ms). */
43+
latency_ms?: number
4044
status: 'pending' | 'streaming' | 'done' | 'error'
4145
error?: string
4246
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { formatLatency, computeOutputTokensPerSecond, formatTokensPerSecond } from '@/lib/format'
2+
3+
export type LatencyStatsLabels = {
4+
/** Optional block heading (e.g. 延迟). Column headers already cover this in tables. */
5+
heading?: string
6+
ttft: string
7+
total: string
8+
tps: string
9+
}
10+
11+
export type LatencyStatsProps = {
12+
latencyMs?: number | null
13+
ttfbMs?: number | null
14+
completionTokens?: number | null
15+
/** When true/undefined, prefer generation window (latency − TTFT). */
16+
stream?: boolean | null
17+
labels: LatencyStatsLabels
18+
className?: string
19+
}
20+
21+
/**
22+
* Three-row latency block: TTFT / total / output TPS.
23+
* Grayscale Studio Admin tokens — muted labels, mono values, soft left rule.
24+
*/
25+
export function LatencyStats({
26+
latencyMs,
27+
ttfbMs,
28+
completionTokens,
29+
stream,
30+
labels,
31+
className = '',
32+
}: LatencyStatsProps) {
33+
const tps = formatTokensPerSecond(
34+
computeOutputTokensPerSecond({
35+
completionTokens,
36+
latencyMs,
37+
ttfbMs,
38+
stream,
39+
}),
40+
)
41+
const rows: Array<[string, string]> = [
42+
[labels.ttft, formatLatency(ttfbMs)],
43+
[labels.total, formatLatency(latencyMs)],
44+
[labels.tps, tps],
45+
]
46+
47+
return (
48+
<div className={className}>
49+
{labels.heading ? (
50+
<div className="mb-1 text-caption font-medium text-muted">{labels.heading}</div>
51+
) : null}
52+
<div className="border-l border-separator pl-2 leading-4">
53+
{rows.map(([label, value]) => (
54+
<div key={label} className="flex items-baseline gap-2 py-px">
55+
<span className="shrink-0 text-caption text-muted">{label}</span>
56+
<span className="mono text-xs text-foreground">{value}</span>
57+
</div>
58+
))}
59+
</div>
60+
</div>
61+
)
62+
}

‎frontend/src/i18n/messages.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ export const messages: Record<Lang, Dict> = {
271271
logsColStatus: 'Status',
272272
logsColLatency: 'Latency',
273273
logsColTTFT: 'First token',
274+
logsLatencyTotal: 'Total',
275+
logsOutputTPS: 'Output TPS',
274276
logsColTokens: 'Tokens',
275277
logsTokensIn: 'in',
276278
logsTokensOut: 'out',
@@ -1188,6 +1190,8 @@ export const messages: Record<Lang, Dict> = {
11881190
logsColStatus: '状态',
11891191
logsColLatency: '延迟',
11901192
logsColTTFT: '首字',
1193+
logsLatencyTotal: '总耗时',
1194+
logsOutputTPS: '输出 TPS',
11911195
logsColTokens: 'Tokens',
11921196
logsTokensIn: '输入',
11931197
logsTokensOut: '输出',

‎frontend/src/lib/format.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,36 @@ export function formatCredits(value: number | null | undefined) {
7373
if (value >= 10_000) return formatCompact(value)
7474
return value.toLocaleString('zh-CN', { minimumFractionDigits: value < 100 ? 2 : 0, maximumFractionDigits: 2 })
7575
}
76+
77+
/** Output generation rate (tok/s). Prefer post-TTFT window on streams. */
78+
export function computeOutputTokensPerSecond(opts: {
79+
completionTokens?: number | null
80+
latencyMs?: number | null
81+
ttfbMs?: number | null
82+
stream?: boolean | null
83+
}): number | null {
84+
const completion = opts.completionTokens
85+
const latency = opts.latencyMs
86+
const ttfb = opts.ttfbMs
87+
if (completion == null || !Number.isFinite(completion) || completion <= 0) return null
88+
if (latency == null || !Number.isFinite(latency) || latency <= 0) return null
89+
90+
const preferGeneration =
91+
opts.stream === true &&
92+
ttfb != null &&
93+
Number.isFinite(ttfb) &&
94+
ttfb >= 0 &&
95+
latency - ttfb > 0
96+
97+
const seconds = preferGeneration ? (latency - (ttfb as number)) / 1000 : latency / 1000
98+
if (!(seconds > 0)) return null
99+
const rate = completion / seconds
100+
return Number.isFinite(rate) && rate > 0 ? rate : null
101+
}
102+
103+
/** e.g. `39.7 tok/s`, integer when large; missing → `—`. */
104+
export function formatTokensPerSecond(rate: number | null | undefined) {
105+
if (rate == null || !Number.isFinite(rate) || rate <= 0) return '—'
106+
const text = rate >= 100 ? String(Math.round(rate)) : Number(rate.toFixed(1)).toString()
107+
return `${text} tok/s`
108+
}

‎frontend/src/pages/AccessPage.tsx‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,30 +279,54 @@ export function AccessPage() {
279279
}
280280
const apiMessages = toApiMessages(history, apiUser)
281281

282+
const startedAt = performance.now()
283+
let firstTokenAt: number | null = null
284+
const markFirstToken = () => {
285+
if (firstTokenAt == null) firstTokenAt = performance.now()
286+
}
287+
const timingPatch = (): Pick<ThreadMessage, 'ttfb_ms' | 'latency_ms'> => {
288+
const endedAt = performance.now()
289+
return {
290+
latency_ms: Math.max(0, Math.round(endedAt - startedAt)),
291+
ttfb_ms: firstTokenAt != null ? Math.max(0, Math.round(firstTokenAt - startedAt)) : undefined,
292+
}
293+
}
294+
282295
try {
283296
await streamChat(selectedModel, apiMessages, selectedAccount || undefined, {
284297
signal: controller.signal,
285-
onContent: (_chunk, full) => patchAssistant(assistantId, { content: full }),
286-
onReasoning: (_chunk, full) => patchAssistant(assistantId, { reasoning: full }),
287-
onToolCalls: (calls) => patchAssistant(assistantId, { toolCalls: mapToolCalls(calls, true) }),
298+
onContent: (_chunk, full) => {
299+
if (_chunk) markFirstToken()
300+
patchAssistant(assistantId, { content: full })
301+
},
302+
onReasoning: (_chunk, full) => {
303+
if (_chunk) markFirstToken()
304+
patchAssistant(assistantId, { reasoning: full })
305+
},
306+
onToolCalls: (calls) => {
307+
markFirstToken()
308+
patchAssistant(assistantId, { toolCalls: mapToolCalls(calls, true) })
309+
},
288310
onUsage: (usage) => patchAssistant(assistantId, { usage }),
289311
})
290312
patchAssistant(assistantId, (prev) => ({
291313
status: 'done',
314+
...timingPatch(),
292315
toolCalls: prev.toolCalls?.map((call) => ({ ...call, status: 'success' as const })),
293316
}))
294317
setChatState('success')
295318
} catch (error) {
296319
if (controller.signal.aborted) {
297320
patchAssistant(assistantId, (prev) => ({
298321
status: 'done',
322+
...timingPatch(),
299323
toolCalls: prev.toolCalls?.map((call) => ({ ...call, status: 'success' as const })),
300324
}))
301325
setChatState('success')
302326
return
303327
}
304328
const message = error instanceof Error ? error.message : String(error)
305-
patchAssistant(assistantId, { status: 'error', error: message })
329+
patchAssistant(assistantId, { status: 'error', error: message, ...timingPatch() })
306330
setChatState('error')
307331
} finally {
308332
if (chatAbortRef.current === controller) chatAbortRef.current = null

‎frontend/src/pages/LogsPage.tsx‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { FilterToggle } from '@/components/ui/FilterToggle'
3636
import { ListPager, type PageSize } from '@/components/ui/ListPager'
3737
import { PageAlert } from '@/components/ui/PageAlert'
3838
import { LogsPageSkeleton, LogsRequestListSkeleton, LogsRuntimeListSkeleton, SkeletonBlock } from '@/components/ui/PageSkeletons'
39+
import { LatencyStats } from '@/components/ui/LatencyStats'
3940
import { SearchBar } from '@/components/ui/SearchBar'
4041
import { dateValueToISO, isoToDateValue, TimeRangePicker, type DateRangeValue } from '@/components/ui/TimeRangePicker'
4142
import { useI18n } from '@/hooks/useI18n'
@@ -149,12 +150,6 @@ function RequestDetailSkeleton() {
149150
)
150151
}
151152

152-
function formatLatency(ms?: number | null) {
153-
if (ms == null) return '—'
154-
if (ms < 1000) return `${ms}ms`
155-
return `${(ms / 1000).toFixed(1)}s`
156-
}
157-
158153
function formatCredit(value?: number | null) {
159154
if (value == null || !Number.isFinite(value)) return '—'
160155
return String(Math.round(value * 10000) / 10000)
@@ -729,7 +724,6 @@ export function LogsPage() {
729724
<Table.Column>{t('logsColStatus')}</Table.Column>
730725
<Table.Column>{t('logsColStream')}</Table.Column>
731726
<Table.Column>{t('logsColLatency')}</Table.Column>
732-
<Table.Column>{t('logsColTTFT')}</Table.Column>
733727
<Table.Column>{t('logsColTokens')}</Table.Column>
734728
</Table.Header>
735729
<Table.Body>
@@ -764,8 +758,19 @@ export function LogsPage() {
764758
{item.error_kind ? <div className="mono mt-1 text-caption text-muted">{item.error_kind}</div> : null}
765759
</Table.Cell>
766760
<Table.Cell><span className="text-xs text-muted">{item.stream ? t('logsStreamYes') : t('logsStreamNo')}</span></Table.Cell>
767-
<Table.Cell><span className="mono text-xs">{formatLatency(item.latency_ms)}</span></Table.Cell>
768-
<Table.Cell><span className="mono text-xs">{formatLatency(item.ttfb_ms)}</span></Table.Cell>
761+
<Table.Cell>
762+
<LatencyStats
763+
latencyMs={item.latency_ms}
764+
ttfbMs={item.ttfb_ms}
765+
completionTokens={item.completion_tokens}
766+
stream={item.stream}
767+
labels={{
768+
ttft: t('logsColTTFT'),
769+
total: t('logsLatencyTotal'),
770+
tps: t('logsOutputTPS'),
771+
}}
772+
/>
773+
</Table.Cell>
769774
<Table.Cell>
770775
<TokenSplit
771776
log={item}
@@ -975,8 +980,6 @@ export function LogsPage() {
975980
[t('logsColReasoning'), selected ? (reasoningLabel(selected) || '—') : '—'],
976981
[t('logsColProvider'), selected ? providerLabel(selected) : '—'],
977982
[t('logsColAccount'), selected?.account_id ? (accountNameById.get(selected.account_id) || selected.account_id) : '—'],
978-
[t('logsColLatency'), formatLatency(selected?.latency_ms)],
979-
[t('logsColTTFT'), formatLatency(selected?.ttfb_ms)],
980983
[t('logsColStream'), selected?.stream ? t('logsStreamYes') : t('logsStreamNo')],
981984
[t('logsRouting'), selected ? routingLabel(selected.routing) : '—'],
982985
].map(([label, value]) => (
@@ -998,6 +1001,20 @@ export function LogsPage() {
9981001
) : '—'}
9991002
</dd>
10001003
</div>
1004+
<div className="sm:col-span-2">
1005+
<LatencyStats
1006+
latencyMs={selected?.latency_ms}
1007+
ttfbMs={selected?.ttfb_ms}
1008+
completionTokens={selected?.completion_tokens}
1009+
stream={selected?.stream}
1010+
labels={{
1011+
heading: t('logsColLatency'),
1012+
ttft: t('logsColTTFT'),
1013+
total: t('logsLatencyTotal'),
1014+
tps: t('logsOutputTPS'),
1015+
}}
1016+
/>
1017+
</div>
10011018
</dl>
10021019
{selected && selected.message_count ? (
10031020
<div className="rounded-lg bg-surface-secondary px-3 py-3 text-xs">

internal/webui/static/assets/AreaChart-DUXUSapq.js renamed to internal/webui/static/assets/AreaChart-E_lFhMSB.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/webui/static/assets/CartesianChart-CreCyrwg.js renamed to internal/webui/static/assets/CartesianChart-CkMARPGy.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)