feat: Add desktop language switching for Japanese and Traditional Chinese

This commit is contained in:
Jim Liu 宝玉 2026-06-05 15:40:18 -05:00 committed by Teknium
parent 2bf0a6e760
commit f18a9dbefc
15 changed files with 871 additions and 82 deletions

View file

@ -1,10 +1,10 @@
import { useStore } from '@nanostores/react'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { LanguageSwitcher } from '@/components/language-switcher'
import { 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'
@ -53,27 +53,11 @@ function ThemePreview({ name }: { name: string }) {
}
export function AppearanceSettings() {
const { t, isSavingLocale, locale, setLocale } = useI18n()
const { t, isSavingLocale } = 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>
@ -86,45 +70,13 @@ export function AppearanceSettings() {
</div>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<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>
<div className="grid gap-2 sm:grid-cols-3">
{locales.map(code => {
const active = locale === code
return (
<button
className={cn(
'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={() => void selectLocale(code)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{LOCALE_META[code].name}
</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
{code}
</div>
</button>
)
})}
<LanguageSwitcher />
</div>
</section>

View file

@ -105,7 +105,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
label={t.settings.nav.providers}
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
@ -113,14 +113,14 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
label={t.settings.nav.providerAccounts}
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
label={t.settings.nav.providerApiKeys}
nested
onClick={() => openProviderView('keys')}
/>
@ -143,14 +143,14 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label="Tools"
label={t.settings.nav.keysTools}
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label="Settings"
label={t.settings.nav.keysSettings}
nested
onClick={() => openKeysView('settings')}
/>

View file

@ -0,0 +1,39 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesConfigRecord } from '@/hermes'
import { type I18nConfigClient, I18nProvider } from '@/i18n'
import { LanguageSwitcher } from './language-switcher'
describe('LanguageSwitcher', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('persists language changes through display.language config', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } }
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue(latestConfig),
saveConfig
}
render(
<I18nProvider configClient={configClient}>
<LanguageSwitcher />
</I18nProvider>
)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Switch language' }).hasAttribute('disabled')).toBe(false)
})
fireEvent.click(screen.getByRole('button', { name: 'Switch language' }))
fireEvent.click(screen.getByRole('option', { name: /日本語/i }))
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ja', skin: 'slate' } })
})
})

View file

@ -0,0 +1,145 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { useIsMobile } from '@/hooks/use-mobile'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, ChevronDown, Globe } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
export interface LanguageSwitcherProps {
className?: string
collapsed?: boolean
dropUp?: boolean
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
disabled?: boolean
label: string
locale: Locale
onSelect: (code: Locale) => void
}
export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { isSavingLocale, locale, setLocale, t } = useI18n()
const [open, setOpen] = useState(false)
const isMobile = useIsMobile()
const useMobileSheet = Boolean(dropUp && isMobile)
const current = LOCALE_META[locale]
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>
const title = t.language.switchTo
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
setOpen(false)
return
}
triggerHaptic('selection')
try {
await setLocale(code)
setOpen(false)
triggerHaptic('success')
} catch (error) {
notifyError(error, t.language.saveError)
}
}
const trigger = (
<Button
aria-label={title}
aria-expanded={open}
className={cn(
'min-w-32 justify-between gap-2 border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 text-left text-muted-foreground hover:text-foreground',
collapsed && 'min-w-0 px-2',
className
)}
disabled={isSavingLocale}
size="sm"
title={title}
type="button"
variant="outline"
>
<span className="inline-flex min-w-0 items-center gap-2">
<Globe className="size-3.5 shrink-0" />
{!collapsed && <span className="truncate">{locale === 'en' ? 'EN' : current.name}</span>}
</span>
{!collapsed && <ChevronDown className="size-3 shrink-0 opacity-70" />}
</Button>
)
if (useMobileSheet) {
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="max-h-[min(28rem,80vh)] rounded-t-xl" side="bottom">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{t.language.description}</SheetDescription>
</SheetHeader>
<ScrollArea className="max-h-80 px-2 pb-3">
<LanguageSwitcherOptions
allLocales={allLocales}
disabled={isSavingLocale}
label={title}
locale={locale}
onSelect={code => void selectLocale(code)}
/>
</ScrollArea>
</SheetContent>
</Sheet>
)
}
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1" side={dropUp ? 'top' : 'bottom'}>
<ScrollArea className="max-h-80">
<LanguageSwitcherOptions
allLocales={allLocales}
disabled={isSavingLocale}
label={title}
locale={locale}
onSelect={code => void selectLocale(code)}
/>
</ScrollArea>
</PopoverContent>
</Popover>
)
}
function LanguageSwitcherOptions({ allLocales, disabled, label, locale, onSelect }: LanguageSwitcherOptionsProps) {
return (
<div aria-label={label} className="py-1" role="listbox">
{allLocales.map(([code, meta]) => {
const selected = code === locale
return (
<button
aria-selected={selected}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:pointer-events-none disabled:opacity-50',
selected ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
disabled={disabled}
key={code}
onClick={() => onSelect(code)}
role="option"
type="button"
>
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
{selected && <Check className="size-3.5 shrink-0 text-primary" />}
</button>
)
})}
</div>
)
}

View file

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

View file

@ -13,6 +13,7 @@ function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
<div>
<p data-testid="locale">{locale}</p>
<p data-testid="label">{t.language.label}</p>
<p data-testid="save">{t.common.save}</p>
<p data-testid="loading">{String(isLoadingConfig)}</p>
<p data-testid="saving">{String(isSavingLocale)}</p>
<p data-testid="save-error">{saveError?.message ?? ''}</p>
@ -94,9 +95,47 @@ describe('I18nProvider', () => {
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('loads zh-hant from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-TW' } }),
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('zh-hant')
expect(screen.getByTestId('save').textContent).toBe('儲存')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('loads ja from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja-JP' } }),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('ja')
expect(screen.getByTestId('save').textContent).toBe('保存')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('does not overwrite unsupported configured languages', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }),
saveConfig: vi.fn()
}
@ -145,6 +184,31 @@ describe('I18nProvider', () => {
})
})
it('saves newly supported locales to display.language', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const configClient: I18nConfigClient = {
getConfig: vi
.fn()
.mockResolvedValueOnce({ display: { language: 'en' } })
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' } }),
saveConfig
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe target="ja" />
</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: 'ja', skin: 'mono' } })
expect(screen.getByTestId('locale').textContent).toBe('ja')
})
it('rolls back the visible locale when saving fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),

View file

@ -0,0 +1,41 @@
import { en } from './en'
import type { Translations } from './types'
type TranslationOverride<T> = T extends (...args: never[]) => string
? T
: T extends readonly unknown[]
? T
: T extends string
? string
: T extends object
? { [K in keyof T]?: TranslationOverride<T[K]> }
: T
export type TranslationOverrides = TranslationOverride<Translations>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function mergeTranslations<T>(base: T, overrides: TranslationOverride<T> | undefined): T {
if (!isRecord(base) || !isRecord(overrides)) {
return (overrides ?? base) as T
}
const result: Record<string, unknown> = { ...base }
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
continue
}
const baseValue = result[key]
result[key] = isRecord(baseValue) && isRecord(value) ? mergeTranslations(baseValue, value) : value
}
return result as T
}
export function defineLocale(overrides: TranslationOverrides): Translations {
return mergeTranslations<Translations>(en, overrides)
}

View file

@ -107,7 +107,8 @@ export const en: Translations = {
label: 'Language',
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
saveError: 'Language update failed'
saveError: 'Language update failed',
switchTo: 'Switch language'
},
settings: {
@ -119,8 +120,13 @@ export const en: Translations = {
exportFailed: 'Export failed',
resetFailed: 'Reset failed',
nav: {
providers: 'Providers',
providerAccounts: 'Accounts',
providerApiKeys: 'API keys',
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
keysTools: 'Tools',
keysSettings: 'Settings',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
@ -521,8 +527,7 @@ export const en: Translations = {
editTitle: 'Edit cron job',
createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
createDesc:
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
createDesc: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name',
namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt',

237
apps/desktop/src/i18n/ja.ts Normal file
View file

@ -0,0 +1,237 @@
import { defineLocale } from './define-locale'
export const ja = defineLocale({
common: {
save: '保存',
saving: '保存中…',
cancel: 'キャンセル',
close: '閉じる',
confirm: '確認',
delete: '削除',
refresh: '更新',
retry: '再試行',
on: 'オン',
off: 'オフ'
},
language: {
label: '言語',
description: 'デスクトップインターフェイスの言語を選択します。',
saving: '言語を保存中…',
saveError: '言語の更新に失敗しました',
switchTo: '言語を切り替え'
},
settings: {
closeSettings: '設定を閉じる',
exportConfig: '設定を書き出す',
importConfig: '設定を読み込む',
resetToDefaults: 'デフォルトに戻す',
resetConfirm: 'すべての設定を Hermes のデフォルトに戻しますか?',
exportFailed: '書き出しに失敗しました',
resetFailed: 'リセットに失敗しました',
nav: {
providers: 'プロバイダー',
providerAccounts: 'アカウント',
providerApiKeys: 'API キー',
gateway: 'ゲートウェイ',
apiKeys: 'ツールとキー',
keysTools: 'ツール',
keysSettings: '設定',
mcp: 'MCP',
archivedChats: 'アーカイブ済みチャット',
about: '情報'
},
sections: {
model: 'モデル',
chat: 'チャット',
appearance: '外観',
workspace: 'ワークスペース',
safety: '安全性',
memory: 'メモリとコンテキスト',
voice: '音声',
advanced: '詳細'
},
searchPlaceholder: {
about: 'Hermes Desktop について',
config: '設定を検索…',
gateway: 'ゲートウェイ接続…',
keys: 'API キーを検索…',
mcp: 'MCP サーバーを検索…',
sessions: 'アーカイブ済みセッションを検索…'
},
modeOptions: {
light: { label: 'ライト', description: '明るいデスクトップ表示' },
dark: { label: 'ダーク', description: 'まぶしさを抑えたワークスペース' },
system: { label: 'システム', description: 'OS の外観に合わせる' }
},
appearance: {
title: '外観',
intro:
'デスクトップ専用の表示設定です。モードは明るさ、テーマはアクセントカラーとチャット面のスタイルを制御します。',
colorMode: 'カラーモード',
colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
toolViewTitle: 'ツール呼び出しの表示',
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
product: 'プロダクト',
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
},
fieldLabels: {
model: 'デフォルトモデル',
model_context_length: 'コンテキストウィンドウ',
fallback_providers: 'フォールバックモデル',
toolsets: '有効なツールセット',
timezone: 'タイムゾーン',
'display.personality': '人格',
'display.show_reasoning': '推論ブロック',
'agent.max_turns': '最大エージェントステップ',
'agent.image_input_mode': '画像添付',
'terminal.cwd': '作業ディレクトリ',
'terminal.backend': '実行バックエンド',
'terminal.timeout': 'コマンドタイムアウト',
'terminal.persistent_shell': '永続シェル',
'terminal.env_passthrough': '環境変数の引き継ぎ',
file_read_max_chars: 'ファイル読み取り上限',
'tool_output.max_bytes': 'ターミナル出力上限',
'tool_output.max_lines': 'ファイルページ上限',
'tool_output.max_line_length': '行長上限',
'code_execution.mode': 'コード実行モード',
'approvals.mode': '承認モード',
'approvals.timeout': '承認タイムアウト',
'approvals.mcp_reload_confirm': 'MCP 再読み込みの確認',
command_allowlist: 'コマンド許可リスト',
'security.redact_secrets': 'シークレットを伏せる',
'security.allow_private_urls': 'プライベート URL を許可',
'browser.allow_private_urls': 'ブラウザーのプライベート URL',
'browser.auto_local_for_private_urls': 'プライベート URL にはローカルブラウザーを使用',
'checkpoints.enabled': 'ファイルチェックポイント',
'checkpoints.max_snapshots': 'チェックポイント上限',
'voice.record_key': '音声ショートカット',
'voice.max_recording_seconds': '最大録音時間',
'voice.auto_tts': '応答を読み上げる',
'stt.enabled': '音声認識',
'stt.provider': '音声認識プロバイダー',
'stt.local.model': 'ローカル文字起こしモデル',
'stt.local.language': '文字起こし言語',
'stt.elevenlabs.model_id': 'ElevenLabs STT モデル',
'stt.elevenlabs.language_code': 'ElevenLabs 言語',
'stt.elevenlabs.tag_audio_events': '音声イベントをタグ付け',
'stt.elevenlabs.diarize': '話者分離',
'tts.provider': '音声合成プロバイダー',
'tts.edge.voice': 'Edge 音声',
'tts.openai.model': 'OpenAI TTS モデル',
'tts.openai.voice': 'OpenAI 音声',
'tts.elevenlabs.voice_id': 'ElevenLabs 音声',
'tts.elevenlabs.model_id': 'ElevenLabs モデル',
'memory.memory_enabled': '永続メモリ',
'memory.user_profile_enabled': 'ユーザープロファイル',
'memory.memory_char_limit': 'メモリ予算',
'memory.user_char_limit': 'プロファイル予算',
'memory.provider': 'メモリプロバイダー',
'context.engine': 'コンテキストエンジン',
'compression.enabled': '自動圧縮',
'compression.threshold': '圧縮しきい値',
'compression.target_ratio': '圧縮目標',
'compression.protect_last_n': '保護する直近メッセージ',
'agent.api_max_retries': 'API 再試行回数',
'agent.service_tier': 'サービス階層',
'agent.tool_use_enforcement': 'ツール使用の強制',
'delegation.model': 'サブエージェントモデル',
'delegation.provider': 'サブエージェントプロバイダー',
'delegation.max_iterations': 'サブエージェントターン上限',
'delegation.max_concurrent_children': '並列サブエージェント',
'delegation.child_timeout_seconds': 'サブエージェントタイムアウト',
'delegation.reasoning_effort': 'サブエージェント推論強度',
'updates.non_interactive_local_changes': 'アプリ内更新時のローカル変更'
},
fieldDescriptions: {
model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
'display.personality': '新しいセッションのデフォルトのアシスタントスタイルです。',
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
'display.show_reasoning': 'バックエンドが推論内容を提供したときに表示します。',
'agent.image_input_mode': '画像添付をモデルへ送る方法を制御します。',
'terminal.cwd': 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
'code_execution.mode': 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。',
'terminal.persistent_shell': 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
'terminal.env_passthrough': 'ツール実行へ渡す環境変数です。',
file_read_max_chars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
'approvals.mode': '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
'approvals.timeout': '承認プロンプトがタイムアウトするまで待つ時間です。',
'security.redact_secrets': '検出したシークレットを、可能な限りモデルから見える内容から隠します。',
'checkpoints.enabled': 'ファイル編集前にロールバック用スナップショットを作成します。',
'memory.memory_enabled': '将来のセッションに役立つ永続メモリを保存します。',
'memory.user_profile_enabled': 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。',
'context.engine': '長い会話がコンテキスト上限に近づいたときの管理戦略です。',
'compression.enabled': '会話が大きくなったとき、古いコンテキストを要約します。',
'voice.auto_tts': 'アシスタントの応答を自動で読み上げます。',
'stt.enabled': 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
'stt.elevenlabs.language_code': '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。',
'agent.max_turns': 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。',
'updates.non_interactive_local_changes':
'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
},
about: {
heading: 'Hermes Desktop',
version: value => `バージョン ${value}`,
versionUnavailable: 'バージョンを取得できません',
updates: '更新',
checkNow: '今すぐ確認',
checking: '確認中…',
seeWhatsNew: '新機能を見る',
releaseNotes: 'リリースノート',
onLatest: '最新バージョンです。',
installing: '更新をインストール中です。',
cantUpdate: 'このビルドはアプリ内から更新できません。',
cantReach: '更新サーバーに接続できませんでした。',
tapCheck: '更新を探すには「今すぐ確認」を押してください。',
updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`,
lastChecked: age => `前回確認: ${age}`,
justNowSuffix: ' · たった今',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes はバックグラウンドで自動的に更新を確認し、利用可能になったら通知します。',
branchCommit: (branch, commit) => `ブランチ ${branch} · コミット ${commit}`,
never: '未確認',
justNow: 'たった今',
minAgo: count => `${count} 分前`,
hoursAgo: count => `${count} 時間前`,
daysAgo: count => `${count} 日前`
}
},
skills: {
all: 'すべて',
noDescription: '説明はありません。'
},
profiles: {
newProfile: '新しいプロファイル',
noProfiles: 'プロファイルが見つかりません。',
skills: count => `${count} スキル`,
defaultBadge: 'デフォルト',
rename: '名前を変更',
saveSoul: 'SOUL を保存',
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
invalidName: hint => `無効なプロファイル名。${hint}`,
nameRequired: '名前は必須です',
created: '作成しました',
renamed: '名前を変更しました',
deleted: '削除しました',
soulSaved: 'SOUL.md を保存しました'
},
cron: {
last: '前回',
next: '次回',
resume: '再開',
pause: '一時停止',
triggerNow: '今すぐ実行',
namePlaceholder: '例: 日次サマリー',
promptPlaceholder: '実行ごとにエージェントが行う内容は?'
}
})

View file

@ -1,12 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LOCALE,
isLocale,
isSupportedLocaleValue,
localeConfigValue,
normalizeLocale
} from './languages'
import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, localeConfigValue, normalizeLocale } from './languages'
describe('desktop i18n languages', () => {
it('normalizes supported locale aliases', () => {
@ -16,23 +10,34 @@ describe('desktop i18n languages', () => {
expect(normalizeLocale('zh-CN')).toBe('zh')
expect(normalizeLocale('zh-Hans')).toBe('zh')
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
expect(normalizeLocale('zh-Hant')).toBe('zh-hant')
expect(normalizeLocale('zh-TW')).toBe('zh-hant')
expect(normalizeLocale('zh_HK')).toBe('zh-hant')
expect(normalizeLocale('ja')).toBe('ja')
expect(normalizeLocale('ja-JP')).toBe('ja')
})
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)
expect(normalizeLocale('de')).toBe(DEFAULT_LOCALE)
})
it('distinguishes exact locale ids from supported config aliases', () => {
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
expect(isSupportedLocaleValue('ja')).toBe(false)
expect(isSupportedLocaleValue('zh-TW')).toBe(true)
expect(isSupportedLocaleValue('ja-JP')).toBe(true)
expect(isSupportedLocaleValue('de')).toBe(false)
expect(isLocale('zh-CN')).toBe(false)
expect(isLocale('zh')).toBe(true)
expect(isLocale('zh-hant')).toBe(true)
expect(isLocale('ja')).toBe(true)
})
it('returns the persisted config value for supported locales', () => {
expect(localeConfigValue('en')).toBe('en')
expect(localeConfigValue('zh')).toBe('zh')
expect(localeConfigValue('zh-hant')).toBe('zh-hant')
expect(localeConfigValue('ja')).toBe('ja')
})
})

View file

@ -12,6 +12,16 @@ export const LOCALE_OPTIONS = [
id: 'zh',
name: '简体中文',
configValue: 'zh'
},
{
id: 'zh-hant',
name: '繁體中文',
configValue: 'zh-hant'
},
{
id: 'ja',
name: '日本語',
configValue: 'ja'
}
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
@ -32,7 +42,22 @@ const LOCALE_ALIASES: Record<string, Locale> = {
'zh-hans': 'zh',
zh_hans: 'zh',
'zh-hans-cn': 'zh',
zh_hans_cn: 'zh'
zh_hans_cn: 'zh',
'zh-tw': 'zh-hant',
zh_tw: 'zh-hant',
'zh-hk': 'zh-hant',
zh_hk: 'zh-hant',
'zh-mo': 'zh-hant',
zh_mo: 'zh-hant',
'zh-hant': 'zh-hant',
zh_hant: 'zh-hant',
'zh-hant-tw': 'zh-hant',
zh_hant_tw: 'zh-hant',
'zh-hant-hk': 'zh-hant',
zh_hant_hk: 'zh-hant',
ja: 'ja',
'ja-jp': 'ja',
ja_jp: 'ja'
}
export function isLocale(value: unknown): value is Locale {

View file

@ -21,6 +21,30 @@ describe('desktop i18n runtime translator', () => {
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
})
it('translates migrated overlap keys for newly supported locales', () => {
setRuntimeI18nLocale('ja')
expect(translateNow('common.save')).toBe('保存')
setRuntimeI18nLocale('zh-hant')
expect(translateNow('cron.promptPlaceholder')).toBe('代理每次執行時應做什麼?')
})
it('translates settings copy for newly supported locales', () => {
setRuntimeI18nLocale('ja')
expect(translateNow('settings.appearance.title')).toBe('外観')
expect(translateNow('settings.nav.providers')).toBe('プロバイダー')
setRuntimeI18nLocale('zh-hant')
expect(translateNow('settings.appearance.title')).toBe('外觀')
expect(translateNow('settings.nav.providerApiKeys')).toBe('API 金鑰')
})
it('falls back to English for untranslated desktop-only keys in partial locales', () => {
setRuntimeI18nLocale('ja')
expect(translateNow('boot.ready')).toBe('Hermes Desktop is ready')
})
it('returns the key when no locale can resolve a path', () => {
setRuntimeI18nLocale('zh')

View file

@ -1,11 +1,11 @@
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
// so a missing key is a compile error — that's the completeness guard for
// "full" coverage as more surfaces are migrated off hardcoded English.
// surface. Fully translated locale files may satisfy this interface directly;
// partial locales should use `defineLocale()` so missing desktop-only strings
// fall back to English while new keys remain type-checked.
export type Locale = 'en' | 'zh'
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja'
interface ModeOptionCopy {
label: string
@ -114,6 +114,7 @@ export interface Translations {
description: string
saving: string
saveError: string
switchTo: string
}
settings: {
@ -125,8 +126,13 @@ export interface Translations {
exportFailed: string
resetFailed: string
nav: {
providers: string
providerAccounts: string
providerApiKeys: string
gateway: string
apiKeys: string
keysTools: string
keysSettings: string
mcp: string
archivedChats: string
about: string

View file

@ -0,0 +1,236 @@
import { defineLocale } from './define-locale'
export const zhHant = defineLocale({
common: {
save: '儲存',
saving: '儲存中…',
cancel: '取消',
close: '關閉',
confirm: '確認',
delete: '刪除',
refresh: '重新整理',
retry: '重試',
on: '開啟',
off: '關閉'
},
language: {
label: '語言',
description: '選擇桌面介面的語言。',
saving: '正在儲存語言…',
saveError: '語言更新失敗',
switchTo: '切換語言'
},
settings: {
closeSettings: '關閉設定',
exportConfig: '匯出設定',
importConfig: '匯入設定',
resetToDefaults: '恢復預設值',
resetConfirm: '要將所有設定恢復為 Hermes 預設值嗎?',
exportFailed: '匯出失敗',
resetFailed: '重設失敗',
nav: {
providers: '提供方',
providerAccounts: '帳號',
providerApiKeys: 'API 金鑰',
gateway: '閘道',
apiKeys: '工具與金鑰',
keysTools: '工具',
keysSettings: '設定',
mcp: 'MCP',
archivedChats: '已封存聊天',
about: '關於'
},
sections: {
model: '模型',
chat: '聊天',
appearance: '外觀',
workspace: '工作區',
safety: '安全性',
memory: '記憶與上下文',
voice: '語音',
advanced: '進階'
},
searchPlaceholder: {
about: '關於 Hermes Desktop',
config: '搜尋設定…',
gateway: '閘道連線…',
keys: '搜尋 API 金鑰…',
mcp: '搜尋 MCP 伺服器…',
sessions: '搜尋已封存工作階段…'
},
modeOptions: {
light: { label: '明亮', description: '明亮的桌面介面' },
dark: { label: '深色', description: '降低眩光的工作區' },
system: { label: '跟隨系統', description: '跟隨作業系統外觀' }
},
appearance: {
title: '外觀',
intro: '這些是僅限桌面端的顯示偏好。模式控制亮度;主題控制強調色與聊天介面樣式。',
colorMode: '色彩模式',
colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
toolViewTitle: '工具呼叫顯示',
toolViewDesc: '產品模式會隱藏原始工具 payload技術模式會顯示完整輸入/輸出。',
product: '產品',
productDesc: '易讀的工具活動與精簡摘要。',
technical: '技術',
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
},
fieldLabels: {
model: '預設模型',
model_context_length: '上下文視窗',
fallback_providers: '備用模型',
toolsets: '已啟用工具集',
timezone: '時區',
'display.personality': '人格',
'display.show_reasoning': '推理區塊',
'agent.max_turns': '最大代理步數',
'agent.image_input_mode': '圖片附件',
'terminal.cwd': '工作目錄',
'terminal.backend': '執行後端',
'terminal.timeout': '指令逾時',
'terminal.persistent_shell': '持久化 Shell',
'terminal.env_passthrough': '環境變數傳遞',
file_read_max_chars: '檔案讀取上限',
'tool_output.max_bytes': '終端機輸出上限',
'tool_output.max_lines': '檔案頁面上限',
'tool_output.max_line_length': '行長上限',
'code_execution.mode': '程式碼執行模式',
'approvals.mode': '批准模式',
'approvals.timeout': '批准逾時',
'approvals.mcp_reload_confirm': '確認 MCP 重新載入',
command_allowlist: '指令允許清單',
'security.redact_secrets': '遮蔽密鑰',
'security.allow_private_urls': '允許私有 URL',
'browser.allow_private_urls': '瀏覽器私有 URL',
'browser.auto_local_for_private_urls': '私有 URL 使用本機瀏覽器',
'checkpoints.enabled': '檔案檢查點',
'checkpoints.max_snapshots': '檢查點上限',
'voice.record_key': '語音快捷鍵',
'voice.max_recording_seconds': '最長錄音時間',
'voice.auto_tts': '朗讀回覆',
'stt.enabled': '語音轉文字',
'stt.provider': '語音轉文字提供方',
'stt.local.model': '本機轉寫模型',
'stt.local.language': '轉寫語言',
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
'stt.elevenlabs.language_code': 'ElevenLabs 語言',
'stt.elevenlabs.tag_audio_events': '標記音訊事件',
'stt.elevenlabs.diarize': '說話者分離',
'tts.provider': '文字轉語音提供方',
'tts.edge.voice': 'Edge 語音',
'tts.openai.model': 'OpenAI TTS 模型',
'tts.openai.voice': 'OpenAI 語音',
'tts.elevenlabs.voice_id': 'ElevenLabs 語音',
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
'memory.memory_enabled': '持久記憶',
'memory.user_profile_enabled': '使用者設定檔',
'memory.memory_char_limit': '記憶預算',
'memory.user_char_limit': '設定檔預算',
'memory.provider': '記憶提供方',
'context.engine': '上下文引擎',
'compression.enabled': '自動壓縮',
'compression.threshold': '壓縮閾值',
'compression.target_ratio': '壓縮目標',
'compression.protect_last_n': '保護最近訊息',
'agent.api_max_retries': 'API 重試次數',
'agent.service_tier': '服務層級',
'agent.tool_use_enforcement': '工具使用強制',
'delegation.model': '子代理模型',
'delegation.provider': '子代理提供方',
'delegation.max_iterations': '子代理輪次上限',
'delegation.max_concurrent_children': '平行子代理',
'delegation.child_timeout_seconds': '子代理逾時',
'delegation.reasoning_effort': '子代理推理強度',
'updates.non_interactive_local_changes': '應用程式內更新的本機變更'
},
fieldDescriptions: {
model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
'display.personality': '新工作階段的預設助手風格。',
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
'display.show_reasoning': '後端提供推理內容時顯示該區塊。',
'agent.image_input_mode': '控制圖片附件如何傳送給模型。',
'terminal.cwd': '工具與終端機操作的預設專案資料夾。',
'code_execution.mode': '程式碼執行被限制在目前專案中的嚴格程度。',
'terminal.persistent_shell': '後端支援時,在指令之間保留 Shell 狀態。',
'terminal.env_passthrough': '傳入工具執行的環境變數。',
file_read_max_chars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
'approvals.mode': 'Hermes 如何處理需要明確批准的指令。',
'approvals.timeout': '批准提示逾時前等待的時間。',
'security.redact_secrets': '盡可能從模型可見內容中隱藏偵測到的密鑰。',
'checkpoints.enabled': '在檔案編輯前建立可回復的快照。',
'memory.memory_enabled': '儲存有助於未來工作階段的持久記憶。',
'memory.user_profile_enabled': '維護一份精簡的使用者偏好設定檔。',
'context.engine': '長對話接近上下文上限時的管理策略。',
'compression.enabled': '對話變大時摘要較早的上下文。',
'voice.auto_tts': '自動朗讀助手回覆。',
'stt.enabled': '啟用本機或提供方支援的語音轉寫。',
'stt.elevenlabs.language_code': '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。',
'agent.max_turns': 'Hermes 停止一次執行前的工具呼叫輪次上限。',
'updates.non_interactive_local_changes':
'Hermes 從應用程式內更新自身時保留本機原始碼變更stash或丟棄discard。終端機更新一律會詢問。'
},
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
updates: '更新',
checkNow: '立即檢查',
checking: '檢查中…',
seeWhatsNew: '查看新增內容',
releaseNotes: '發行說明',
onLatest: '你已是最新版本。',
installing: '正在安裝更新。',
cantUpdate: '此版本無法從應用程式內自行更新。',
cantReach: '無法連線到更新伺服器。',
tapCheck: '點選「立即檢查」以尋找更新。',
updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`,
lastChecked: age => `上次檢查:${age}`,
justNowSuffix: ' · 剛剛',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes 會在背景自動檢查更新,並在有可用更新時通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '從未',
justNow: '剛剛',
minAgo: count => `${count} 分鐘前`,
hoursAgo: count => `${count} 小時前`,
daysAgo: count => `${count} 天前`
}
},
skills: {
all: '全部',
noDescription: '無可用描述。'
},
profiles: {
newProfile: '新增設定檔',
noProfiles: '找不到設定檔。',
skills: count => `${count} 個技能`,
defaultBadge: '預設',
rename: '重新命名',
saveSoul: '儲存 SOUL',
cloneFromDefault: '從預設設定檔複製設定',
invalidName: hint => `設定檔名稱無效。${hint}`,
nameRequired: '名稱為必填',
created: '已建立',
renamed: '已重新命名',
deleted: '已刪除',
soulSaved: 'SOUL.md 已儲存'
},
cron: {
last: '上次',
next: '下次',
resume: '繼續',
pause: '暫停',
triggerNow: '立即觸發',
namePlaceholder: '例如:每日摘要',
promptPlaceholder: '代理每次執行時應做什麼?'
}
})

View file

@ -101,7 +101,8 @@ export const zh: Translations = {
label: '语言',
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
saveError: '语言更新失败'
saveError: '语言更新失败',
switchTo: '切换语言'
},
settings: {
@ -113,8 +114,13 @@ export const zh: Translations = {
exportFailed: '导出失败',
resetFailed: '重置失败',
nav: {
providers: '提供方',
providerAccounts: '账号',
providerApiKeys: 'API 密钥',
gateway: '网关',
apiKeys: '工具与密钥',
keysTools: '工具',
keysSettings: '设置',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
@ -478,8 +484,7 @@ export const zh: Translations = {
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
discord:
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
@ -495,7 +500,8 @@ export const zh: Translations = {
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
api_server:
'把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},