fix(web-shell): i18n for ~43 hardcoded English strings across 15 files (#6516)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* fix(web-shell): i18n for ~43 hardcoded English strings across 15 files

Multi-round audit replaced hardcoded UI strings with t() calls and
added en/zh-CN i18n keys for:

- Session timeline labels, aria-labels, and kind labels (MessageList)
- SubAgent tab labels, tools count, pending/running status
- Auth protocol option labels, placeholders, API key masking
- Voice dictation UI states (VoiceButton)
- Copy tooltip, Runtime badge, N/A, Server/Agent fallbacks
- Mermaid code block labels, image alt text, model switch summary

* fix(web-shell): i18n for missed models placeholder in AuthMessage

* fix(web-shell): invalidate timeline cache on locale switch

Store the t function reference in sessionTimelineCache alongside the
message signature. When the locale changes, t gets a new reference,
triggering cache invalidation and re-generating entries with the new
language labels.
This commit is contained in:
Shaojin Wen 2026-07-08 16:46:37 +08:00 committed by GitHub
parent 5b2d1369b5
commit 8296ce9e54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 259 additions and 79 deletions

View file

@ -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 | number>) => 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,
});

View file

@ -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}
</MessageTimestamp>

View file

@ -475,12 +475,15 @@ function isMidTurnInjectedDebugMessage(message: {
);
}
export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode {
export function getTurnTimelineNode(
item: DisplayItem,
t?: (key: string, vars?: Record<string, string | number>) => 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 | number>) => 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 | number>) => 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 | number>) => 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 | number>) => 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 | number>) => 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, string | number>) => 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 (
<div className={styles.sessionTimelineLayer} aria-hidden="false">
<nav
className={styles.sessionTimelinePanel}
aria-label="Session timeline"
aria-label={t('timeline.sessionTimeline')}
data-testid="session-timeline"
>
<ol className={styles.sessionTimelineList}>
@ -1675,8 +1707,8 @@ const SessionTimeline = memo(function SessionTimeline({
: entry.id === currentTurnId;
const nodeKinds = entry.nodeKinds.join(',');
const ariaLabel = [
`Turn ${index + 1}: ${entry.label}`,
isCurrent ? 'Current turn' : null,
`${t('timeline.turnPrefix', { index: index + 1 })}: ${entry.label}`,
isCurrent ? t('timeline.currentTurn') : null,
]
.filter(Boolean)
.join('. ');
@ -1823,6 +1855,7 @@ export const MessageList = memo(
useState(false);
const sessionTimelineCache = useRef<{
signature: string;
t: typeof t;
entries: SessionTimelineEntry[];
} | null>(null);
// Signature + entries are O(transcript text); only pay for them while the
@ -1830,14 +1863,18 @@ export const MessageList = memo(
const sessionTimelineEntries = useMemo(() => {
if (!isSessionTimelineVisible) return EMPTY_SESSION_TIMELINE_ENTRIES;
const signature = getSessionTimelineSignature(mergedMessages);
if (sessionTimelineCache.current?.signature !== signature) {
if (
sessionTimelineCache.current?.signature !== signature ||
sessionTimelineCache.current?.t !== t
) {
sessionTimelineCache.current = {
signature,
entries: getSessionTimelineEntries(mergedMessages),
t,
entries: getSessionTimelineEntries(mergedMessages, t),
};
}
return sessionTimelineCache.current.entries;
}, [isSessionTimelineVisible, mergedMessages]);
}, [isSessionTimelineVisible, mergedMessages, t]);
const sessionTimelineEntryIndexById = useMemo(
() =>
new Map(

View file

@ -220,7 +220,7 @@ function WorkspaceSectionRow({
<div className={styles.workspaceSummary}>
{summaryEntries.map(([key, value]) => (
<span key={key} className={styles.summaryChip}>
{key}: {value === null ? 'N/A' : String(value)}
{key}: {value === null ? t('common.na') : String(value)}
</span>
))}
</div>

View file

@ -209,7 +209,7 @@ export function ModelDialog({
) : null}
<span className={styles.label}>{getModelName(model)}</span>
{model.isRuntime ? (
<span className={styles.badge}>Runtime</span>
<span className={styles.badge}>{t('common.runtime')}</span>
) : null}
</div>
);

View file

@ -112,7 +112,7 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
)}
{!loading && error && (
<div className={dp('picker-empty')}>
{error.message || 'Failed to load sessions'}
{error.message || t('resume.failedToLoad')}
</div>
)}
{!loading && !error && filtered.length === 0 && (

View file

@ -32,23 +32,27 @@ interface Option<T extends string> {
description?: string;
}
const PROTOCOL_OPTIONS: Array<Option<string>> = [
{
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, string | number>) => string,
): Array<Option<string>> {
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 | number>) => 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) {
<input
className={styles.input}
value={models}
placeholder={defaultIds || 'model-id-1, model-id-2'}
placeholder={defaultIds || t('auth.modelsPlaceholder')}
onChange={(event) => {
setModels(event.target.value);
setError(null);
@ -707,7 +718,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) {
<input
className={styles.input}
value={contextWindow}
placeholder="auto"
placeholder={t('common.auto')}
onChange={(event) =>
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,
]);

View file

@ -230,7 +230,9 @@ function MermaidBlock({ code }: { code: string }) {
return (
<div className={styles.codeBlock}>
<div className={styles.codeBlockHeader}>
<span className={styles.codeBlockLang}>mermaid (error)</span>
<span className={styles.codeBlockLang}>
{t('mermaid.errorLabel')}
</span>
</div>
<pre className={`${styles.codeBlockContent} ${styles.codeBlockPlain}`}>
<code>{code}</code>
@ -242,7 +244,7 @@ function MermaidBlock({ code }: { code: string }) {
return (
<div className={styles.codeBlock}>
<div className={styles.codeBlockHeader}>
<span className={styles.codeBlockLang}>mermaid</span>
<span className={styles.codeBlockLang}>{t('mermaid.label')}</span>
<span className={styles.mermaidActions}>
<button
className={styles.codeBlockCopy}

View file

@ -565,7 +565,7 @@ export function McpStatusMessage({
? t('mcp.oauth.title')
: step === 'tools'
? t('mcp.toolsForServer', {
name: selectedServer?.name ?? 'Server',
name: selectedServer?.name ?? t('common.server'),
})
: (selectedTool?.name ?? t('mcp.toolDetail'));

View file

@ -710,7 +710,7 @@ function detailTitle(
): string {
switch (task.kind) {
case 'agent':
return `${task.subagentType ?? 'Agent'} ${task.label}`;
return `${task.subagentType ?? t('common.agent')} ${task.label}`;
case 'shell':
return `${t('tasks.kind.shell')} ${task.command}`;
case 'monitor':

View file

@ -1083,12 +1083,15 @@ export const ToolLine = memo(function ToolLine({
? `${t('agent.label')} (${info.explicitAgentType})`
: t('agent.label');
const isComplete = tool.status === 'completed' || tool.status === 'failed';
const progressLabel = tool.status === 'pending' ? 'pending' : 'running';
const progressLabel =
tool.status === 'pending' ? t('subagent.pending') : t('subagent.running');
const runningMeta = [progressLabel, info.elapsed]
.filter(Boolean)
.join(' · ');
const completeMeta = [
info.subToolCount > 0 ? `${info.subToolCount} tools` : '',
info.subToolCount > 0
? t('subagent.toolsCount', { count: info.subToolCount })
: '',
info.elapsed,
info.tokens,
info.reason ? truncateText(info.reason, 80) : '',

View file

@ -82,7 +82,7 @@ export const UserMessage = memo(function UserMessage({
<img
key={index}
src={src}
alt={`User uploaded image ${index + 1}`}
alt={t('user.uploadedImage', { index: index + 1 })}
className={styles.chatImageThumb}
onLoad={measureOverflow}
/>

View file

@ -270,6 +270,7 @@ export function SubAgentPanel({
hideHeader,
inline,
}: SubAgentPanelProps) {
const { t } = useI18n();
const isComplete = tool.status === 'completed' || tool.status === 'failed';
const displayStatus = getAgentDisplayStatus(tool);
const [expanded, setExpanded] = useState(defaultExpanded ?? false);
@ -313,7 +314,9 @@ export function SubAgentPanel({
<span className={styles.desc}>{truncateText(description, 50)}</span>
)}
{isComplete && subToolCount > 0 && (
<span className={styles.meta}>· {subToolCount} tools</span>
<span className={styles.meta}>
· {t('subagent.toolsCount', { count: subToolCount })}
</span>
)}
{elapsed && <span className={styles.meta}>· {elapsed}</span>}
{tokens && <span className={styles.meta}>· {tokens}</span>}
@ -331,13 +334,13 @@ export function SubAgentPanel({
className={`${styles.tab} ${activeTab === 'result' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('result')}
>
Result
{t('subagent.result')}
</button>
<button
className={`${styles.tab} ${activeTab === 'tools' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('tools')}
>
Tools ({subToolCount})
{t('subagent.tools', { count: subToolCount })}
</button>
</div>
)}

View file

@ -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。',

View file

@ -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<string | undefined>(
@ -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}