feat(desktop): localize desktop chrome

Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
This commit is contained in:
Jim Liu 宝玉 2026-06-05 22:43:16 -05:00 committed by Teknium
parent 812dc6957e
commit fbd423b94d
14 changed files with 297 additions and 70 deletions

View file

@ -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<void>
stop: () => Promise<MicRecording | null>
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()

View file

@ -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<ConversationStatus>('idle')
const [muted, setMuted] = useState(false)
const turnTimeoutRef = useRef<number | null>(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

View file

@ -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<VoiceStatus>('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)
}
}

View file

@ -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(
<I18nProvider configClient={null} initialLocale="zh">
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
</I18nProvider>
)
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')
})
})

View file

@ -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 (
<div
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
@ -69,15 +73,15 @@ export function ComposerTriggerPopover({
role="listbox"
>
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
{kind === '@' ? (
<>
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
Try <span className="font-mono text-foreground/80">/help</span>.
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>

View file

@ -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 (
<BrailleSpinner
ariaLabel="Running"
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
/>
@ -114,22 +121,32 @@ function statusGlyph(status: ToolStatus): ReactNode {
}
if (status === 'error') {
return <AlertCircle aria-label="Error" className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label={copy.statusError} className="size-3.5 shrink-0 text-destructive" />
}
if (status === 'warning') {
return <AlertCircle aria-label="Recovered" className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
return (
<AlertCircle
aria-label={copy.statusRecovered}
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
)
}
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return (
<CheckCircle2
aria-label={copy.statusDone}
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
/>
)
}
// 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 ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
@ -296,7 +313,7 @@ function ToolEntry({ part }: ToolEntryProps) {
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
@ -518,7 +535,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
<ToolGlyph copy={copy} status={displayStatus === 'success' ? undefined : displayStatus} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,

View file

@ -51,6 +51,11 @@ export interface ZoomableImageProps extends ComponentProps<'img'> {
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}
/>
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
<ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
@ -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"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{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 (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
aria-label={saving ? copy.savingImage : copy.downloadImage}
className={cn(
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
@ -161,7 +168,7 @@ function ImageActionButton({
event.stopPropagation()
void onClick()
}}
title={saving ? 'Saving image' : 'Download image'}
title={saving ? copy.savingImage : copy.downloadImage}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />

View file

@ -116,6 +116,7 @@ export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
const boot = useStore($desktopBoot)
const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted })
@ -196,7 +197,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<Header />
{onboarding.manual ? (
<Button
aria-label="Close"
aria-label={t.common.close}
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => closeManualOnboarding()}
size="icon-sm"

View file

@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { CopyButton } from './copy-button'
describe('CopyButton i18n', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('uses localized default labels and copied feedback', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
render(
<I18nProvider configClient={null} initialLocale="zh">
<CopyButton text="hello" />
</I18nProvider>
)
const button = screen.getByRole('button', { name: '复制' })
expect(button.textContent).toContain('复制')
fireEvent.click(button)
await waitFor(() => expect(writeText).toHaveBeenCalledWith('hello'))
await waitFor(() => expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy())
expect(screen.getByRole('button', { name: '已复制' }).textContent).toContain('已复制')
})
})

View file

@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -59,10 +60,10 @@ export function CopyButton({
children,
className,
disabled = false,
errorMessage = 'Copy failed',
errorMessage,
haptic = true,
iconClassName,
label = 'Copy',
label,
onCopied,
onCopyError,
preventDefault = false,
@ -71,6 +72,9 @@ export function CopyButton({
text,
title
}: CopyButtonProps) {
const { t } = useI18n()
const resolvedErrorMessage = errorMessage ?? t.common.copyFailed
const resolvedLabel = label ?? t.common.copy
const [status, setStatus] = React.useState<CopyStatus>('idle')
const resetRef = React.useRef<number | null>(null)
@ -138,10 +142,10 @@ export function CopyButton({
const visibleChildren =
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
? status === 'copied'
? 'Copied'
? t.common.copied
: status === 'error'
? 'Failed'
: (children ?? label)
? t.common.failed
: (children ?? resolvedLabel)
: null
const content = (
@ -151,8 +155,9 @@ export function CopyButton({
</>
)
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
const ariaLabel = status === 'idle' ? label : feedbackLabel
const feedbackLabel =
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item') {
return (

View file

@ -20,10 +20,12 @@ export const en: Translations = {
continue: 'Continue',
copied: 'Copied',
copy: 'Copy',
copyFailed: 'Copy failed',
delete: 'Delete',
docs: 'Docs',
done: 'Done',
error: 'Error',
failed: 'Failed',
free: 'Free',
loading: 'Loading…',
notSet: 'Not set',
@ -110,6 +112,25 @@ export const en: Translations = {
openaiRejectedApiKey: 'OpenAI rejected the API key.',
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
},
voice: {
configureSpeechToText: 'Configure speech-to-text to use voice mode.',
couldNotStartSession: 'Could not start voice session',
microphoneAccessDenied: 'Microphone access denied.',
microphoneConstraintsUnsupported: 'Microphone constraints are not supported by this device.',
microphoneFailed: 'Microphone failed',
microphoneInUse: 'Microphone is already in use by another app.',
microphonePermissionDenied: 'Microphone permission was denied.',
microphoneStartFailed: 'Could not start microphone recording.',
microphoneUnsupported: 'This runtime does not support microphone recording.',
noMicrophone: 'No microphone was found.',
noSpeechDetected: 'No speech detected',
playbackFailed: 'Voice playback failed',
recordingFailed: 'Voice recording failed',
transcriptionFailed: 'Voice transcription failed',
transcriptionUnavailable: 'Voice transcription is not available yet.',
tryRecordingAgain: 'Try recording again.',
unavailable: 'Voice unavailable'
}
},
@ -1016,6 +1037,10 @@ export const en: Translations = {
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceDictation: 'Voice dictation',
lookupLoading: 'Looking up…',
lookupNoMatches: 'No matches.',
lookupTry: 'Try',
lookupOr: 'or',
commonCommands: 'Common commands',
hotkeys: 'Hotkeys',
helpFooter: 'opens the full panel · backspace dismisses',
@ -1524,7 +1549,11 @@ export const en: Translations = {
recoveredOne: 'Recovered after 1 failed step',
recoveredMany: count => `Recovered after ${count} failed steps`,
failedOne: '1 step failed',
failedMany: count => `${count} steps failed`
failedMany: count => `${count} steps failed`,
statusRunning: 'Running',
statusError: 'Error',
statusRecovered: 'Recovered',
statusDone: 'Done'
}
},
@ -1587,6 +1616,9 @@ export const en: Translations = {
restartToUseSaveImage: 'Restart Hermes Desktop to use Save Image.',
restartToSaveImages: 'Restart Hermes Desktop to save images',
imageDownloadFailed: 'Image download failed',
openImage: 'Open image',
downloadImage: 'Download image',
savingImage: 'Saving image',
imagePreviewFailed: 'Image preview failed',
imageAttach: 'Image attach',
imageWriteFailed: 'Failed to write image to disk.',

View file

@ -15,7 +15,10 @@ describe('desktop i18n runtime translator', () => {
it('translates string paths for the active runtime locale', () => {
setRuntimeI18nLocale('zh')
expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
expect(translateNow('boot.ready')).toBe('Hermes 桌面版已就绪')
expect(translateNow('notifications.voice.noSpeechDetected')).toBe('没有检测到语音')
expect(translateNow('composer.lookupNoMatches')).toBe('没有匹配项。')
expect(translateNow('assistant.tool.statusRecovered')).toBe('已恢复')
})
it('passes arguments to function translations', () => {

View file

@ -35,10 +35,12 @@ export interface Translations {
continue: string
copied: string
copy: string
copyFailed: string
delete: string
docs: string
done: string
error: string
failed: string
free: string
loading: string
notSet: string
@ -122,6 +124,25 @@ export interface Translations {
openaiRejectedApiKeyWithStatus: (status: string) => string
openaiTtsNeedsKey: string
}
voice: {
configureSpeechToText: string
couldNotStartSession: string
microphoneAccessDenied: string
microphoneConstraintsUnsupported: string
microphoneFailed: string
microphoneInUse: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
noMicrophone: string
noSpeechDetected: string
playbackFailed: string
recordingFailed: string
transcriptionFailed: string
transcriptionUnavailable: string
tryRecordingAgain: string
unavailable: string
}
}
titlebar: {
@ -831,6 +852,10 @@ export interface Translations {
stopDictation: string
transcribingDictation: string
voiceDictation: string
lookupLoading: string
lookupNoMatches: string
lookupTry: string
lookupOr: string
commonCommands: string
hotkeys: string
helpFooter: string
@ -1270,6 +1295,10 @@ export interface Translations {
recoveredMany: (count: number) => string
failedOne: string
failedMany: (count: number) => string
statusRunning: string
statusError: string
statusRecovered: string
statusDone: string
}
}
@ -1331,6 +1360,9 @@ export interface Translations {
restartToUseSaveImage: string
restartToSaveImages: string
imageDownloadFailed: string
openImage: string
downloadImage: string
savingImage: string
imagePreviewFailed: string
imageAttach: string
imageWriteFailed: string

View file

@ -20,10 +20,12 @@ export const zh: Translations = {
continue: '继续',
copied: '已复制',
copy: '复制',
copyFailed: '复制失败',
delete: '删除',
docs: '文档',
done: '完成',
error: '错误',
failed: '失败',
free: '免费',
loading: '加载中…',
notSet: '未设置',
@ -41,14 +43,14 @@ export const zh: Translations = {
},
boot: {
ready: 'Hermes Desktop 已就绪',
desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
ready: 'Hermes 桌面版已就绪',
desktopBootFailedWithMessage: message => `桌面启动失败${message}`,
steps: {
connectingGateway: '正在连接实时桌面网关',
connectingGateway: '正在连接桌面网关',
loadingSettings: '正在加载 Hermes 设置',
loadingSessions: '正在加载最近会话',
startingDesktopConnection: '正在启动桌面连接',
startingHermesDesktop: '正在启动 Hermes Desktop…'
startingHermesDesktop: '正在启动 Hermes 桌面版…'
},
errors: {
backgroundExited: 'Hermes 后台进程已退出。',
@ -60,14 +62,14 @@ export const zh: Translations = {
},
failure: {
title: 'Hermes 无法启动',
description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
description: '后台网关没有启动。请尝试下面的恢复步骤;这里不会删除你的对话或设置。',
remoteTitle: '需要重新登录远程网关',
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
retry: '重试',
repairInstall: '修复安装',
useLocalGateway: '使用本地网关',
openLogs: '打开日志',
repairHint: '修复会重新运行安装器在新机器上可能需要几分钟。',
repairHint: '修复会重新运行安装器在新机器上可能需要几分钟。',
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
hideRecentLogs: '隐藏最近日志',
showRecentLogs: '显示最近日志',
@ -106,6 +108,25 @@ export const zh: Translations = {
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
},
voice: {
configureSpeechToText: '配置语音转文字后即可使用语音模式。',
couldNotStartSession: '无法启动语音会话',
microphoneAccessDenied: '麦克风访问被拒绝。',
microphoneConstraintsUnsupported: '此设备不支持当前麦克风约束。',
microphoneFailed: '麦克风出错',
microphoneInUse: '麦克风正被其他应用占用。',
microphonePermissionDenied: '麦克风权限被拒绝。',
microphoneStartFailed: '无法开始麦克风录音。',
microphoneUnsupported: '当前运行环境不支持麦克风录音。',
noMicrophone: '未找到麦克风。',
noSpeechDetected: '没有检测到语音',
playbackFailed: '语音播放失败',
recordingFailed: '语音录制失败',
transcriptionFailed: '语音转写失败',
transcriptionUnavailable: '语音转写暂不可用。',
tryRecordingAgain: '请再录一次。',
unavailable: '语音不可用'
}
},
@ -1166,6 +1187,10 @@ export const zh: Translations = {
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceDictation: '语音听写',
lookupLoading: '查找中…',
lookupNoMatches: '没有匹配项。',
lookupTry: '试试',
lookupOr: '或',
commonCommands: '常用命令',
hotkeys: '快捷键',
helpFooter: '打开完整面板 · 退格键关闭',
@ -1669,7 +1694,11 @@ export const zh: Translations = {
recoveredOne: '在 1 个失败步骤后已恢复',
recoveredMany: count => `${count} 个失败步骤后已恢复`,
failedOne: '1 个步骤失败',
failedMany: count => `${count} 个步骤失败`
failedMany: count => `${count} 个步骤失败`,
statusRunning: '运行中',
statusError: '错误',
statusRecovered: '已恢复',
statusDone: '完成'
}
},
@ -1700,11 +1729,11 @@ export const zh: Translations = {
yoloSystem: active => `此会话 YOLO ${active ? '已开启' : '已关闭'}`,
yoloTitle: 'YOLO',
yoloToggleFailed: '无法切换 YOLO',
profileStatus: current => `Profile: ${current}。使用 /profile <name> 或“新建会话”选择器在其他 profile 中开始对话。`,
unknownProfile: '未知 profile',
noProfileNamed: (target, available) => `没有名为“${target}”的 profile。可用: ${available}`,
newChatsProfile: name => `新对话将使用 profile ${name}`,
setProfileFailed: '设置 profile 失败',
profileStatus: current => `配置档案:${current}。使用 /profile <name> 或“新建会话”选择器在其他配置档案中开始对话。`,
unknownProfile: '未知配置档案',
noProfileNamed: (target, available) => `没有名为“${target}”的配置档案。可用:${available}`,
newChatsProfile: name => `新对话将使用配置档案 ${name}`,
setProfileFailed: '设置配置档案失败',
sttDisabled: '设置中已禁用语音转文字。',
stopFailed: '停止失败',
regenerateFailed: '重新生成失败',
@ -1728,9 +1757,12 @@ export const zh: Translations = {
sessionExportFailed: '无法导出会话',
imageSaved: '图片已保存',
downloadStarted: '下载已开始',
restartToUseSaveImage: '重启 Hermes Desktop 后可使用保存图片。',
restartToSaveImages: '重启 Hermes Desktop 以保存图片',
restartToUseSaveImage: '重启 Hermes 桌面版后可使用保存图片。',
restartToSaveImages: '重启 Hermes 桌面版以保存图片',
imageDownloadFailed: '图片下载失败',
openImage: '打开图片',
downloadImage: '下载图片',
savingImage: '正在保存图片',
imagePreviewFailed: '图片预览失败',
imageAttach: '附加图片',
imageWriteFailed: '无法将图片写入磁盘。',