diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b00754ff55..9360815640 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -648,8 +648,14 @@ function getModelSwitchSummary(result: unknown): ModelSwitchSummary | null { }; } -function serializeModelSwitchSummary(summary: ModelSwitchSummary): string { - return `Using ${summary.isRuntime ? 'runtime ' : ''}model: ${summary.modelId}`; +function serializeModelSwitchSummary( + summary: ModelSwitchSummary, + t: (key: string, vars?: Record) => string, +): string { + return t('model.usingModel', { + isRuntime: summary.isRuntime ? 1 : 0, + modelId: summary.modelId, + }); } function isEditToolPermission(request: PermissionRequest): boolean { @@ -4017,7 +4023,7 @@ export function App({ if (summary) { store.dispatch({ type: 'debug', - text: serializeModelSwitchSummary(summary), + text: serializeModelSwitchSummary(summary, t), source: 'model_switch_summary', data: summary, }); diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 88f04321a8..28123fb77d 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -46,6 +46,7 @@ export const MessageItem = memo(function MessageItem({ showAssistantBranch = false, isLocateFlashing = false, }: MessageItemProps) { + const { t } = useI18n(); const body = ((): ReactElement | null => { switch (message.role) { case 'user': @@ -204,7 +205,7 @@ export const MessageItem = memo(function MessageItem({ timestamp={message.timestamp} chatMode={message.role === 'user'} copyText={message.role === 'user' ? message.content : undefined} - copyTitle="Copy" + copyTitle={t('common.copy')} > {selectableSafeBody} diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index f44faa3d5a..5955832446 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -475,12 +475,15 @@ function isMidTurnInjectedDebugMessage(message: { ); } -export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode { +export function getTurnTimelineNode( + item: DisplayItem, + t?: (key: string, vars?: Record) => string, +): TurnTimelineNode { if (item.type === 'parallel_agents') { return { kind: 'agents', timestamp: item.timestamp, - label: 'Parallel agents', + label: t ? t('timeline.parallelAgents') : 'Parallel agents', }; } if (item.type !== 'message') return { kind: 'none' }; @@ -491,7 +494,7 @@ export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode { return { kind: 'thought', timestamp: message.timestamp, - label: 'Thinking', + label: t ? t('timeline.thinking') : 'Thinking', }; case 'assistant': if (item.turnCollapse) @@ -501,28 +504,30 @@ export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode { return { kind: 'commentary', timestamp: message.timestamp, - label: 'Assistant update', + label: t ? t('timeline.assistantUpdate') : 'Assistant update', }; case 'tool_group': { const count = message.tools.length; return { kind: 'tool', timestamp: message.timestamp, - label: `${count} tool call${count === 1 ? '' : 's'}`, + label: t + ? t('timeline.toolCalls', { count }) + : `${count} tool call${count === 1 ? '' : 's'}`, }; } case 'plan': return { kind: 'plan', timestamp: message.timestamp, - label: 'Plan update', + label: t ? t('timeline.planUpdate') : 'Plan update', }; case 'system': return isMidTurnInjectedDebugMessage(message) ? { kind: 'status', timestamp: message.timestamp, - label: 'Status update', + label: t ? t('timeline.statusUpdate') : 'Status update', } : { kind: 'none', timestamp: message.timestamp }; case 'user': @@ -613,7 +618,10 @@ function stripBalancedTimelineMarker(raw: string, marker: string): string { return result; } -function timelineLabelForTurn(message: Message): string { +function timelineLabelForTurn( + message: Message, + t?: (key: string, vars?: Record) => string, +): string { const raw = message.role === 'user' ? message.content @@ -621,7 +629,7 @@ function timelineLabelForTurn(message: Message): string { ? message.command : ''; const compact = compactTimelineText(raw, 32); - if (!compact) return 'User turn'; + if (!compact) return t ? t('timeline.userTurn') : 'User turn'; return compact; } @@ -641,19 +649,24 @@ function isTurnStartMessage(message: Message): boolean { return message.role === 'user' || message.role === 'user_shell'; } -function timelineDetailSnippetForMessage(message: Message): string { +function timelineDetailSnippetForMessage( + message: Message, + t?: (key: string, vars?: Record) => string, +): string { switch (message.role) { case 'thinking': // Thinking content may include private model reasoning; keep details label-only. - return SESSION_TIMELINE_KIND_LABEL.thought; + return t ? t('timeline.kind.thought') : 'thinking'; case 'assistant': return compactTimelineText(message.content, 120, { stripMarkdown: true }); case 'tool_group': { const count = message.tools.length; - return `${count} tool call${count === 1 ? '' : 's'}`; + return t + ? t('timeline.toolCalls', { count }) + : `${count} tool call${count === 1 ? '' : 's'}`; } case 'plan': - return 'plan update'; + return t ? t('timeline.planDetail') : 'plan update'; case 'system': return isMidTurnInjectedDebugMessage(message) ? compactTimelineText(message.content, 120, { stripMarkdown: true }) @@ -673,19 +686,33 @@ function timelineDetailSnippetForMessage(message: Message): string { } } -function timelineDetailSnippetForItem(item: DisplayItem): string { +function timelineDetailSnippetForItem( + item: DisplayItem, + t?: (key: string, vars?: Record) => string, +): string { if (item.type === 'parallel_agents') { const count = item.agents.length; - return `${count} parallel agent${count === 1 ? '' : 's'}`; + return t + ? t('timeline.parallelAgentsDetail', { count }) + : `${count} parallel agent${count === 1 ? '' : 's'}`; } if (item.type !== 'message') return ''; - return timelineDetailSnippetForMessage(item.message); + return timelineDetailSnippetForMessage(item.message, t); +} + +function getKindLabel( + kind: TurnTimelineNodeKind, + t?: (key: string, vars?: Record) => string, +): string { + if (!t) return SESSION_TIMELINE_KIND_LABEL[kind]; + return t(`timeline.kind.${kind}`); } function timelineDetailForTurn( turnItems: readonly DisplayItem[], finalAssistantId: string | null, nodeKinds: readonly TurnTimelineNodeKind[], + t?: (key: string, vars?: Record) => string, ): string { if (finalAssistantId !== null) { for (const item of turnItems) { @@ -711,22 +738,21 @@ function timelineDetailForTurn( ) { continue; } - const snippet = timelineDetailSnippetForItem(item); + const snippet = timelineDetailSnippetForItem(item, t); if (snippet) snippets.push(snippet); } const detail = compactTimelineText(snippets.join(' · '), 180); if (detail) return detail; if (nodeKinds.length > 0) { - return nodeKinds - .map((kind) => SESSION_TIMELINE_KIND_LABEL[kind]) - .join(' · '); + return nodeKinds.map((kind) => getKindLabel(kind, t)).join(' · '); } - return 'No activity'; + return t ? t('timeline.noActivity') : 'No activity'; } export function getSessionTimelineEntries( messages: readonly Message[], + t?: (key: string, vars?: Record) => string, ): SessionTimelineEntry[] { const entries: SessionTimelineEntry[] = []; let turnStart: Message | null = null; @@ -760,7 +786,7 @@ export function getSessionTimelineEntries( ) { continue; } - const node = getTurnTimelineNode(item); + const node = getTurnTimelineNode(item, t); if (node.kind !== 'none' && !nodeKinds.includes(node.kind)) { nodeKinds.push(node.kind); } @@ -768,8 +794,13 @@ export function getSessionTimelineEntries( entries.push({ id: turnStart.id, - label: timelineLabelForTurn(turnStart), - detail: timelineDetailForTurn(timelineItems, finalAssistantId, nodeKinds), + label: timelineLabelForTurn(turnStart, t), + detail: timelineDetailForTurn( + timelineItems, + finalAssistantId, + nodeKinds, + t, + ), timestamp: turnStart.timestamp, nodeKinds, ...(isScheduledTaskMessage(turnStart) ? { isScheduledTask: true } : {}), @@ -1654,13 +1685,14 @@ const SessionTimeline = memo(function SessionTimeline({ hidden: boolean; onSelect: (turnId: string) => void; }) { + const { t } = useI18n(); if (hidden || entries.length === 0) return null; return (
); diff --git a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx index f7ab71dee1..9bd414c486 100644 --- a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx @@ -112,7 +112,7 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) { )} {!loading && error && (
- {error.message || 'Failed to load sessions'} + {error.message || t('resume.failedToLoad')}
)} {!loading && !error && filtered.length === 0 && ( diff --git a/packages/web-shell/client/components/messages/AuthMessage.tsx b/packages/web-shell/client/components/messages/AuthMessage.tsx index 4b9b1259aa..f05ad79dcd 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.tsx +++ b/packages/web-shell/client/components/messages/AuthMessage.tsx @@ -32,23 +32,27 @@ interface Option { description?: string; } -const PROTOCOL_OPTIONS: Array> = [ - { - value: 'openai', - label: 'OpenAI-compatible', - description: 'Standard OpenAI API format (most common)', - }, - { - value: 'anthropic', - label: 'Anthropic-compatible', - description: 'Anthropic Messages API format', - }, - { - value: 'gemini', - label: 'Gemini-compatible', - description: 'Google Gemini API format', - }, -]; +function getProtocolOptions( + t: (key: string, vars?: Record) => string, +): Array> { + return [ + { + value: 'openai', + label: t('auth.protocol.openai'), + description: t('auth.protocol.openaiDesc'), + }, + { + value: 'anthropic', + label: t('auth.protocol.anthropic'), + description: t('auth.protocol.anthropicDesc'), + }, + { + value: 'gemini', + label: t('auth.protocol.gemini'), + description: t('auth.protocol.geminiDesc'), + }, + ]; +} function defaultBaseUrl(protocol: string): string { if (protocol === 'anthropic') return 'https://api.anthropic.com/v1'; @@ -81,9 +85,12 @@ function titleForStep( return t('auth.step.advanced'); } -function maskApiKey(value: string): string { +function maskApiKey( + value: string, + t?: (key: string, vars?: Record) => string, +): string { const trimmed = value.trim(); - if (!trimmed) return '(not set)'; + if (!trimmed) return t ? t('auth.notSet') : '(not set)'; if (trimmed.length <= 6) return '***'; return `${trimmed.slice(0, 3)}...${trimmed.slice(-4)}`; } @@ -549,7 +556,9 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { if (currentStep === 'protocol') { const allowed = provider.protocolOptions ?? [provider.protocol]; return renderOptions( - PROTOCOL_OPTIONS.filter((option) => allowed.includes(option.value)), + getProtocolOptions(t).filter((option) => + allowed.includes(option.value), + ), optionIndex, setOptionIndex, ); @@ -616,7 +625,9 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { className={styles.input} type="password" value={apiKey} - placeholder={provider.apiKeyPlaceholder ?? 'sk-...'} + placeholder={ + provider.apiKeyPlaceholder ?? t('auth.apiKeyPlaceholder') + } onChange={(event) => { setApiKey(event.target.value); setError(null); @@ -645,7 +656,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { { setModels(event.target.value); setError(null); @@ -707,7 +718,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { setContextWindow(event.target.value.replace(/[^0-9]/g, '')) } @@ -747,7 +758,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { const hasGenerationConfig = Object.keys(generationConfig).length > 0; return JSON.stringify( { - env: { [envKey]: maskApiKey(apiKey) }, + env: { [envKey]: maskApiKey(apiKey, t) }, modelProviders: { [protocol]: normalizedIds.map((id) => ({ id, @@ -775,6 +786,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { models, protocol, provider, + t, thinking, ]); diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index daeb9a68d4..a6bbebd20e 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -230,7 +230,9 @@ function MermaidBlock({ code }: { code: string }) { return (
- mermaid (error) + + {t('mermaid.errorLabel')} +
           {code}
@@ -242,7 +244,7 @@ function MermaidBlock({ code }: { code: string }) {
   return (
     
- mermaid + {t('mermaid.label')}
)} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index df83537074..d0a6efc4ef 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -161,7 +161,59 @@ const EN: Messages = { 'agent.view': 'View', 'agents.closed': 'Agents panel closed.', 'agents.title': 'Agents', + 'subagent.result': 'Result', + 'subagent.tools': (v) => `Tools (${v?.count ?? 0})`, + 'subagent.toolsCount': (v) => `${v?.count ?? 0} tools`, 'subagent.toggleStream': 'Toggle agent stream details', + 'subagent.pending': 'pending', + 'subagent.running': 'running', + 'timeline.parallelAgents': 'Parallel agents', + 'timeline.thinking': 'Thinking', + 'timeline.assistantUpdate': 'Assistant update', + 'timeline.toolCalls': (v) => + `${v?.count ?? 0} tool call${v?.count === 1 ? '' : 's'}`, + 'timeline.planUpdate': 'Plan update', + 'timeline.statusUpdate': 'Status update', + 'timeline.userTurn': 'User turn', + 'timeline.planDetail': 'plan update', + 'timeline.parallelAgentsDetail': (v) => + `${v?.count ?? 0} parallel agent${v?.count === 1 ? '' : 's'}`, + 'timeline.noActivity': 'No activity', + 'timeline.sessionTimeline': 'Session timeline', + 'timeline.turnPrefix': (v) => `Turn ${v?.index ?? 0}`, + 'timeline.currentTurn': 'Current turn', + 'timeline.kind.thought': 'thinking', + 'timeline.kind.commentary': 'assistant update', + 'timeline.kind.tool': 'tool calls', + 'timeline.kind.agents': 'parallel agents', + 'timeline.kind.plan': 'plan update', + 'timeline.kind.status': 'status update', + 'timeline.kind.none': 'turn', + 'common.copy': 'Copy', + 'common.na': 'N/A', + 'common.server': 'Server', + 'common.agent': 'Agent', + 'common.auto': 'auto', + 'common.runtime': 'Runtime', + 'common.failedToLoad': 'Failed to load', + 'auth.notSet': '(not set)', + 'auth.apiKeyPlaceholder': 'sk-...', + 'auth.modelsPlaceholder': 'model-id-1, model-id-2', + 'model.usingModel': (v) => + `Using ${v?.isRuntime ? 'runtime ' : ''}model: ${v?.modelId ?? ''}`, + 'mermaid.label': 'mermaid', + 'mermaid.errorLabel': 'mermaid (error)', + 'user.uploadedImage': (v) => `User uploaded image ${v?.index ?? 1}`, + 'voice.noSpeech': 'No speech detected.', + 'voice.stopDictation': 'Stop dictation', + 'voice.transcribing': 'Transcribing…', + 'voice.starting': 'Starting…', + 'voice.errorRetry': (v) => + `Voice error — click to retry${v?.message ? `: ${v.message}` : ''}`, + 'voice.noSpeechRetry': 'No speech detected — click to retry', + 'voice.startDictation': 'Start voice dictation', + 'voice.error': 'Voice error', + 'resume.failedToLoad': 'Failed to load sessions', 'toast.dismiss': 'Dismiss notification', 'toast.dismissShort': 'Dismiss', 'insight.ready': 'Insight report generated successfully!', @@ -918,6 +970,12 @@ const EN: Messages = { 'auth.step.apiKey': 'API Key', 'auth.step.models': 'Model IDs', 'auth.step.advanced': 'Advanced Config', + 'auth.protocol.openai': 'OpenAI-compatible', + 'auth.protocol.openaiDesc': 'Standard OpenAI API format (most common)', + 'auth.protocol.anthropic': 'Anthropic-compatible', + 'auth.protocol.anthropicDesc': 'Anthropic Messages API format', + 'auth.protocol.gemini': 'Gemini-compatible', + 'auth.protocol.geminiDesc': 'Google Gemini API format', 'auth.apiKeyRequired': 'API key cannot be empty.', 'auth.baseUrlInvalid': 'Base URL must start with http:// or https://.', 'auth.baseUrlPrompt': 'Enter the API endpoint for this protocol.', @@ -1822,7 +1880,57 @@ const ZH: Messages = { 'agent.view': '查看', 'agents.closed': '智能体面板已关闭。', 'agents.title': '智能体', + 'subagent.result': '结果', + 'subagent.tools': (v) => `工具 (${v?.count ?? 0})`, + 'subagent.toolsCount': (v) => `${v?.count ?? 0} 个工具`, 'subagent.toggleStream': '展开/收起子智能体详情', + 'subagent.pending': '等待中', + 'subagent.running': '运行中', + 'timeline.parallelAgents': '并行智能体', + 'timeline.thinking': '思考', + 'timeline.assistantUpdate': '助手更新', + 'timeline.toolCalls': (v) => `${v?.count ?? 0} 个工具调用`, + 'timeline.planUpdate': '计划更新', + 'timeline.statusUpdate': '状态更新', + 'timeline.userTurn': '用户轮次', + 'timeline.planDetail': '计划更新', + 'timeline.parallelAgentsDetail': (v) => `${v?.count ?? 0} 个并行智能体`, + 'timeline.noActivity': '无活动', + 'timeline.sessionTimeline': '会话时间线', + 'timeline.turnPrefix': (v) => `第 ${v?.index ?? 0} 轮`, + 'timeline.currentTurn': '当前轮次', + 'timeline.kind.thought': '思考', + 'timeline.kind.commentary': '助手更新', + 'timeline.kind.tool': '工具调用', + 'timeline.kind.agents': '并行智能体', + 'timeline.kind.plan': '计划更新', + 'timeline.kind.status': '状态更新', + 'timeline.kind.none': '轮次', + 'common.copy': '复制', + 'common.na': '不适用', + 'common.server': '服务器', + 'common.agent': '智能体', + 'common.auto': '自动', + 'common.runtime': '运行时', + 'common.failedToLoad': '加载失败', + 'auth.notSet': '(未设置)', + 'auth.apiKeyPlaceholder': 'sk-...', + 'auth.modelsPlaceholder': '模型ID-1, 模型ID-2', + 'model.usingModel': (v) => + `正在使用${v?.isRuntime ? '运行时' : ''}模型:${v?.modelId ?? ''}`, + 'mermaid.label': 'mermaid', + 'mermaid.errorLabel': 'mermaid(错误)', + 'user.uploadedImage': (v) => `用户上传的图片 ${v?.index ?? 1}`, + 'voice.noSpeech': '未检测到语音。', + 'voice.stopDictation': '停止语音输入', + 'voice.transcribing': '正在转录…', + 'voice.starting': '正在启动…', + 'voice.errorRetry': (v) => + `语音错误 — 点击重试${v?.message ? `:${v.message}` : ''}`, + 'voice.noSpeechRetry': '未检测到语音 — 点击重试', + 'voice.startDictation': '开始语音输入', + 'voice.error': '语音错误', + 'resume.failedToLoad': '加载会话失败', 'toast.dismiss': '关闭通知', 'toast.dismissShort': '关闭', 'insight.ready': 'Insight 报告已生成!', @@ -2531,6 +2639,12 @@ const ZH: Messages = { 'auth.step.apiKey': 'API Key', 'auth.step.models': '模型 ID', 'auth.step.advanced': '高级配置', + 'auth.protocol.openai': 'OpenAI 兼容', + 'auth.protocol.openaiDesc': '标准 OpenAI API 格式(最常用)', + 'auth.protocol.anthropic': 'Anthropic 兼容', + 'auth.protocol.anthropicDesc': 'Anthropic Messages API 格式', + 'auth.protocol.gemini': 'Gemini 兼容', + 'auth.protocol.geminiDesc': 'Google Gemini API 格式', 'auth.apiKeyRequired': 'API key 不能为空。', 'auth.baseUrlInvalid': 'Base URL 必须以 http:// 或 https:// 开头。', 'auth.baseUrlPrompt': '输入此协议的 API endpoint。', diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index cf47000dcf..affe9332e7 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react'; import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../i18n'; import { useVoiceCapture } from './useVoiceCapture'; import styles from './VoiceButton.module.css'; @@ -57,6 +58,7 @@ export function VoiceButton({ disabled, }: VoiceButtonProps): React.JSX.Element | null { const workspace = useWorkspace(); + const { t } = useI18n(); const features = workspace.capabilities?.features ?? []; // Surfaced when a recording finalizes with no transcript (e.g. silence). const [noticeMessage, setNoticeMessage] = useState( @@ -73,7 +75,7 @@ export function VoiceButton({ setNoticeMessage(undefined); onInsert(trimmed); } else { - setNoticeMessage('No speech detected.'); + setNoticeMessage(t('voice.noSpeech')); } }, }); @@ -120,16 +122,16 @@ export function VoiceButton({ const canCancel = isRecording || isConnecting; const label = isRecording - ? 'Stop dictation' + ? t('voice.stopDictation') : isTranscribing - ? 'Transcribing…' + ? t('voice.transcribing') : isConnecting - ? 'Starting…' + ? t('voice.starting') : isError - ? `Voice error — click to retry${errorMessage ? `: ${errorMessage}` : ''}` + ? t('voice.errorRetry', { message: errorMessage ?? '' }) : isNotice - ? 'No speech detected — click to retry' - : 'Start voice dictation'; + ? t('voice.noSpeechRetry') + : t('voice.startDictation'); let control: React.JSX.Element; if (isRecording) { @@ -213,7 +215,7 @@ export function VoiceButton({ className={`${styles.interim}${isError ? ` ${styles.error}` : ''}`} > {isError - ? errorMessage || 'Voice error' + ? errorMessage || t('voice.error') : isNotice ? noticeMessage : interimText}