From fbd423b94d50e14de83196be040f63a591f570eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Fri, 5 Jun 2026 22:43:16 -0500 Subject: [PATCH] feat(desktop): localize desktop chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com> --- .../chat/composer/hooks/use-mic-recorder.ts | 34 +++++++---- .../composer/hooks/use-voice-conversation.ts | 25 ++++---- .../chat/composer/hooks/use-voice-recorder.ts | 15 +++-- .../chat/composer/trigger-popover.test.tsx | 42 +++++++++++++ .../src/app/chat/composer/trigger-popover.tsx | 10 +++- .../components/assistant-ui/tool-fallback.tsx | 35 ++++++++--- .../src/components/chat/zoomable-image.tsx | 17 ++++-- .../components/desktop-onboarding-overlay.tsx | 3 +- .../src/components/ui/copy-button.test.tsx | 36 +++++++++++ .../desktop/src/components/ui/copy-button.tsx | 19 +++--- apps/desktop/src/i18n/en.ts | 34 ++++++++++- apps/desktop/src/i18n/runtime.test.ts | 5 +- apps/desktop/src/i18n/types.ts | 32 ++++++++++ apps/desktop/src/i18n/zh.ts | 60 ++++++++++++++----- 14 files changed, 297 insertions(+), 70 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/trigger-popover.test.tsx create mode 100644 apps/desktop/src/components/ui/copy-button.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts index df74c7f4a5c..8823084a36e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts @@ -17,39 +17,49 @@ export interface MicRecording { heardSpeech: boolean } +export interface MicRecorderErrorCopy { + microphoneAccessDenied: string + microphoneConstraintsUnsupported: string + microphoneInUse: string + microphonePermissionDenied: string + microphoneStartFailed: string + microphoneUnsupported: string + noMicrophone: string +} + interface MicRecorderHandle { start: (options?: MicRecorderOptions) => Promise stop: () => Promise cancel: () => void } -function micError(error: unknown): Error { +function micError(error: unknown, copy: MicRecorderErrorCopy): Error { const name = error instanceof DOMException ? error.name : '' if (name === 'NotAllowedError' || name === 'SecurityError') { - return new Error('Microphone permission was denied.') + return new Error(copy.microphonePermissionDenied) } if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { - return new Error('No microphone was found.') + return new Error(copy.noMicrophone) } if (name === 'NotReadableError' || name === 'TrackStartError') { - return new Error('Microphone is already in use by another app.') + return new Error(copy.microphoneInUse) } if (name === 'OverconstrainedError') { - return new Error('Microphone constraints are not supported by this device.') + return new Error(copy.microphoneConstraintsUnsupported) } if (error instanceof Error) { return error } - return new Error('Could not start microphone recording.') + return new Error(copy.microphoneStartFailed) } -export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } { +export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } { const [level, setLevel] = useState(0) const [recording, setRecording] = useState(false) @@ -158,13 +168,13 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re } if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { - throw new Error('This runtime does not support microphone recording.') + throw new Error(copy.microphoneUnsupported) } const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.() if (permitted === false) { - throw new Error('Microphone access denied.') + throw new Error(copy.microphoneAccessDenied) } let stream: MediaStream @@ -174,7 +184,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re audio: { echoCancellation: true, noiseSuppression: true } }) } catch (error) { - throw micError(error) + throw micError(error, copy) } const mimeType = @@ -188,7 +198,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) } catch (error) { stream.getTracks().forEach(track => track.stop()) - throw micError(error) + throw micError(error, copy) } chunksRef.current = [] @@ -231,7 +241,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re } recorder.onerror = event => { - const error = micError((event as Event & { error?: unknown }).error) + const error = micError((event as Event & { error?: unknown }).error, copy) const resolver = stopResolverRef.current stopResolverRef.current = null cleanup() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 3261acc3409..e4e8f3201be 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useI18n } from '@/i18n' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notify, notifyError } from '@/store/notifications' @@ -32,7 +33,9 @@ export function useVoiceConversation({ pendingResponse, consumePendingResponse }: VoiceConversationOptions) { - const { handle, level } = useMicRecorder() + const { t } = useI18n() + const voiceCopy = t.notifications.voice + const { handle, level } = useMicRecorder(voiceCopy) const [status, setStatus] = useState('idle') const [muted, setMuted] = useState(false) const turnTimeoutRef = useRef(null) @@ -168,7 +171,7 @@ export function useVoiceConversation({ await onSubmit(transcript) setStatus('thinking') } catch (error) { - notifyError(error, 'Voice transcription failed') + notifyError(error, voiceCopy.transcriptionFailed) if (enabledRef.current && !mutedRef.current && !busyRef.current) { pendingStartRef.current = true @@ -180,7 +183,7 @@ export function useVoiceConversation({ turnClosingRef.current = false } }, - [handle, onSubmit, onTranscribeAudio] + [handle, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed] ) const startListening = useCallback(async () => { @@ -201,7 +204,7 @@ export function useVoiceConversation({ silenceMs: 1_250, idleSilenceMs: 12_000, onError: error => { - notifyError(error, 'Microphone failed') + notifyError(error, voiceCopy.microphoneFailed) pendingStartRef.current = false onFatalError?.() }, @@ -210,12 +213,12 @@ export function useVoiceConversation({ setStatus('listening') turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000) } catch (error) { - notifyError(error, 'Could not start voice session') + notifyError(error, voiceCopy.couldNotStartSession) pendingStartRef.current = false setStatus('idle') onFatalError?.() } - }, [handle, handleTurn, onFatalError]) + }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) const speak = useCallback(async (text: string) => { setStatus('speaking') @@ -223,7 +226,7 @@ export function useVoiceConversation({ try { await playSpeechText(text, { source: 'voice-conversation' }) } catch (error) { - notifyError(error, 'Voice playback failed') + notifyError(error, voiceCopy.playbackFailed) } finally { if (enabledRef.current) { pendingStartRef.current = true @@ -232,14 +235,14 @@ export function useVoiceConversation({ setStatus('idle') } } - }, []) + }, [voiceCopy.playbackFailed]) const start = useCallback(async () => { if (!onTranscribeAudio) { notify({ kind: 'warning', - title: 'Voice unavailable', - message: 'Configure speech-to-text to use voice mode.' + title: voiceCopy.unavailable, + message: voiceCopy.configureSpeechToText }) onFatalError?.() @@ -252,7 +255,7 @@ export function useVoiceConversation({ consumePendingResponse() pendingStartRef.current = true await startListening() - }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening]) + }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable]) const end = useCallback(async () => { pendingStartRef.current = false diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts index cffc2820ca7..937f2d3bc03 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import { useI18n } from '@/i18n' import { notify, notifyError } from '@/store/notifications' import type { VoiceActivityState, VoiceStatus } from '../types' @@ -19,7 +20,9 @@ export function useVoiceRecorder({ focusInput, onTranscript }: VoiceRecorderOptions) { - const { handle, level, recording } = useMicRecorder() + const { t } = useI18n() + const voiceCopy = t.notifications.voice + const { handle, level, recording } = useMicRecorder(voiceCopy) const [voiceStatus, setVoiceStatus] = useState('idle') const [elapsedSeconds, setElapsedSeconds] = useState(0) const startedAtRef = useRef(0) @@ -62,12 +65,12 @@ export function useVoiceRecorder({ const transcript = (await onTranscribeAudio(result.audio)).trim() if (!transcript) { - notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' }) + notify({ kind: 'warning', title: voiceCopy.noSpeechDetected, message: voiceCopy.tryRecordingAgain }) } else { onTranscript(transcript) } } catch (error) { - notifyError(error, 'Voice transcription failed') + notifyError(error, voiceCopy.transcriptionFailed) } finally { setVoiceStatus('idle') focusInput() @@ -76,13 +79,13 @@ export function useVoiceRecorder({ const start = async () => { if (!onTranscribeAudio) { - notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' }) + notify({ kind: 'warning', title: voiceCopy.unavailable, message: voiceCopy.transcriptionUnavailable }) return } try { - await handle.start({ onError: error => notifyError(error, 'Voice recording failed') }) + await handle.start({ onError: error => notifyError(error, voiceCopy.recordingFailed) }) startedAtRef.current = Date.now() setElapsedSeconds(0) setVoiceStatus('recording') @@ -91,7 +94,7 @@ export function useVoiceRecorder({ timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000) } catch (error) { setVoiceStatus('idle') - notifyError(error, 'Voice recording failed') + notifyError(error, voiceCopy.recordingFailed) } } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx new file mode 100644 index 00000000000..9acc43f7f19 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { ComposerTriggerPopover } from './trigger-popover' + +function renderPopover(kind: '@' | '/', loading = false) { + const onHover = vi.fn() + const onPick = vi.fn() + + const rendered = render( + + + + ) + + return { ...rendered, onHover, onPick } +} + +describe('ComposerTriggerPopover i18n', () => { + afterEach(() => { + cleanup() + }) + + it('renders localized empty lookup copy for @ references', () => { + const { container } = renderPopover('@') + + expect(screen.getByText('没有匹配项。')).toBeTruthy() + expect(container.textContent).toContain('试试') + expect(container.textContent).toContain('@file:') + expect(container.textContent).toContain('或') + expect(container.textContent).toContain('@folder:') + }) + + it('renders localized loading copy for slash commands', () => { + const { container } = renderPopover('/', true) + + expect(screen.getByText('查找中…')).toBeTruthy() + expect(container.textContent).toContain('/help') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 7cc6a3b2237..a09190dd6b3 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,6 +1,7 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { @@ -60,6 +61,9 @@ export function ComposerTriggerPopover({ onPick, placement = 'top' }: ComposerTriggerPopoverProps) { + const { t } = useI18n() + const copy = t.composer + return (
{items.length === 0 ? ( - + {kind === '@' ? ( <> - Try @file: or{' '} + {copy.lookupTry} @file: {copy.lookupOr}{' '} @folder:. ) : ( <> - Try /help. + {copy.lookupTry} /help. )} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index e120e6f6fda..c143aa5576e 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -83,6 +83,13 @@ const TOOL_SECTION_SURFACE_CLASS = const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed') +interface ToolStatusCopy { + statusDone: string + statusError: string + statusRecovered: string + statusRunning: string +} + function rawTechnicalTrace(args: unknown, result: unknown): string { const parts = [args, result] .filter(value => value !== undefined && value !== null) @@ -102,11 +109,11 @@ function rawTechnicalTrace(args: unknown, result: unknown): string { return parts.join('\n') } -function statusGlyph(status: ToolStatus): ReactNode { +function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { if (status === 'running') { return ( @@ -114,22 +121,32 @@ function statusGlyph(status: ToolStatus): ReactNode { } if (status === 'error') { - return + return } if (status === 'warning') { - return + return ( + + ) } - return + return ( + + ) } // Leading glyph for any tool-row header. Status (running/error/warning) // takes precedence; otherwise falls back to the tool's codicon. Returns // null when neither applies so callers can render unconditionally. -function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) { +function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) { const node = status ? ( - statusGlyph(status) + statusGlyph(status, copy) ) : icon ? ( ) : null @@ -296,7 +313,7 @@ function ToolEntry({ part }: ToolEntryProps) { trailing={trailing} > - + - + { slot?: string } +interface ImageActionCopy { + downloadImage: string + savingImage: string +} + export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) { const { t } = useI18n() const copy = t.desktop @@ -112,7 +117,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, . onClick={() => setLightboxOpen(false)} src={src} /> - +
@@ -128,12 +133,12 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, . className="contents" disabled={!canOpen} onClick={() => canOpen && setLightboxOpen(true)} - title={canOpen ? 'Open image' : undefined} + title={canOpen ? copy.openImage : undefined} type="button" > {alt - {src && } + {src && } {lightbox} @@ -141,17 +146,19 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, . } function ImageActionButton({ + copy, onClick, saving, variant }: { + copy: ImageActionCopy onClick: () => void saving: boolean variant: 'inline' | 'lightbox' }) { return (