Skip to content
20 changes: 20 additions & 0 deletions src/features/message/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,16 @@ const AssistantMessageView = memo(function AssistantMessageView({
const { created, completed } = info.time
const duration = completed != null ? completed - created : undefined

// 最早 part.time.start,用于 TTFT/TPS 计算
const firstPartStart = useMemo(() => {
for (const p of parts) {
if ((p.type === 'text' || p.type === 'reasoning') && p.time?.start) {
return p.time.start
}
}
return undefined
}, [parts])

// agent / model(仅 assistant 消息)
const assistantInfo = info.role === 'assistant' ? (info as AssistantMessageInfo) : null
const agent = assistantInfo?.agent || undefined
Expand Down Expand Up @@ -473,6 +483,8 @@ const AssistantMessageView = memo(function AssistantMessageView({
agent={agent}
modelLabel={modelLabel}
completedAt={isLastStepFinish ? completed : undefined}
created={isLastStepFinish ? created : undefined}
firstPartStart={isLastStepFinish ? firstPartStart : undefined}
/>
)
}
Expand All @@ -495,6 +507,8 @@ const AssistantMessageView = memo(function AssistantMessageView({
agent={agent}
modelLabel={modelLabel}
completedAt={isLastStepFinish ? completed : undefined}
created={isLastStepFinish ? created : undefined}
firstPartStart={isLastStepFinish ? firstPartStart : undefined}
/>
)
case 'subtask':
Expand Down Expand Up @@ -547,6 +561,8 @@ interface ToolGroupProps {
agent?: string
modelLabel?: string
completedAt?: number
created?: number
firstPartStart?: number
}

/** 用户需要阅读/交互的工具:沉浸模式下这些工具完成后保持展开 */
Expand All @@ -565,6 +581,8 @@ const ToolGroup = memo(function ToolGroup({
agent,
modelLabel,
completedAt,
created,
firstPartStart,
}: ToolGroupProps) {
const { t } = useTranslation('message')
const { descriptiveToolSteps, inlineToolRequests, immersiveMode } = useTheme()
Expand Down Expand Up @@ -741,6 +759,8 @@ const ToolGroup = memo(function ToolGroup({
agent={agent}
modelLabel={modelLabel}
completedAt={completedAt}
created={created}
firstPartStart={firstPartStart}
/>
</div>
)}
Expand Down
136 changes: 136 additions & 0 deletions src/features/message/parts/StepFinishPartView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { StepFinishPartView } from './StepFinishPartView'
import type { StepFinishPart } from '../../../types/message'

let mockStepFinishDisplay = {
tokens: true,
tps: true,
cache: true,
cost: true,
duration: true,
turnDuration: true,
agent: true,
model: true,
completedAt: true,
ttft: true,
}

vi.mock('../../../hooks/useTheme', () => ({
useTheme: () => ({
stepFinishDisplay: mockStepFinishDisplay,
completedAtFormat: 'time' as const,
}),
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (key === 'stepFinish.inputTokens') return `Input: ${opts?.input}`
if (key === 'stepFinish.outputTokens') return `Output: ${opts?.output}`
if (key === 'stepFinish.reasoningTokens') return `Reasoning: ${opts?.reasoning}`
if (key === 'stepFinish.cacheRead') return `Cache read: ${opts?.read}`
if (key === 'stepFinish.cacheWrite') return `Cache write: ${opts?.write}`
if (key === 'stepFinish.cached') return `${opts?.count} cached`
if (key === 'stepFinish.totalDuration') return `${opts?.duration} total`
if (key === 'tokens') return 'tokens'
return key
},
}),
}))

function createStepFinishPart(overrides?: Partial<StepFinishPart>): StepFinishPart {
return {
id: 'step-finish-1',
sessionID: 'session-1',
messageID: 'msg-1',
type: 'step-finish',
reason: 'stop',
cost: 0,
tokens: {
input: 100,
output: 200,
reasoning: 50,
cache: { read: 0, write: 0 },
},
...overrides,
}
}

describe('StepFinishPartView', () => {
it('renders TPS when data is available', () => {
render(
<StepFinishPartView
part={createStepFinishPart()}
completedAt={20000}
created={5000}
firstPartStart={6000}
/>,
)
// created=5000, completedAt=20000, firstPartStart=6000
// genTime = 20000 - 6000 = 14000ms = 14s
// genTokens = 200 + 50 = 250
// tps = Math.round(250 / 14) = Math.round(17.857) = 18
expect(screen.getByText('18 tps')).toBeInTheDocument()
})

it('renders TTFT when data is available', () => {
render(
<StepFinishPartView
part={createStepFinishPart()}
completedAt={20000}
created={5000}
firstPartStart={6000}
/>,
)
// ttft = 6000 - 5000 = 1000ms
expect(screen.getByText(/TTFT/)).toBeInTheDocument()
expect(screen.getByText(/1\.0s/)).toBeInTheDocument()
})

it('does not render TPS when no firstPartStart', () => {
render(
<StepFinishPartView
part={createStepFinishPart()}
completedAt={20000}
/>,
)
expect(screen.queryByText(/tps/)).toBeNull()
})

it('does not render TTFT when no firstPartStart', () => {
render(
<StepFinishPartView
part={createStepFinishPart()}
completedAt={20000}
/>,
)
expect(screen.queryByText(/TTFT/)).toBeNull()
})

it('does not render TTFT/TPS when toggled off in settings', () => {
mockStepFinishDisplay = {
tokens: true,
tps: false,
cache: true,
cost: true,
duration: true,
turnDuration: true,
agent: true,
model: true,
completedAt: false,
ttft: false,
}

render(
<StepFinishPartView
part={createStepFinishPart()}
completedAt={20000}
created={5000}
firstPartStart={6000}
/>,
)
expect(screen.queryByText(/tps/)).toBeNull()
expect(screen.queryByText(/TTFT/)).toBeNull()
})
})
20 changes: 20 additions & 0 deletions src/features/message/parts/StepFinishPartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface StepFinishPartViewProps {
modelLabel?: string
/** 消息完成时间戳(毫秒),用于显示完成时刻 */
completedAt?: number
/** 消息创建时间戳(毫秒),来自 AssistantMessage.time.created */
created?: number
/** 消息中最早 part.time.start(毫秒时间戳),用于计算 TTFT/TPS */
firstPartStart?: number
}

export const StepFinishPartView = memo(function StepFinishPartView({
Expand All @@ -31,20 +35,30 @@ export const StepFinishPartView = memo(function StepFinishPartView({
agent,
modelLabel,
completedAt,
created,
firstPartStart,
}: StepFinishPartViewProps) {
const { t } = useTranslation('message')
const { stepFinishDisplay: show, completedAtFormat } = useTheme()
const { tokens, cost } = part
const totalTokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
const cacheHit = tokens.cache.read

// TTFT & TPS
const ttft = created != null && firstPartStart != null ? firstPartStart - created : undefined
const genTime = completedAt != null && firstPartStart != null ? completedAt - firstPartStart : undefined
const genTokens = tokens.output + tokens.reasoning
const tps = genTime != null && genTime > 0 && genTokens > 0 ? Math.round(genTokens / (genTime / 1000)) : undefined

// 所有项都关闭时不渲染
const hasAny =
(show.agent && !!agent) ||
(show.model && !!modelLabel) ||
(show.tokens && totalTokens > 0) ||
(show.cache && cacheHit > 0) ||
(show.cost && cost > 0) ||
(show.tps && tps != null) ||
(show.ttft && ttft != null) ||
(show.duration && duration != null && duration > 0) ||
(show.turnDuration && turnDuration != null && turnDuration > 0) ||
(show.completedAt && completedAt != null)
Expand All @@ -70,6 +84,12 @@ export const StepFinishPartView = memo(function StepFinishPartView({
</span>
)}
{show.cost && cost > 0 && <span>{formatCost(cost)}</span>}
{show.tps && tps != null && (
<span>{tps} tps</span>
)}
{show.ttft && ttft != null && (
<span>TTFT {formatDuration(ttft)}</span>
)}
{show.duration && duration != null && duration > 0 && <span>{formatDuration(duration)}</span>}
{show.turnDuration && turnDuration != null && turnDuration > 0 && (
<span>{t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })}</span>
Expand Down
2 changes: 2 additions & 0 deletions src/features/settings/components/ChatSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export function ChatSettings() {
{ key: 'tokens', label: t('chat.tokens'), desc: t('chat.showTokenUsage') },
{ key: 'cache', label: t('chat.cache'), desc: t('chat.showCacheHit') },
{ key: 'cost', label: t('chat.cost'), desc: t('chat.showApiCost') },
{ key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') },
{ key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') },
{ key: 'duration', label: t('chat.duration'), desc: t('chat.showResponseTime') },
{ key: 'turnDuration', label: t('chat.totalDuration'), desc: t('chat.showTurnElapsed') },
{ key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') },
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
"showModel": "Show model name",
"tokens": "Tokens",
"showTokenUsage": "Show token usage",
"tps": "TPS",
"showTps": "Show throughput (tokens per second), computed from opencode API timestamps, includes LLM network overhead",
"cache": "Cache",
"showCacheHit": "Show cache hit info",
"cost": "Cost",
Expand All @@ -124,6 +126,8 @@
"showTurnElapsed": "Show full turn elapsed time",
"completedAt": "Completed At",
"showCompletedAt": "Show message completion time",
"ttft": "TTFT",
"showTtft": "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)",
"completedAtFormat": "Completion Time Format",
"completedAtFormatDesc": "Choose whether to show only time or full date and time",
"completedAtTimeOnly": "Time Only",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
"showModel": "显示模型名称",
"tokens": "Token",
"showTokenUsage": "显示 Token 用量",
"tps": "TPS",
"showTps": "显示吞吐量(每秒生成 Token 数),基于 opencode API 时间戳计算,包含 LLM 网络消耗",
"cache": "Cache",
"showCacheHit": "显示 Cache 命中信息",
"cost": "费用",
Expand All @@ -124,6 +126,8 @@
"showTurnElapsed": "显示完整轮次耗时",
"completedAt": "完成时刻",
"showCompletedAt": "显示消息完成时间",
"ttft": "TTFT",
"showTtft": "显示首 Token 延迟(基于 opencode API 时间戳计算,包含 LLM 网络消耗)",
"completedAtFormat": "完成时刻格式",
"completedAtFormatDesc": "选择只显示时间,或显示完整日期和时间",
"completedAtTimeOnly": "仅时间",
Expand Down
4 changes: 4 additions & 0 deletions src/store/themeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface StepFinishDisplay {
agent: boolean
model: boolean
completedAt: boolean
tps: boolean
ttft: boolean
}

export type CompletedAtFormat = 'time' | 'dateTime'
Expand Down Expand Up @@ -104,6 +106,8 @@ const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = {
agent: false,
model: false,
completedAt: false,
tps: false,
ttft: false,
}

const DEFAULT_COMPLETED_AT_FORMAT: CompletedAtFormat = 'time'
Expand Down