mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): localize desktop chrome
Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
This commit is contained in:
parent
812dc6957e
commit
fbd423b94d
14 changed files with 297 additions and 70 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
42
apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
Normal file
42
apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
36
apps/desktop/src/components/ui/copy-button.test.tsx
Normal file
36
apps/desktop/src/components/ui/copy-button.test.tsx
Normal 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('已复制')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '无法将图片写入磁盘。',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue