feat(desktop): persist i18n language in config

This commit is contained in:
Jim Liu 宝玉 2026-06-04 15:44:37 -05:00 committed by Teknium
parent 4a1907bd10
commit 1d9c3ebae0
21 changed files with 836 additions and 141 deletions

View file

@ -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

View file

@ -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 (
<SettingsContent>
<div className="space-y-5">
@ -74,6 +90,7 @@ export function AppearanceSettings() {
<div>
<div className="text-sm font-medium">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<Pill>{LOCALE_META[locale].name}</Pill>
</div>
@ -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"
>
<div className="flex items-start justify-between gap-3">

View file

@ -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<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([])
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 (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
@ -172,12 +179,10 @@ export function BootFailureOverlay() {
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
{remoteReauth ? copy.remoteTitle : copy.title}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{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}
</p>
</div>
</div>
@ -197,28 +202,26 @@ export function BootFailureOverlay() {
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
{copy.retry}
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
{copy.useLocalGateway}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
Open logs
{copy.openLogs}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{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}
</p>
</div>
@ -229,7 +232,7 @@ export function BootFailureOverlay() {
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? 'Hide' : 'Show'} recent logs
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">

View file

@ -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)
}

View file

@ -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<string | null>(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 <body>.
return createPortal(
<div
aria-label="Notifications"
aria-label={copy.region}
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
@ -81,10 +84,10 @@ export function NotificationStack() {
{overflowCount > 0 && (
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
</button>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
Clear all
{copy.clearAll}
</button>
</div>
)}
@ -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 (
<Alert
@ -126,7 +131,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
</AlertDescription>
</div>
<button
aria-label="Dismiss notification"
aria-label={copy.dismiss}
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dismissNotification(notification.id)}
type="button"
@ -138,9 +143,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
}
function NotificationDetail({ detail }: { detail: string }) {
const { t } = useI18n()
const copy = t.notifications
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
@ -148,12 +156,12 @@ function NotificationDetail({ detail }: { detail: string }) {
<CopyButton
appearance="inline"
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
errorMessage="Could not copy notification detail"
errorMessage={copy.copyDetailFailed}
iconClassName="size-3"
label="Copy detail"
label={copy.copyDetail}
text={detail}
>
Copy detail
{copy.copyDetail}
</CopyButton>
</div>
</details>

View file

@ -29,6 +29,7 @@ import type {
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionMessagesResponse,
@ -80,6 +81,7 @@ export type {
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
RpcEvent,
@ -563,6 +565,12 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),

View file

@ -0,0 +1,8 @@
import { en } from './en'
import type { Locale, Translations } from './types'
import { zh } from './zh'
export const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh
}

View file

@ -1,57 +1,168 @@
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider, useI18n } from './context'
import type { HermesConfigRecord } from '@/hermes'
const STORAGE_KEY = 'hermes-desktop-locale'
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
import type { Locale } from './types'
// This jsdom build ships a partial localStorage (missing removeItem/clear), so
// back it with a Map for a deterministic, self-contained test.
function installStorageMock() {
const store = new Map<string, string>()
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
const { isLoadingConfig, isSavingLocale, locale, saveError, setLocale, t } = useI18n()
const mock: Storage = {
get length() {
return store.size
},
clear: () => store.clear(),
getItem: key => store.get(key) ?? null,
key: index => Array.from(store.keys())[index] ?? null,
removeItem: key => void store.delete(key),
setItem: (key, value) => void store.set(key, String(value))
}
Object.defineProperty(window, 'localStorage', { configurable: true, value: mock })
return (
<div>
<p data-testid="locale">{locale}</p>
<p data-testid="label">{t.language.label}</p>
<p data-testid="loading">{String(isLoadingConfig)}</p>
<p data-testid="saving">{String(isSavingLocale)}</p>
<p data-testid="save-error">{saveError?.message ?? ''}</p>
<button onClick={() => void setLocale(target).catch(() => undefined)} type="button">
switch
</button>
</div>
)
}
const wrapper = ({ children }: { children: ReactNode }) => <I18nProvider>{children}</I18nProvider>
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(
<I18nProvider configClient={null}>
<LanguageProbe />
</I18nProvider>
)
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(
<I18nProvider configClient={null} initialLocale="zh-CN">
<LanguageProbe target="en" />
</I18nProvider>
)
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(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
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(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
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(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
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(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
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(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
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')
})
})

View file

@ -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<Locale, Translations> = {
en,
zh
export { LOCALE_META } from './languages'
export interface I18nConfigClient {
getConfig: () => Promise<HermesConfigRecord>
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<Locale, { name: string }> = {
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<string, unknown> {
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<void>
t: Translations
}
const I18nContext = createContext<I18nContextValue>({
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<Locale>(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<Locale>(() => normalizeLocale(initialLocale))
const [isLoadingConfig, setIsLoadingConfig] = useState(false)
const [isSavingLocale, setIsSavingLocale] = useState(false)
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
const [saveError, setSaveError] = useState<Error | null>(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<I18nContextValue>(
() => ({ locale, setLocale, t: TRANSLATIONS[locale] }),
[locale, setLocale]
() => ({
configLoadError,
isLoadingConfig,
isSavingLocale,
locale,
saveError,
setLocale,
t: TRANSLATIONS[locale]
}),
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>

View file

@ -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: {

View file

@ -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'

View file

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

View file

@ -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<Locale, { name: string }> = Object.fromEntries(
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
) as Record<Locale, { name: string }>
const LOCALE_ALIASES: Record<string, Locale> = {
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
}

View file

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

View file

@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolvePath(catalog: Translations, key: string): unknown {
return key.split('.').reduce<unknown>((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
}

View file

@ -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: {

View file

@ -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: {

View file

@ -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,

View file

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

View file

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

View file

@ -466,6 +466,10 @@ export interface ProfileInfo {
skill_count: number
}
export interface ProfileSetupCommand {
command: string
}
export interface ProfileSoul {
content: string
exists: boolean