feat(desktop): persist i18n language in config
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user