mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): i18n for ~43 hardcoded English strings across 15 files (#6516)
* 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:
parent
5b2d1369b5
commit
8296ce9e54
15 changed files with 259 additions and 79 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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) : '',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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。',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue