diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 2647968ad6b..7bd0ba34a73 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' +import { translateNow } from '@/i18n' import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, @@ -151,7 +152,7 @@ export function useGatewayBoot({ // backoff in the finally block below. if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) { reauthNotified = true - notifyError(err, 'Gateway sign-in required') + notifyError(err, translateNow('boot.errors.gatewaySignInRequired')) } } finally { reconnecting = false diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index b18d553aa2c..6fa5aefadba 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -4,6 +4,7 @@ import { type Locale, LOCALE_META, useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Palette } from '@/lib/icons' import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { useTheme } from '@/themes/context' import { BUILTIN_THEMES } from '@/themes/presets' @@ -52,13 +53,28 @@ function ThemePreview({ name }: { name: string }) { } export function AppearanceSettings() { - const { t, locale, setLocale } = useI18n() + const { t, isSavingLocale, locale, setLocale } = useI18n() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const activeTheme = availableThemes.find(theme => theme.name === themeName) const a = t.settings.appearance const locales = Object.keys(LOCALE_META) as Locale[] + const selectLocale = async (code: Locale) => { + if (code === locale || isSavingLocale) { + return + } + + triggerHaptic('selection') + + try { + await setLocale(code) + triggerHaptic('success') + } catch (error) { + notifyError(error, t.language.saveError) + } + } + return (
@@ -74,6 +90,7 @@ export function AppearanceSettings() {
{t.language.label}
{t.language.description}
+ {isSavingLocale &&
{t.language.saving}
}
{LOCALE_META[locale].name}
@@ -87,11 +104,9 @@ export function AppearanceSettings() { 'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)', active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' )} + disabled={isSavingLocale} key={code} - onClick={() => { - triggerHaptic('crisp') - setLocale(code) - }} + onClick={() => void selectLocale(code)} type="button" >
diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index b8cc2205e15..7d1e92b2290 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import type { DesktopConnectionConfig } from '@/global' +import { useI18n } from '@/i18n' import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons' import { $desktopBoot } from '@/store/boot' import { notify, notifyError } from '@/store/notifications' @@ -27,6 +28,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null export function BootFailureOverlay() { const boot = useStore($desktopBoot) const onboarding = useStore($desktopOnboarding) + const { t } = useI18n() const [busy, setBusy] = useState(null) const [logs, setLogs] = useState([]) const [showLogs, setShowLogs] = useState(false) @@ -141,7 +143,7 @@ export function BootFailureOverlay() { const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url) if (result?.connected) { - notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' }) + notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage }) window.location.reload() return @@ -149,19 +151,24 @@ export function BootFailureOverlay() { notify({ kind: 'warning', - title: 'Sign-in incomplete', - message: 'The login window closed before authentication finished.' + title: t.boot.failure.signInIncompleteTitle, + message: t.boot.failure.signInIncompleteMessage }) } catch (err) { - notifyError(err, 'Sign-in failed') + notifyError(err, t.boot.failure.signInFailed) } finally { setBusy(null) } } const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) + const copy = t.boot.failure - const label = signInLabel(remoteReauth) + const label = signInLabel(remoteReauth, { + identityProvider: copy.identityProvider, + remoteGateway: copy.signInToRemoteGateway, + withProvider: copy.signInWithProvider + }) return (
@@ -172,12 +179,10 @@ export function BootFailureOverlay() {

- {remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"} + {remoteReauth ? copy.remoteTitle : copy.title}

- {remoteReauth - ? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.' - : "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."} + {remoteReauth ? copy.remoteDescription : copy.description}

@@ -197,28 +202,26 @@ export function BootFailureOverlay() { ) : ( )} {!remoteReauth ? ( ) : null}

- {remoteReauth - ? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.' - : 'Repair re-runs the installer and can take a few minutes on a fresh machine.'} + {remoteReauth ? copy.remoteSignInHint : copy.repairHint}

@@ -229,7 +232,7 @@ export function BootFailureOverlay() { onClick={() => setShowLogs(v => !v)} type="button" > - {showLogs ? 'Hide' : 'Show'} recent logs + {showLogs ? copy.hideRecentLogs : copy.showRecentLogs} {showLogs ? (
diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts
index 20ac68618a8..9faa4eea27e 100644
--- a/apps/desktop/src/components/boot-failure-reauth.ts
+++ b/apps/desktop/src/components/boot-failure-reauth.ts
@@ -14,6 +14,18 @@ export interface RemoteReauth {
   providerLabel: string
 }
 
+interface SignInCopy {
+  identityProvider: string
+  remoteGateway: string
+  withProvider: (provider: string) => string
+}
+
+const DEFAULT_SIGN_IN_COPY: SignInCopy = {
+  identityProvider: 'your identity provider',
+  remoteGateway: 'Sign in to remote gateway',
+  withProvider: provider => `Sign in with ${provider}`
+}
+
 // A remote, gated (oauth-bucket), not-currently-connected gateway is a
 // remote-reauth boot failure: the access cookie lapsed (e.g. the remote
 // dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
@@ -58,10 +70,12 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
 }
 
 // Button copy for the remote sign-in action.
-export function signInLabel(reauth: RemoteReauth | null): string {
+export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
   if (reauth?.isPassword) {
-    return 'Sign in to remote gateway'
+    return copy.remoteGateway
   }
 
-  return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
+  const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
+
+  return copy.withProvider(provider ?? copy.identityProvider)
 }
diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx
index 55a7d148c06..44caad7c380 100644
--- a/apps/desktop/src/components/notifications.tsx
+++ b/apps/desktop/src/components/notifications.tsx
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
 import { Codicon } from '@/components/ui/codicon'
 import { CopyButton } from '@/components/ui/copy-button'
+import { useI18n } from '@/i18n'
 import { triggerHaptic } from '@/lib/haptics'
 import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
 import { cn } from '@/lib/utils'
@@ -30,8 +31,10 @@ const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
 
 export function NotificationStack() {
   const notifications = useStore($notifications)
+  const { t } = useI18n()
   const lastNotificationIdRef = useRef(null)
   const [expanded, setExpanded] = useState(false)
+  const copy = t.notifications
 
   useEffect(() => {
     if (notifications.length <= 1) {
@@ -72,7 +75,7 @@ export function NotificationStack() {
   // scope, so fall back to its constant (34px) when mounted on .
   return createPortal(
     
@@ -81,10 +84,10 @@ export function NotificationStack() { {overflowCount > 0 && (
)} @@ -97,6 +100,8 @@ function NotificationItem({ notification }: { notification: AppNotification }) { const styles = tone[notification.kind] const Icon = styles.icon const hasDetail = Boolean(notification.detail && notification.detail !== notification.message) + const { t } = useI18n() + const copy = t.notifications return (
+ + ) } -const wrapper = ({ children }: { children: ReactNode }) => {children} - describe('I18nProvider', () => { - beforeEach(installStorageMock) - - it('defaults to English', () => { - const { result } = renderHook(() => useI18n(), { wrapper }) - - expect(result.current.locale).toBe('en') - expect(result.current.t.language.label).toBe('Language') + afterEach(() => { + cleanup() + vi.restoreAllMocks() }) - it('switches translations and persists the locale', () => { - const { result } = renderHook(() => useI18n(), { wrapper }) + it('defaults to English without a config client', () => { + render( + + + + ) - act(() => result.current.setLocale('zh')) - - expect(result.current.locale).toBe('zh') - expect(result.current.t.language.label).toBe('语言') - expect(window.localStorage.getItem(STORAGE_KEY)).toBe('zh') + expect(screen.getByTestId('locale').textContent).toBe('en') + expect(screen.getByTestId('label').textContent).toBe('Language') }) - it('restores a persisted locale on mount', () => { - window.localStorage.setItem(STORAGE_KEY, 'zh') + it('normalizes an initial locale alias and switches translations', async () => { + render( + + + + ) - const { result } = renderHook(() => useI18n(), { wrapper }) + expect(screen.getByTestId('locale').textContent).toBe('zh') + expect(screen.getByTestId('label').textContent).toBe('语言') - expect(result.current.locale).toBe('zh') + fireEvent.click(screen.getByRole('button', { name: 'switch' })) + + await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en')) + expect(screen.getByTestId('label').textContent).toBe('Language') + }) + + it('loads the initial locale from display.language config', async () => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-Hans' } }), + saveConfig: vi.fn() + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + expect(screen.getByTestId('locale').textContent).toBe('zh') + expect(screen.getByTestId('label').textContent).toBe('语言') + expect(configClient.saveConfig).not.toHaveBeenCalled() + }) + + it('keeps English usable when config loading fails', async () => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')), + saveConfig: vi.fn() + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + expect(screen.getByTestId('locale').textContent).toBe('en') + expect(screen.getByTestId('label').textContent).toBe('Language') + expect(configClient.saveConfig).not.toHaveBeenCalled() + }) + + it('does not overwrite unsupported configured languages', async () => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }), + saveConfig: vi.fn() + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + expect(screen.getByTestId('locale').textContent).toBe('en') + expect(screen.getByTestId('label').textContent).toBe('Language') + expect(configClient.saveConfig).not.toHaveBeenCalled() + }) + + it('reads latest config before saving language and preserves unrelated values', async () => { + const saveConfig = vi.fn().mockResolvedValue({ ok: true }) + + const latestConfig: HermesConfigRecord = { + display: { language: 'en', skin: 'slate' }, + terminal: { cwd: '/new' } + } + + const configClient: I18nConfigClient = { + getConfig: vi + .fn() + .mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' }, terminal: { cwd: '/old' } }) + .mockResolvedValueOnce(latestConfig), + saveConfig + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + fireEvent.click(screen.getByRole('button', { name: 'switch' })) + + await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1)) + expect(saveConfig).toHaveBeenCalledWith({ + display: { language: 'zh', skin: 'slate' }, + terminal: { cwd: '/new' } + }) + }) + + it('rolls back the visible locale when saving fails', async () => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }), + saveConfig: vi.fn().mockRejectedValue(new Error('save failed')) + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + fireEvent.click(screen.getByRole('button', { name: 'switch' })) + + await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('save failed')) + + expect(screen.getByTestId('locale').textContent).toBe('en') + expect(screen.getByTestId('label').textContent).toBe('Language') }) }) diff --git a/apps/desktop/src/i18n/context.tsx b/apps/desktop/src/i18n/context.tsx index 3c3f6bdf0ab..103a63047e7 100644 --- a/apps/desktop/src/i18n/context.tsx +++ b/apps/desktop/src/i18n/context.tsx @@ -1,71 +1,178 @@ -import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react' +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { en } from './en' +import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes' + +import { TRANSLATIONS } from './catalog' +import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages' +import { setRuntimeI18nLocale } from './runtime' import type { Locale, Translations } from './types' -import { zh } from './zh' -const TRANSLATIONS: Record = { - en, - zh +export { LOCALE_META } from './languages' + +export interface I18nConfigClient { + getConfig: () => Promise + saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }> } -// Endonyms (native names) for the language picker so users recognize their -// language regardless of the current UI language. No country flags — -// languages are not countries. -export const LOCALE_META: Record = { - en: { name: 'English' }, - zh: { name: '简体中文' } -} - -const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[] -const STORAGE_KEY = 'hermes-desktop-locale' - -function isLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as string[]).includes(value) -} - -function getInitialLocale(): Locale { - try { - const stored = window.localStorage.getItem(STORAGE_KEY) - - if (stored && isLocale(stored)) { - return stored +const defaultConfigClient: I18nConfigClient = { + getConfig: () => { + if (typeof window === 'undefined' || !window.hermesDesktop?.api) { + return Promise.resolve({}) } - } catch { - // localStorage unavailable (privacy mode / SSR) — fall back to English. - } - return 'en' + return getHermesConfigRecord() + }, + saveConfig: config => { + if (typeof window === 'undefined' || !window.hermesDesktop?.api) { + return Promise.resolve({ ok: true }) + } + + return saveHermesConfig(config) + } } -interface I18nContextValue { +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown { + return isRecord(config.display) ? config.display.language : undefined +} + +export function withConfigDisplayLanguage(config: HermesConfigRecord, locale: Locale): HermesConfigRecord { + const display = isRecord(config.display) ? config.display : {} + + return { + ...config, + display: { + ...display, + language: localeConfigValue(locale) + } + } +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export interface I18nContextValue { + configLoadError: Error | null + isLoadingConfig: boolean + isSavingLocale: boolean locale: Locale - setLocale: (next: Locale) => void + saveError: Error | null + setLocale: (next: Locale) => Promise t: Translations } const I18nContext = createContext({ - locale: 'en', - setLocale: () => {}, - t: en + configLoadError: null, + isLoadingConfig: false, + isSavingLocale: false, + locale: DEFAULT_LOCALE, + saveError: null, + setLocale: async () => {}, + t: TRANSLATIONS[DEFAULT_LOCALE] }) -export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState(getInitialLocale) +export interface I18nProviderProps { + children: ReactNode + configClient?: I18nConfigClient | null + initialLocale?: unknown +} - const setLocale = useCallback((next: Locale) => { - setLocaleState(next) +export function I18nProvider({ children, configClient = defaultConfigClient, initialLocale }: I18nProviderProps) { + const [locale, setLocaleState] = useState(() => normalizeLocale(initialLocale)) + const [isLoadingConfig, setIsLoadingConfig] = useState(false) + const [isSavingLocale, setIsSavingLocale] = useState(false) + const [configLoadError, setConfigLoadError] = useState(null) + const [saveError, setSaveError] = useState(null) + const localeRef = useRef(locale) - try { - window.localStorage.setItem(STORAGE_KEY, next) - } catch { - // ignore persistence failures + useEffect(() => { + localeRef.current = locale + setRuntimeI18nLocale(locale) + }, [locale]) + + useEffect(() => { + if (!configClient) { + return } - }, []) + + let cancelled = false + + setIsLoadingConfig(true) + setConfigLoadError(null) + + configClient + .getConfig() + .then(config => { + if (!cancelled) { + setLocaleState(normalizeLocale(getConfigDisplayLanguage(config))) + } + }) + .catch(error => { + if (!cancelled) { + setConfigLoadError(toError(error)) + setLocaleState(DEFAULT_LOCALE) + } + }) + .finally(() => { + if (!cancelled) { + setIsLoadingConfig(false) + } + }) + + return () => { + cancelled = true + } + }, [configClient, initialLocale]) + + const setLocale = useCallback( + async (next: Locale) => { + const previousLocale = localeRef.current + + setSaveError(null) + setLocaleState(next) + + if (!configClient) { + return + } + + setIsSavingLocale(true) + + try { + const latestConfig = await configClient.getConfig() + const result = await configClient.saveConfig(withConfigDisplayLanguage(latestConfig, next)) + + if (!result.ok) { + throw new Error('Failed to save language') + } + } catch (error) { + const nextError = toError(error) + + setLocaleState(previousLocale) + setSaveError(nextError) + + throw nextError + } finally { + setIsSavingLocale(false) + } + }, + [configClient] + ) const value = useMemo( - () => ({ locale, setLocale, t: TRANSLATIONS[locale] }), - [locale, setLocale] + () => ({ + configLoadError, + isLoadingConfig, + isSavingLocale, + locale, + saveError, + setLocale, + t: TRANSLATIONS[locale] + }), + [configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale] ) return {children} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 78433fef0bd..4ca14b7daf1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -16,6 +16,79 @@ export const en: Translations = { off: 'Off' }, + boot: { + ready: 'Hermes Desktop is ready', + desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`, + steps: { + connectingGateway: 'Connecting live desktop gateway', + loadingSettings: 'Loading Hermes settings', + loadingSessions: 'Loading recent sessions', + startingDesktopConnection: 'Starting desktop connection', + startingHermesDesktop: 'Starting Hermes Desktop…' + }, + errors: { + backgroundExited: 'Hermes background process exited.', + backgroundExitedDuringStartup: 'Hermes background process exited during startup.', + backendStopped: 'Backend stopped', + desktopBootFailed: 'Desktop boot failed', + gatewaySignInRequired: 'Gateway sign-in required', + ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.' + }, + failure: { + title: "Hermes couldn't start", + description: + "The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.", + remoteTitle: 'Remote gateway sign-in required', + remoteDescription: + 'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.', + retry: 'Retry', + repairInstall: 'Repair install', + useLocalGateway: 'Use local gateway', + openLogs: 'Open logs', + repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.', + remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.', + hideRecentLogs: 'Hide recent logs', + showRecentLogs: 'Show recent logs', + signedInTitle: 'Signed in', + signedInMessage: 'Reconnecting to the remote gateway…', + signInIncompleteTitle: 'Sign-in incomplete', + signInIncompleteMessage: 'The login window closed before authentication finished.', + signInFailed: 'Sign-in failed', + signInToRemoteGateway: 'Sign in to remote gateway', + signInWithProvider: provider => `Sign in with ${provider}`, + identityProvider: 'your identity provider' + } + }, + + notifications: { + region: 'Notifications', + hide: 'Hide', + show: 'Show', + more: count => `${count} more ${count === 1 ? 'notification' : 'notifications'}`, + clearAll: 'Clear all', + dismiss: 'Dismiss notification', + details: 'Details', + copyDetail: 'Copy detail', + copyDetailFailed: 'Could not copy notification detail', + backendOutOfDateTitle: 'Backend out of date', + backendOutOfDateMessage: + 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', + updateHermes: 'Update Hermes', + updateReadyTitle: 'Update ready', + updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`, + seeWhatsNew: "See what's new", + errors: { + elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.', + elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).', + methodNotAllowed: + 'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.', + microphonePermission: 'Microphone permission was denied.', + 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.' + } + }, + titlebar: { hideSidebar: 'Hide sidebar', showSidebar: 'Show sidebar', @@ -32,7 +105,9 @@ export const en: Translations = { language: { label: 'Language', - description: 'Choose the language for the desktop interface.' + description: 'Choose the language for the desktop interface.', + saving: 'Saving language…', + saveError: 'Language update failed' }, settings: { diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index b8982a0600d..b04d64948ce 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,2 +1,20 @@ -export { I18nProvider, LOCALE_META, useI18n } from './context' +export { TRANSLATIONS } from './catalog' +export { + getConfigDisplayLanguage, + type I18nConfigClient, + type I18nContextValue, + I18nProvider, + LOCALE_META, + useI18n, + withConfigDisplayLanguage +} from './context' +export { + DEFAULT_LOCALE, + isLocale, + isSupportedLocaleValue, + LOCALE_OPTIONS, + localeConfigValue, + normalizeLocale +} from './languages' +export { setRuntimeI18nLocale, translateNow } from './runtime' export type { Locale, Translations } from './types' diff --git a/apps/desktop/src/i18n/languages.test.ts b/apps/desktop/src/i18n/languages.test.ts new file mode 100644 index 00000000000..c5f8ebc6258 --- /dev/null +++ b/apps/desktop/src/i18n/languages.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_LOCALE, + isLocale, + isSupportedLocaleValue, + localeConfigValue, + normalizeLocale +} from './languages' + +describe('desktop i18n languages', () => { + it('normalizes supported locale aliases', () => { + expect(normalizeLocale('en')).toBe('en') + expect(normalizeLocale('EN-US')).toBe('en') + expect(normalizeLocale('zh')).toBe('zh') + expect(normalizeLocale('zh-CN')).toBe('zh') + expect(normalizeLocale('zh-Hans')).toBe('zh') + expect(normalizeLocale(' zh_hans_cn ')).toBe('zh') + }) + + it('falls back to English for empty or unsupported values', () => { + expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE) + expect(normalizeLocale('')).toBe(DEFAULT_LOCALE) + expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE) + }) + + it('distinguishes exact locale ids from supported config aliases', () => { + expect(isSupportedLocaleValue('zh-CN')).toBe(true) + expect(isSupportedLocaleValue('ja')).toBe(false) + expect(isLocale('zh-CN')).toBe(false) + expect(isLocale('zh')).toBe(true) + }) + + it('returns the persisted config value for supported locales', () => { + expect(localeConfigValue('en')).toBe('en') + expect(localeConfigValue('zh')).toBe('zh') + }) +}) diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts new file mode 100644 index 00000000000..4fdcdf9de0e --- /dev/null +++ b/apps/desktop/src/i18n/languages.ts @@ -0,0 +1,56 @@ +import type { Locale } from './types' + +export const DEFAULT_LOCALE: Locale = 'en' + +export const LOCALE_OPTIONS = [ + { + id: 'en', + name: 'English', + configValue: 'en' + }, + { + id: 'zh', + name: '简体中文', + configValue: 'zh' + } +] as const satisfies readonly { configValue: string; id: Locale; name: string }[] + +// Endonyms (native names) for the language picker so users recognize their +// language regardless of the current UI language. No country flags: +// languages are not countries. +export const LOCALE_META: Record = Object.fromEntries( + LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }]) +) as Record + +const LOCALE_ALIASES: Record = { + en: 'en', + 'en-us': 'en', + en_us: 'en', + zh: 'zh', + 'zh-cn': 'zh', + zh_cn: 'zh', + 'zh-hans': 'zh', + zh_hans: 'zh', + 'zh-hans-cn': 'zh', + zh_hans_cn: 'zh' +} + +export function isLocale(value: unknown): value is Locale { + return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value) +} + +export function normalizeLocale(value: unknown): Locale { + if (typeof value !== 'string') { + return DEFAULT_LOCALE + } + + return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE +} + +export function isSupportedLocaleValue(value: unknown): boolean { + return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null +} + +export function localeConfigValue(locale: Locale): string { + return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE +} diff --git a/apps/desktop/src/i18n/runtime.test.ts b/apps/desktop/src/i18n/runtime.test.ts new file mode 100644 index 00000000000..a18296f3bfa --- /dev/null +++ b/apps/desktop/src/i18n/runtime.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { setRuntimeI18nLocale, translateNow } from './runtime' + +describe('desktop i18n runtime translator', () => { + beforeEach(() => { + setRuntimeI18nLocale('en') + }) + + afterEach(() => { + setRuntimeI18nLocale('en') + }) + + it('translates string paths for the active runtime locale', () => { + setRuntimeI18nLocale('zh') + + expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪') + }) + + it('passes arguments to function translations', () => { + expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.') + }) + + it('returns the key when no locale can resolve a path', () => { + setRuntimeI18nLocale('zh') + + expect(translateNow('missing.path')).toBe('missing.path') + }) +}) diff --git a/apps/desktop/src/i18n/runtime.ts b/apps/desktop/src/i18n/runtime.ts new file mode 100644 index 00000000000..b9276aaf965 --- /dev/null +++ b/apps/desktop/src/i18n/runtime.ts @@ -0,0 +1,53 @@ +import { TRANSLATIONS } from './catalog' +import { DEFAULT_LOCALE } from './languages' +import type { Locale, Translations } from './types' + +let runtimeLocale: Locale = DEFAULT_LOCALE + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function resolvePath(catalog: Translations, key: string): unknown { + return key.split('.').reduce((current, part) => { + if (!isRecord(current)) { + return undefined + } + + return current[part] + }, catalog) +} + +function renderTranslation(value: unknown, args: unknown[]): string | null { + if (typeof value === 'string') { + return value + } + + if (typeof value === 'function') { + return (value as (...args: unknown[]) => string)(...args) + } + + return null +} + +export function setRuntimeI18nLocale(locale: Locale) { + runtimeLocale = locale +} + +export function translateNow(key: string, ...args: unknown[]): string { + const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args) + + if (active !== null) { + return active + } + + if (runtimeLocale !== DEFAULT_LOCALE) { + const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args) + + if (fallback !== null) { + return fallback + } + } + + return key +} diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 75350ac05a1..af54fb40cb9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -26,6 +26,75 @@ export interface Translations { off: string } + boot: { + ready: string + desktopBootFailedWithMessage: (message: string) => string + steps: { + connectingGateway: string + loadingSettings: string + loadingSessions: string + startingDesktopConnection: string + startingHermesDesktop: string + } + errors: { + backgroundExited: string + backgroundExitedDuringStartup: string + backendStopped: string + desktopBootFailed: string + gatewaySignInRequired: string + ipcBridgeUnavailable: string + } + failure: { + title: string + description: string + remoteTitle: string + remoteDescription: string + retry: string + repairInstall: string + useLocalGateway: string + openLogs: string + repairHint: string + remoteSignInHint: string + hideRecentLogs: string + showRecentLogs: string + signedInTitle: string + signedInMessage: string + signInIncompleteTitle: string + signInIncompleteMessage: string + signInFailed: string + signInToRemoteGateway: string + signInWithProvider: (provider: string) => string + identityProvider: string + } + } + + notifications: { + region: string + hide: string + show: string + more: (count: number) => string + clearAll: string + dismiss: string + details: string + copyDetail: string + copyDetailFailed: string + backendOutOfDateTitle: string + backendOutOfDateMessage: string + updateHermes: string + updateReadyTitle: string + updateReadyMessage: (count: number) => string + seeWhatsNew: string + errors: { + elevenLabsNeedsKey: string + elevenLabsRejectedKey: string + methodNotAllowed: string + microphonePermission: string + openaiRejectedApiKey: string + openaiRejectedApiKeyWithStatus: (status: string) => string + openaiTtsNeedsKey: string + } + } + titlebar: { hideSidebar: string showSidebar: string @@ -43,6 +112,8 @@ export interface Translations { language: { label: string description: string + saving: string + saveError: string } settings: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 83fe6ba9495..36ca4038a2f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -14,6 +14,75 @@ export const zh: Translations = { off: '关' }, + boot: { + ready: 'Hermes Desktop 已就绪', + desktopBootFailedWithMessage: message => `桌面启动失败:${message}`, + steps: { + connectingGateway: '正在连接实时桌面网关', + loadingSettings: '正在加载 Hermes 设置', + loadingSessions: '正在加载最近会话', + startingDesktopConnection: '正在启动桌面连接', + startingHermesDesktop: '正在启动 Hermes Desktop…' + }, + errors: { + backgroundExited: 'Hermes 后台进程已退出。', + backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。', + backendStopped: '后端已停止', + desktopBootFailed: '桌面启动失败', + gatewaySignInRequired: '需要登录网关', + ipcBridgeUnavailable: '桌面 IPC 桥不可用。' + }, + failure: { + title: 'Hermes 无法启动', + description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。', + remoteTitle: '需要重新登录远程网关', + remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。', + retry: '重试', + repairInstall: '修复安装', + useLocalGateway: '使用本地网关', + openLogs: '打开日志', + repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。', + remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。', + hideRecentLogs: '隐藏最近日志', + showRecentLogs: '显示最近日志', + signedInTitle: '已登录', + signedInMessage: '正在重新连接远程网关…', + signInIncompleteTitle: '登录未完成', + signInIncompleteMessage: '登录窗口在认证完成前关闭。', + signInFailed: '登录失败', + signInToRemoteGateway: '登录远程网关', + signInWithProvider: provider => `使用 ${provider} 登录`, + identityProvider: '你的身份提供方' + } + }, + + notifications: { + region: '通知', + hide: '隐藏', + show: '显示', + more: count => `另外 ${count} 条通知`, + clearAll: '全部清除', + dismiss: '关闭通知', + details: '详情', + copyDetail: '复制详情', + copyDetailFailed: '无法复制通知详情', + backendOutOfDateTitle: '后端版本过旧', + backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。', + updateHermes: '更新 Hermes', + updateReadyTitle: '有可用更新', + updateReadyMessage: count => `有 ${count} 项新更改可用。`, + seeWhatsNew: '查看更新内容', + errors: { + elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。', + elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。', + methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。', + microphonePermission: '麦克风权限已被拒绝。', + openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。', + openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`, + openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。' + } + }, + titlebar: { hideSidebar: '隐藏侧边栏', showSidebar: '显示侧边栏', @@ -30,7 +99,9 @@ export const zh: Translations = { language: { label: '语言', - description: '选择桌面界面的语言。' + description: '选择桌面界面的语言。', + saving: '正在保存语言…', + saveError: '语言更新失败' }, settings: { diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts index dfbd6d5f3cd..f25be5089db 100644 --- a/apps/desktop/src/store/boot.ts +++ b/apps/desktop/src/store/boot.ts @@ -1,6 +1,7 @@ import { atom } from 'nanostores' import type { DesktopBootProgress } from '@/global' +import { translateNow } from '@/i18n' export interface DesktopBootState extends DesktopBootProgress { visible: boolean @@ -9,7 +10,7 @@ export interface DesktopBootState extends DesktopBootProgress { const INITIAL_BOOT_STATE: DesktopBootState = { error: null, fakeMode: false, - message: 'Starting Hermes Desktop…', + message: translateNow('boot.steps.startingHermesDesktop'), phase: 'renderer.init', progress: 2, running: true, @@ -61,7 +62,7 @@ export function setDesktopBootStep(step: { }) } -export function completeDesktopBoot(message = 'Hermes Desktop is ready') { +export function completeDesktopBoot(message = translateNow('boot.ready')) { const current = $desktopBoot.get() $desktopBoot.set({ ...current, @@ -80,7 +81,7 @@ export function failDesktopBoot(message: string) { $desktopBoot.set({ ...current, error: message, - message: `Desktop boot failed: ${message}`, + message: translateNow('boot.desktopBootFailedWithMessage', message), phase: 'renderer.error', progress: clampProgress(current.progress), running: false, diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index d99887f2763..b80f7861003 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -1,5 +1,7 @@ import { atom } from 'nanostores' +import { translateNow } from '@/i18n' + export type NotificationKind = 'error' | 'warning' | 'info' | 'success' export interface NotificationAction { @@ -52,28 +54,29 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string summarize: msg => { const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1] - return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.` + return status + ? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status) + : translateNow('notifications.errors.openaiRejectedApiKey') } }, { test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg), - summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.' + summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey') }, { test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg), summarize: msg => /ELEVENLABS_API_KEY not set/i.test(msg) - ? 'ElevenLabs STT needs ELEVENLABS_API_KEY.' - : 'ElevenLabs rejected the API key (401).' + ? translateNow('notifications.errors.elevenLabsNeedsKey') + : translateNow('notifications.errors.elevenLabsRejectedKey') }, { test: msg => /method not allowed/i.test(msg), - summarize: () => - 'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.' + summarize: () => translateNow('notifications.errors.methodNotAllowed') }, { test: msg => /microphone permission/i.test(msg), - summarize: () => 'Microphone permission was denied.' + summarize: () => translateNow('notifications.errors.microphonePermission') } ] diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 3a8fbc77968..ad568093f35 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -13,6 +13,7 @@ import type { DesktopUpdateStatus, DesktopVersionInfo } from '@/global' +import { translateNow } from '@/i18n' import { persistString, storedString } from '@/lib/storage' import { dismissNotification, notify } from '@/store/notifications' @@ -85,12 +86,12 @@ export function reportBackendContract(contract: number | undefined): void { } notify({ - action: { label: 'Update Hermes', onClick: () => void applyUpdates() }, + action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() }, durationMs: 0, id: SKEW_TOAST_ID, kind: 'warning', - message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', - title: 'Backend out of date' + message: translateNow('notifications.backendOutOfDateMessage'), + title: translateNow('notifications.backendOutOfDateTitle') }) } @@ -121,7 +122,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { notify({ action: { - label: "See what's new", + label: translateNow('notifications.seeWhatsNew'), onClick: () => { snoozeUpdateToast() openUpdatesWindow() @@ -130,9 +131,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { durationMs: 0, id: UPDATE_TOAST_ID, kind: 'info', - message: `${behind} new change${behind === 1 ? '' : 's'} available.`, + message: translateNow('notifications.updateReadyMessage', behind), onDismiss: () => snoozeUpdateToast(), - title: 'Update ready' + title: translateNow('notifications.updateReadyTitle') }) } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index bec52cb1c90..921925cddd0 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -466,6 +466,10 @@ export interface ProfileInfo { skill_count: number } +export interface ProfileSetupCommand { + command: string +} + export interface ProfileSoul { content: string exists: boolean