mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): persist i18n language in config
This commit is contained in:
parent
4a1907bd10
commit
1d9c3ebae0
21 changed files with 836 additions and 141 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
8
apps/desktop/src/i18n/catalog.ts
Normal file
8
apps/desktop/src/i18n/catalog.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
38
apps/desktop/src/i18n/languages.test.ts
Normal file
38
apps/desktop/src/i18n/languages.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
56
apps/desktop/src/i18n/languages.ts
Normal file
56
apps/desktop/src/i18n/languages.ts
Normal 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
|
||||
}
|
||||
29
apps/desktop/src/i18n/runtime.test.ts
Normal file
29
apps/desktop/src/i18n/runtime.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
53
apps/desktop/src/i18n/runtime.ts
Normal file
53
apps/desktop/src/i18n/runtime.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -466,6 +466,10 @@ export interface ProfileInfo {
|
|||
skill_count: number
|
||||
}
|
||||
|
||||
export interface ProfileSetupCommand {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue