diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index 0db992f9..9133aeff 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -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 @@ -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} /> ) } @@ -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': @@ -547,6 +561,8 @@ interface ToolGroupProps { agent?: string modelLabel?: string completedAt?: number + created?: number + firstPartStart?: number } /** 用户需要阅读/交互的工具:沉浸模式下这些工具完成后保持展开 */ @@ -565,6 +581,8 @@ const ToolGroup = memo(function ToolGroup({ agent, modelLabel, completedAt, + created, + firstPartStart, }: ToolGroupProps) { const { t } = useTranslation('message') const { descriptiveToolSteps, inlineToolRequests, immersiveMode } = useTheme() @@ -741,6 +759,8 @@ const ToolGroup = memo(function ToolGroup({ agent={agent} modelLabel={modelLabel} completedAt={completedAt} + created={created} + firstPartStart={firstPartStart} /> )} diff --git a/src/features/message/parts/StepFinishPartView.test.tsx b/src/features/message/parts/StepFinishPartView.test.tsx new file mode 100644 index 00000000..827fac5b --- /dev/null +++ b/src/features/message/parts/StepFinishPartView.test.tsx @@ -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) => { + 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 { + 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( + , + ) + // 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( + , + ) + // ttft = 6000 - 5000 = 1000ms + expect(screen.getByText(/TTFT/)).toBeInTheDocument() + expect(screen.getByText(/1\.0s/)).toBeInTheDocument() + }) + + it('does not render TPS when no firstPartStart', () => { + render( + , + ) + expect(screen.queryByText(/tps/)).toBeNull() + }) + + it('does not render TTFT when no firstPartStart', () => { + render( + , + ) + 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( + , + ) + expect(screen.queryByText(/tps/)).toBeNull() + expect(screen.queryByText(/TTFT/)).toBeNull() + }) +}) diff --git a/src/features/message/parts/StepFinishPartView.tsx b/src/features/message/parts/StepFinishPartView.tsx index 642937ec..3f576833 100644 --- a/src/features/message/parts/StepFinishPartView.tsx +++ b/src/features/message/parts/StepFinishPartView.tsx @@ -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({ @@ -31,6 +35,8 @@ export const StepFinishPartView = memo(function StepFinishPartView({ agent, modelLabel, completedAt, + created, + firstPartStart, }: StepFinishPartViewProps) { const { t } = useTranslation('message') const { stepFinishDisplay: show, completedAtFormat } = useTheme() @@ -38,6 +44,12 @@ export const StepFinishPartView = memo(function StepFinishPartView({ 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) || @@ -45,6 +57,8 @@ export const StepFinishPartView = memo(function StepFinishPartView({ (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) @@ -70,6 +84,12 @@ export const StepFinishPartView = memo(function StepFinishPartView({ )} {show.cost && cost > 0 && {formatCost(cost)}} + {show.tps && tps != null && ( + {tps} tps + )} + {show.ttft && ttft != null && ( + TTFT {formatDuration(ttft)} + )} {show.duration && duration != null && duration > 0 && {formatDuration(duration)}} {show.turnDuration && turnDuration != null && turnDuration > 0 && ( {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} diff --git a/src/features/settings/components/ChatSettings.tsx b/src/features/settings/components/ChatSettings.tsx index c2baf288..0f595cf1 100644 --- a/src/features/settings/components/ChatSettings.tsx +++ b/src/features/settings/components/ChatSettings.tsx @@ -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') }, diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7b779046..bacb702e 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -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", @@ -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", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index f35af62b..f88e00cd 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -114,6 +114,8 @@ "showModel": "显示模型名称", "tokens": "Token", "showTokenUsage": "显示 Token 用量", + "tps": "TPS", + "showTps": "显示吞吐量(每秒生成 Token 数),基于 opencode API 时间戳计算,包含 LLM 网络消耗", "cache": "Cache", "showCacheHit": "显示 Cache 命中信息", "cost": "费用", @@ -124,6 +126,8 @@ "showTurnElapsed": "显示完整轮次耗时", "completedAt": "完成时刻", "showCompletedAt": "显示消息完成时间", + "ttft": "TTFT", + "showTtft": "显示首 Token 延迟(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", "completedAtFormat": "完成时刻格式", "completedAtFormatDesc": "选择只显示时间,或显示完整日期和时间", "completedAtTimeOnly": "仅时间", diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 483dbf71..61227296 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -72,6 +72,8 @@ export interface StepFinishDisplay { agent: boolean model: boolean completedAt: boolean + tps: boolean + ttft: boolean } export type CompletedAtFormat = 'time' | 'dateTime' @@ -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'