From 29235cd6d27b5d16971ece92618311e164be1346 Mon Sep 17 00:00:00 2001 From: acumen7 Date: Wed, 3 Jun 2026 18:05:31 +0800 Subject: [PATCH] feat(desktop): add i18n infrastructure with Chinese (zh-CN) support - Add react-i18next + i18next + i18next-browser-languagedetector deps - Create i18n init with system-language auto-detect, fallback to en - Create comprehensive zh-CN translation file (~150 strings) - Wire i18n into main.tsx Migrate overlays to use react-i18next translations: - boot-failure-overlay.tsx (all strings) - gateway-connecting-overlay.tsx (animated CONNECTING text) - updates-overlay.tsx (all strings, stage labels via i18n.t()) - desktop-install-overlay.tsx (all strings, stage states, progress) - desktop-onboarding-overlay.tsx (~75 strings across sub-components) --- apps/desktop/package.json | 3 + apps/desktop/src/app/updates-overlay.tsx | 82 ++++--- .../assistant-ui/thread-virtualizer.tsx | 5 + .../src/components/assistant-ui/thread.tsx | 2 +- .../src/components/boot-failure-overlay.tsx | 19 +- .../components/desktop-install-overlay.tsx | 163 ++++++++----- .../desktop-onboarding-overlay.test.tsx | 3 +- .../components/desktop-onboarding-overlay.tsx | 179 ++++++++------ .../components/gateway-connecting-overlay.tsx | 16 +- apps/desktop/src/components/model-picker.tsx | 1 + apps/desktop/src/i18n/index.ts | 73 ++++++ .../src/i18n/locales/zh-CN/translation.json | 227 ++++++++++++++++++ apps/desktop/src/main.tsx | 1 + 13 files changed, 577 insertions(+), 197 deletions(-) create mode 100644 apps/desktop/src/i18n/index.ts create mode 100644 apps/desktop/src/i18n/locales/zh-CN/translation.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 15bb812b4594..459cc352fd25 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -71,6 +71,8 @@ "cmdk": "^1.1.1", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "ignore": "^7.0.5", "katex": "^0.16.45", "leva": "^0.10.1", @@ -81,6 +83,7 @@ "react": "^19.2.5", "react-arborist": "^3.5.0", "react-dom": "^19.2.5", + "react-i18next": "^17.0.8", "react-router-dom": "^7.14.2", "react-shiki": "^0.9.3", "remark-math": "^6.0.0", diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 39cbe97c1339..db80922424d2 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -1,10 +1,12 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { writeClipboardText } from '@/components/ui/copy-button' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' +import i18n from '@/i18n' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -21,14 +23,14 @@ import { } from '@/store/updates' const STAGE_LABELS: Record = { - idle: 'Getting ready…', - prepare: 'Getting ready…', - fetch: 'Downloading…', - pull: 'Almost there…', - pydeps: 'Finishing up…', - restart: 'Restarting Hermes…', - manual: 'Update from your terminal', - error: 'Update paused' + idle: i18n.t('updates.stage.idle'), + prepare: i18n.t('updates.stage.prepare'), + fetch: i18n.t('updates.stage.fetch'), + pull: i18n.t('updates.stage.pull'), + pydeps: i18n.t('updates.stage.pydeps'), + restart: i18n.t('updates.stage.restart'), + manual: i18n.t('updates.stage.manual'), + error: i18n.t('updates.stage.error') } function totalItems(groups: readonly CommitGroup[]) { @@ -36,6 +38,7 @@ function totalItems(groups: readonly CommitGroup[]) { } export function UpdatesOverlay() { + const { t } = useTranslation() const open = useStore($updateOverlayOpen) const status = useStore($updateStatus) const checking = useStore($updateChecking) @@ -123,9 +126,11 @@ function IdleView({ onRetryCheck: () => void status: DesktopUpdateStatus | null }) { + const { t } = useTranslation() + if (!status && checking) { return ( - } title="Looking for updates…" /> + } title={t('updates.checking.title')} /> ) } @@ -134,11 +139,11 @@ function IdleView({ - Try again + {t('updates.checking.retry')} } icon={} - title="Couldn’t check for updates" + title={t('updates.checking.error_title')} /> ) } @@ -148,12 +153,12 @@ function IdleView({ - Close + {t('updates.status.close')} } - body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'} + body={status.message ?? t('updates.status.not_supported')} icon={} - title="Update not available" + title={t('updates.status.not_available')} /> ) } @@ -163,12 +168,12 @@ function IdleView({ - Try again + {t('updates.checking.retry')} } - body="Check your connection and try again." + body={t('updates.status.check_connection')} icon={} - title="Couldn’t check for updates" + title={t('updates.checking.error_title')} /> ) } @@ -178,12 +183,12 @@ function IdleView({ - Close + {t('updates.status.close')} } - body="You’re running the latest version." + body={t('updates.status.up_to_date')} icon={} - title="You’re all set" + title={t('updates.status.all_set')} /> ) } @@ -199,9 +204,9 @@ function IdleView({ - New update available + {t('updates.available.title')} - A new version of Hermes is ready to install. + {t('updates.available.desc')} @@ -223,20 +228,20 @@ function IdleView({
{remaining > 0 && (

- + {remaining} more change{remaining === 1 ? '' : 's'} included. + {t('updates.available.more_changes', { count: remaining })}

)} @@ -244,6 +249,7 @@ function IdleView({ } function ManualView({ command, onDone }: { command: string; onDone: () => void }) { + const { t } = useTranslation() const [copied, setCopied] = useState(false) const handleCopy = () => { @@ -260,16 +266,16 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void } - Update from your terminal + {t('updates.manual.title')} - You installed Hermes from the command line, so updates run there too. Paste this into your terminal: + {t('updates.manual.desc')}

- Hermes will pick up the new version next time you launch it. + {t('updates.manual.footnote')}

) @@ -338,6 +344,8 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) { } function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) { + const { t } = useTranslation() + return (
@@ -345,22 +353,22 @@ function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss - Update didn’t finish + {t('updates.error.title')} - {message || 'No worries — nothing was lost. You can try again now.'} + {message || t('updates.error.default_msg')}
diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 2e6bbaf8ff7b..46f6b8dce02a 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -308,13 +308,16 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v } let pinRafScheduled = false + const schedulePin = () => { if (pinRafScheduled || !armedRef.current) { return } + pinRafScheduled = true requestAnimationFrame(() => { pinRafScheduled = false + if (armedRef.current) { pinToBottom() } @@ -367,6 +370,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v if (!enabled) { return } + if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) { pinToBottom() requestAnimationFrame(() => { @@ -375,6 +379,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v } }) } + prevGroupCountForLayoutRef.current = groupCount }, [enabled, groupCount, pinToBottom]) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 1576ab9b52aa..ac9f78e15ff5 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -49,13 +49,13 @@ import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent } from '@/components/assistant-ui/directive-text' -import { UserMessageText } from '@/components/assistant-ui/user-message-text' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer' import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' +import { UserMessageText } from '@/components/assistant-ui/user-message-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 943981302580..afe026dc75ca 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' @@ -13,6 +14,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | null // renders dead — "gateway offline", no composer, only a toast — with no way // to retry, repair the install, switch the gateway, or find the logs. export function BootFailureOverlay() { + const { t } = useTranslation() const boot = useStore($desktopBoot) const onboarding = useStore($desktopOnboarding) const [busy, setBusy] = useState(null) @@ -69,10 +71,9 @@ export function BootFailureOverlay() {
-

Hermes couldn't start

+

{t('boot_failure.title')}

- The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your - chats or settings. + {t('boot_failure.desc')}

@@ -86,23 +87,23 @@ export function BootFailureOverlay() {

- Repair re-runs the installer and can take a few minutes on a fresh machine. + {t('boot_failure.repair_hint')}

@@ -113,7 +114,7 @@ export function BootFailureOverlay() { onClick={() => setShowLogs(v => !v)} type="button" > - {showLogs ? 'Hide' : 'Show'} recent logs + {showLogs ? t('boot_failure.hide_logs') : t('boot_failure.show_logs')} {showLogs ? (
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx
index 16ccc6ad3ab8..921d2bb8c769 100644
--- a/apps/desktop/src/components/desktop-install-overlay.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.tsx
@@ -1,8 +1,7 @@
 import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
 
 import { Button } from '@/components/ui/button'
-import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
-import { cn } from '@/lib/utils'
 import type {
   DesktopBootstrapEvent,
   DesktopBootstrapStageDescriptor,
@@ -10,6 +9,8 @@ import type {
   DesktopBootstrapStageState,
   DesktopBootstrapState
 } from '@/global'
+import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
+import { cn } from '@/lib/utils'
 
 /**
  * DesktopInstallOverlay
@@ -50,16 +51,17 @@ interface StageRowProps {
 }
 
 const STATE_LABEL: Record = {
-  pending: 'Pending',
-  running: 'Installing',
-  succeeded: 'Done',
-  skipped: 'Skipped',
-  failed: 'Failed'
+  pending: 'install.stage_pending',
+  running: 'install.stage_installing',
+  succeeded: 'install.stage_done',
+  skipped: 'install.stage_skipped',
+  failed: 'install.stage_failed'
 }
 
 function formatStageName(name: string): string {
   // 'system-packages' -> 'System packages'; 'uv' stays 'uv'
-  if (name.length <= 3) return name
+  if (name.length <= 3) {return name}
+
   return name
     .split('-')
     .map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
@@ -67,38 +69,51 @@ function formatStageName(name: string): string {
 }
 
 function formatDuration(ms: number | null | undefined): string {
-  if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
-  if (ms < 1000) return `${ms} ms`
+  if (typeof ms !== 'number' || !Number.isFinite(ms)) {return ''}
+
+  if (ms < 1000) {return `${ms} ms`}
   const s = ms / 1000
-  if (s < 60) return `${s.toFixed(1)}s`
+
+  if (s < 60) {return `${s.toFixed(1)}s`}
   const m = Math.floor(s / 60)
   const rs = Math.round(s - m * 60)
+
   return `${m}m ${rs}s`
 }
 
 // Live elapsed for a running stage, as m:ss (or s for sub-minute).
 function formatElapsed(ms: number): string {
   const s = Math.max(0, Math.floor(ms / 1000))
-  if (s < 60) return `${s}s`
+
+  if (s < 60) {return `${s}s`}
   const m = Math.floor(s / 60)
+
   return `${m}:${String(s - m * 60).padStart(2, '0')}`
 }
 
 function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
+  const { t } = useTranslation()
   const state: DesktopBootstrapStageState = result?.state || 'pending'
+
   const elapsed =
     state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : ''
+
   const icon = useMemo(() => {
     switch (state) {
       case 'running':
         return 
+
       case 'succeeded':
         return 
+
       case 'skipped':
         return 
+
       case 'failed':
         return 
+
       case 'pending':
+
       default:
         return 
} @@ -121,9 +136,9 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { {formatStageName(descriptor.name)} - {state === 'running' ? (elapsed ? `${STATE_LABEL[state]} · ${elapsed}` : STATE_LABEL[state]) : null} + {state === 'running' ? (elapsed ? `${t(STATE_LABEL[state])} · ${elapsed}` : t(STATE_LABEL[state])) : null} {state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null} - {state === 'failed' ? STATE_LABEL[state] : null} + {state === 'failed' ? t(STATE_LABEL[state]) : null}
{reason && state !== 'pending' &&

{reason}

} @@ -146,9 +161,11 @@ const EMPTY_STATE: DesktopBootstrapState = { function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState { if (ev.type === 'manifest') { const stages: Record = {} + for (const stage of ev.stages) { stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null } } + return { ...state, active: true, @@ -158,8 +175,10 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De startedAt: state.startedAt || Date.now() } } + if (ev.type === 'stage') { const prev = state.stages[ev.name] + return { ...state, stages: { @@ -176,17 +195,23 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De } } } + if (ev.type === 'log') { const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line }) - while (next.length > 500) next.shift() + + while (next.length > 500) {next.shift()} + return { ...state, log: next } } + if (ev.type === 'complete') { return { ...state, active: false, completedAt: Date.now(), error: null } } + if (ev.type === 'failed') { return { ...state, active: false, error: ev.error || 'unknown error' } } + if (ev.type === 'unsupported-platform') { return { ...state, @@ -199,10 +224,12 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De } } } + return state } export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) { + const { t } = useTranslation() const [state, setState] = useState(EMPTY_STATE) const [logOpen, setLogOpen] = useState(false) const [copied, setCopied] = useState(false) @@ -213,23 +240,25 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP // Tick once a second while a bootstrap is in flight so running steps show a // live elapsed timer. Stops when nothing is active to avoid idle renders. useEffect(() => { - if (!state.active) return + if (!state.active) {return} const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) }, [state.active]) // Subscribe to bootstrap events + load initial snapshot useEffect(() => { - if (!enabled) return + if (!enabled) {return} const desktop = window.hermesDesktop - if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return + + if (!desktop || typeof desktop.onBootstrapEvent !== 'function') {return} let cancelled = false desktop .getBootstrapState() .then(snapshot => { - if (!cancelled && snapshot) setState(snapshot) + if (!cancelled && snapshot) {setState(snapshot)} }) .catch(() => { // Older Electron build without the IPC handler -- bootstrap UI just @@ -237,6 +266,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP }) const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev))) + return () => { cancelled = true off?.() @@ -255,21 +285,25 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP // the top-level error message and the user has to click "Show installer // output" to see WHY the stage failed. useEffect(() => { - if (state.error) setLogOpen(true) + if (state.error) {setLogOpen(true)} }, [state.error]) // Mount logic: show whenever a bootstrap is in flight, completed-with-error, // or actively running with a manifest. Hide entirely after a successful // completion so the rest of the UI can take over. const shouldShow = useMemo(() => { - if (!enabled) return false - if (state.active) return true - if (state.error) return true - if (state.unsupportedPlatform) return true + if (!enabled) {return false} + + if (state.active) {return true} + + if (state.error) {return true} + + if (state.unsupportedPlatform) {return true} + return false }, [enabled, state.active, state.error, state.unsupportedPlatform]) - if (!shouldShow) return null + if (!shouldShow) {return null} // Unsupported-platform branch: macOS/Linux packaged builds hit this when // there's no Hermes Agent installed yet and we can't drive install.sh @@ -278,48 +312,48 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP if (state.unsupportedPlatform) { const ups = state.unsupportedPlatform const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform + return (
-

Hermes needs a one-time install

+

{t('install.unsupported.title')}

- Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the - command below, then relaunch this app. Subsequent launches will skip this step. + {t('install.unsupported.desc', { platform: platformLabel })}

-
Install command
+
{t('install.unsupported.command_label')}
               {ups.installCommand}
             
- Will install to {ups.activeRoot} + {t('install.unsupported.will_install_to', { path: ups.activeRoot })} -
@@ -329,9 +363,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP const stages = state.manifest?.stages || [] const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name + const completedCount = stages.filter( s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped' ).length + const totalCount = stages.length const failed = Boolean(state.error) const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 @@ -344,13 +380,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP {/* Header -- always visible, never scrolls */}

- {failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'} + {failed ? t('install.header.failed') : state.active ? t('install.header.active') : t('install.header.completed')}

{failed - ? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.' - : 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' + - 'Subsequent launches will skip this step.'} + ? t('install.header.failed_desc') + : t('install.header.active_desc')}

@@ -360,8 +395,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
- {completedCount} of {totalCount} steps complete - {currentStage && ` -- now: ${formatStageName(currentStage)}`} + {t('install.progress.count', { count: completedCount, total: totalCount })} + {currentStage && ` ${t('install.progress.current', { stageName: formatStageName(currentStage) })}`} {currentElapsed && ` (${currentElapsed})`} {progressPct}% @@ -378,7 +413,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP {totalCount === 0 && state.active && (
- Fetching installer manifest... + {t('install.progress.fetching')}
)} @@ -386,7 +421,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
- Error + {t('install.error')}

{state.error}

@@ -396,11 +431,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
    {stages.map(stage => ( ))}
@@ -408,14 +443,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
@@ -427,11 +462,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP )} > {state.log.length === 0 ? ( -
No output yet.
+
{t('install.log.empty')}
) : ( <> {state.log.map((entry, i) => ( -
+
{entry.stage ? [{entry.stage}] : null} {entry.line}
@@ -463,7 +498,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP variant="ghost" > {cancelling ? : null} - {cancelling ? 'Cancelling...' : 'Cancel install'} + {cancelling ? t('install.footer.cancelling') : t('install.footer.cancel')}
@@ -474,18 +509,17 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
- Full transcript saved to{' '} - %LOCALAPPDATA%\hermes\logs\ + {t('install.footer.saved_to', { path: '%LOCALAPPDATA%\\hermes\\logs\\' })}
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx index 379642c9973f..2ee12b8ef4eb 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -1,9 +1,8 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import type { OAuthProvider } from '@/types/hermes' - import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding' +import type { OAuthProvider } from '@/types/hermes' import { Picker } from './desktop-onboarding-overlay' diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 7d091ee59b3c..6bbfb02e4e55 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -1,11 +1,13 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { ModelPickerDialog } from '@/components/model-picker' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { getGlobalModelOptions } from '@/hermes' +import i18n from '@/i18n' import { Check, ChevronDown, @@ -61,64 +63,64 @@ const MIN_KEY_LENGTH = 8 const API_KEY_OPTIONS: ApiKeyOption[] = [ { id: 'openrouter', - name: 'OpenRouter', - short: 'one key, many models', + name: i18n.t('onboarding.providers.openrouter.name'), + short: i18n.t('onboarding.providers.openrouter.short'), envKey: 'OPENROUTER_API_KEY', - description: 'Hosts hundreds of models behind a single key. Good default for new installs.', + description: i18n.t('onboarding.providers.openrouter.desc'), docsUrl: 'https://openrouter.ai/keys' }, { id: 'openai', - name: 'OpenAI', - short: 'GPT-class models', + name: i18n.t('onboarding.providers.openai.name'), + short: i18n.t('onboarding.providers.openai.short'), envKey: 'OPENAI_API_KEY', - description: 'Direct access to OpenAI models.', + description: i18n.t('onboarding.providers.openai.desc'), docsUrl: 'https://platform.openai.com/api-keys' }, { id: 'gemini', - name: 'Google Gemini', - short: 'Gemini models', + name: i18n.t('onboarding.providers.gemini.name'), + short: i18n.t('onboarding.providers.gemini.short'), envKey: 'GEMINI_API_KEY', - description: 'Direct access to Google Gemini models.', + description: i18n.t('onboarding.providers.gemini.desc'), docsUrl: 'https://aistudio.google.com/app/apikey' }, { id: 'xai', - name: 'xAI Grok', - short: 'Grok models', + name: i18n.t('onboarding.providers.xai.name'), + short: i18n.t('onboarding.providers.xai.short'), envKey: 'XAI_API_KEY', - description: 'Direct access to xAI Grok models.', + description: i18n.t('onboarding.providers.xai.desc'), docsUrl: 'https://console.x.ai/' }, { id: 'local', - name: 'Local / custom endpoint', - short: 'self-hosted', + name: i18n.t('onboarding.providers.local.name'), + short: i18n.t('onboarding.providers.local.short'), envKey: 'OPENAI_BASE_URL', - description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).', + description: i18n.t('onboarding.providers.local.desc'), docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint', placeholder: 'http://127.0.0.1:8000/v1' } ] const PROVIDER_DISPLAY: Record = { - nous: { order: 0, title: 'Nous Portal' }, - anthropic: { order: 1, title: 'Anthropic Claude' }, - 'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' }, - 'minimax-oauth': { order: 3, title: 'MiniMax' }, - 'xai-oauth': { order: 4, title: 'xAI Grok' }, - 'claude-code': { order: 5, title: 'Claude Code' }, - 'qwen-oauth': { order: 6, title: 'Qwen Code' } + nous: { order: 0, title: i18n.t('onboarding.oauth_providers.nous') }, + anthropic: { order: 1, title: i18n.t('onboarding.oauth_providers.anthropic') }, + 'openai-codex': { order: 2, title: i18n.t('onboarding.oauth_providers.openai-codex') }, + 'minimax-oauth': { order: 3, title: i18n.t('onboarding.oauth_providers.minimax-oauth') }, + 'xai-oauth': { order: 4, title: i18n.t('onboarding.oauth_providers.xai-oauth') }, + 'claude-code': { order: 5, title: i18n.t('onboarding.oauth_providers.claude-code') }, + 'qwen-oauth': { order: 6, title: i18n.t('onboarding.oauth_providers.qwen-oauth') } } const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` const FLOW_SUBTITLES: Record = { - pkce: 'Opens your browser to sign in, then continues here', - device_code: 'Opens a verification page in your browser — Hermes connects automatically', - loopback: 'Opens your browser to sign in — Hermes connects automatically', - external: 'Sign in once in your terminal, then come back to chat' + pkce: i18n.t('onboarding.flow_subtitles.pkce'), + device_code: i18n.t('onboarding.flow_subtitles.device_code'), + loopback: i18n.t('onboarding.flow_subtitles.loopback'), + external: i18n.t('onboarding.flow_subtitles.external') } const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name @@ -128,6 +130,7 @@ const sortProviders = (providers: OAuthProvider[]) => [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name)) export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) { + const { t } = useTranslation() const onboarding = useStore($desktopOnboarding) const boot = useStore($desktopBoot) const ctxRef = useRef({ requestGateway, onCompleted }) @@ -177,7 +180,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway onClick={() => closeManualOnboarding()} type="button" > - Close + {t('onboarding.close')}
) : null} @@ -198,6 +201,7 @@ function ReasonNotice({ reason }: { reason: string }) { } function Preparing({ boot }: { boot: DesktopBootState }) { + const { t } = useTranslation() const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) const hasError = Boolean(boot.error) const installing = boot.phase.startsWith('runtime.') @@ -206,8 +210,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {

{installing - ? 'Hermes is finishing install. This usually takes under a minute on first run.' - : 'Starting Hermes…'} + ? t('onboarding.preparing.installing') + : t('onboarding.preparing.starting')}

@@ -235,9 +241,9 @@ function Header() {
-

Let's get you setup with Hermes Agent

+

{t('onboarding.header.title')}

- Connect a model provider to start chatting. Most options take one click. + {t('onboarding.header.subtitle')}

@@ -246,7 +252,7 @@ function Header() { } const FEATURED_ID = 'nous' -const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes' +const FEATURED_PITCH = i18n.t('onboarding.featured.pitch') const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1' const readShowAll = () => { @@ -268,6 +274,7 @@ const persistShowAll = (value: boolean) => { } export function Picker({ ctx }: { ctx: OnboardingContext }) { + const { t } = useTranslation() const { mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) @@ -278,7 +285,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { } if (providers === null) { - return Looking up providers... + return {t('onboarding.picker.looking_up')} } const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx) @@ -306,7 +313,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { onClick={() => setShowAll(persistShowAll(!showAll))} type="button" > - {showAll ? 'Collapse' : 'Other providers'} + {showAll ? t('onboarding.picker.collapse') : t('onboarding.picker.other_providers')} ) : null} @@ -316,7 +323,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { onClick={() => setOnboardingMode('apikey')} type="button" > - I have an API key + {t('onboarding.picker.i_have_key')}
@@ -330,6 +337,7 @@ function FeaturedProviderRow({ onSelect: (provider: OAuthProvider) => void provider: OAuthProvider }) { + const { t } = useTranslation() const loggedIn = provider.status?.logged_in return ( @@ -350,7 +358,7 @@ function FeaturedProviderRow({ ) : ( )}
@@ -362,15 +370,19 @@ function FeaturedProviderRow({ } function ConnectedTag() { + const { t } = useTranslation() + return ( - Connected + {t('onboarding.featured.connected')} ) } function KeyProviderRow({ onClick }: { onClick: () => void }) { + const { t } = useTranslation() + return ( @@ -412,6 +424,7 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide } function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) { + const { t } = useTranslation() const [option, setOption] = useState(API_KEY_OPTIONS[0]) const [value, setValue] = useState('') const [saving, setSaving] = useState(false) @@ -432,7 +445,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon if (result.ok) { setValue('') } else { - setError(result.message ?? 'Could not save credential.') + setError(result.message ?? t('onboarding.api_key.save_error')) } setSaving(false) @@ -447,7 +460,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon type="button" > - Back to sign in + {t('onboarding.api_key.back')} ) : null} @@ -478,7 +491,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon

{option.description}

- {option.docsUrl ? Get a key : null} + {option.docsUrl ? {t('onboarding.api_key.get_key')} : null}
setValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && void submit()} - placeholder={option.placeholder || 'Paste API key'} + placeholder={option.placeholder || t('onboarding.api_key.placeholder')} type={isLocal ? 'text' : 'password'} value={value} /> @@ -496,7 +509,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
@@ -504,21 +517,22 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon } function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) { + const { t } = useTranslation() const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : '' if (flow.status === 'starting') { - return Starting sign-in for {title}... + return {t('onboarding.flow.starting', { title })} } if (flow.status === 'submitting') { - return Verifying your code with {title}... + return {t('onboarding.flow.verifying', { title })} } if (flow.status === 'success') { return (
- {title} connected. Picking a default model... + {t('onboarding.flow.success', { title })}
) } @@ -531,11 +545,11 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow return (
- {flow.message || 'Sign-in failed. Try again.'} + {flow.message || t('onboarding.flow.failed')}
@@ -544,23 +558,23 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow if (flow.status === 'awaiting_user') { return ( - +
    -
  1. We opened {title} in your browser.
  2. -
  3. Authorize Hermes there.
  4. -
  5. Copy the authorization code and paste it below.
  6. +
  7. {t('onboarding.flow.step_browser_open', { title })}
  8. +
  9. {t('onboarding.flow.step_browser_authorize')}
  10. +
  11. {t('onboarding.flow.step_paste_code')}
setOnboardingCode(e.target.value)} onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} - placeholder="Paste authorization code" + placeholder={t('onboarding.flow.code_placeholder')} value={flow.code} /> - Re-open authorization page}> + {t('onboarding.flow.reopen_auth')}}>
@@ -569,15 +583,15 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow if (flow.status === 'awaiting_browser') { return ( - +

We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to copy or paste.

- Re-open sign-in page}> + {t('onboarding.flow.reopen_signin')}}> - Waiting for you to authorize... + {t('onboarding.flow.waiting')} @@ -587,19 +601,18 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow if (flow.status === 'external_pending') { return ( - +

- {title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed - in": + {t('onboarding.flow.external_instruction', { title })}

void copyExternalCommand()} text={flow.provider.cli_command} /> {title} docs : null} + left={flow.provider.docs_url ? {t('onboarding.flow.external_docs', { title })} : null} >
@@ -611,13 +624,13 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow } return ( - -

We opened {title} in your browser. Enter this code there:

+ +

{t('onboarding.flow.device_code_instruction', { title })}

void copyDeviceCode()} text={flow.start.user_code} /> - Re-open verification page}> + {t('onboarding.flow.reopen_verification')}}> - Waiting for you to authorize... + {t('onboarding.flow.waiting')} @@ -645,11 +658,13 @@ function CodeBlock({ onCopy: () => void text: string }) { + const { t } = useTranslation() + return (
{text}
) @@ -665,9 +680,11 @@ function FlowFooter({ children, left }: { children: React.ReactNode; left?: Reac } function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { + const { t } = useTranslation() + return ( ) } @@ -679,6 +696,7 @@ function ConfirmingModelPanel({ ctx: OnboardingContext flow: Extract }) { + const { t } = useTranslation() // Local state controls whether the model picker dialog is open. // We reuse the existing ModelPickerDialog component (the same picker // available from the chat shell) rather than building an inline @@ -692,9 +710,11 @@ function ConfirmingModelPanel({ queryKey: ['onboarding-model-options', flow.providerSlug], queryFn: () => getGlobalModelOptions() }) + const providerRow = options.data?.providers?.find( p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase() ) + const price = providerRow?.pricing?.[flow.currentModel] const freeTier = providerRow?.free_tier @@ -702,34 +722,39 @@ function ConfirmingModelPanel({
- {flow.label} connected. + {t('onboarding.confirming.connected', { label: flow.label })}
-

Default model

+

{t('onboarding.confirming.default_model')}

{freeTier === true && ( - Free tier + {t('onboarding.confirming.free_tier')} )} {freeTier === false && ( - Pro + {t('onboarding.confirming.pro_tier')} )}

{flow.currentModel}

{price && (price.input || price.output) && (

- {price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`} + {price.free + ? t('onboarding.confirming.free_price') + : t('onboarding.confirming.price_format', { + input: price.input || '?', + output: price.output || '?' + })}

)}
@@ -737,7 +762,7 @@ function ConfirmingModelPanel({
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 2b442b7f74d5..6b3c537baa28 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -1,15 +1,11 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' import { $gatewayState } from '@/store/session' -// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at -// the render level means no timer logic (even a stale HMR one) can ever -// scramble "CONN". -const PREFIX = 'CONN' -const TAIL = 'ECTING' // Even-weight mono ascii so cycling glyphs don't jump width (matches the // nousnet-web download-button decode effect). const SCRAMBLE_CHARS = '/\\|-_=+<>~:*' @@ -39,8 +35,8 @@ function forcedPreview(): boolean { } } -function scrambledTail(resolvedCount: number): string { - return Array.from(TAIL, (ch, i) => +function scrambledTail(tail: string, resolvedCount: number): string { + return Array.from(tail, (ch, i) => i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0] ).join('') } @@ -49,6 +45,10 @@ export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) const [previewing] = useState(forcedPreview) + const { t } = useTranslation() + const connectingText = t('gateway.connecting') + const PREFIX = connectingText.charAt(0) + const TAIL = connectingText.slice(1) const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState('live') @@ -86,7 +86,7 @@ export function GatewayConnectingOverlay() { } resolved += 0.5 - setTail(scrambledTail(Math.floor(resolved))) + setTail(scrambledTail(TAIL, Math.floor(resolved))) }, TICK_MS) return () => window.clearInterval(id) diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 7c881ba1934a..6678dd5288c8 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -185,6 +185,7 @@ function ModelResults({ } const q = search.trim().toLowerCase() + const matches = (provider: ModelOptionProvider, model: string) => !q || model.toLowerCase().includes(q) || diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts new file mode 100644 index 000000000000..6d23844157ab --- /dev/null +++ b/apps/desktop/src/i18n/index.ts @@ -0,0 +1,73 @@ +/** + * i18n initialization for the desktop app. + * + * Language detection priority: + * 1. System language (navigator.language) + * 2. User override (localStorage: 'hermes-desktop-lang') + * 3. Fallback to 'en' + * + * Heuristic for locale vs. language code: we differentiate zh-CN from zh-TW + * but treat zh-Hans / zh-Hant / zh-CN / zh-SG as zh-CN, and zh-TW / zh-HK + * as zh-TW. All other languages fall back to 'en' when the exact locale + * file is missing (i18next's fallbackLng handles this). + */ + +import i18n from 'i18next' +import languageDetector from 'i18next-browser-languagedetector' +import { initReactI18next } from 'react-i18next' + +import zhCN from './locales/zh-CN/translation.json' + +const LANGUAGE_DETECTION_ORDER = ['localStorage', 'navigator', 'htmlTag'] +const LOCALSTORAGE_KEY = 'hermes-desktop-lang' + +const SUPPORTED_LOCALES = ['en', 'zh-CN', 'zh-TW'] as const + +function normalizeLanguage(lng: string): string { + const lower = lng.toLowerCase().replace(/_/g, '-') + + if (lower.startsWith('zh')) { + if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-hant')) { + return 'zh-TW' + } + + return 'zh-CN' + } + + return 'en' +} + +void i18n + .use(languageDetector) + .use(initReactI18next) + .init({ + resources: { + 'zh-CN': { translation: zhCN } + }, + fallbackLng: 'en', + debug: import.meta.env.DEV, + interpolation: { + escapeValue: false // React already escapes + }, + detection: { + lookupLocalStorage: LOCALSTORAGE_KEY, + order: LANGUAGE_DETECTION_ORDER, + caches: ['localStorage'], + convertDetectedLanguage: normalizeLanguage + }, + returnObjects: false + }) + +export default i18n + +/** Switch locale at runtime and persist the choice. */ +export function setLocale(lng: string): void { + const normalized = SUPPORTED_LOCALES.includes(lng as any) ? lng : 'en' + localStorage.setItem(LOCALSTORAGE_KEY, normalized) + void i18n.changeLanguage(normalized) +} + +/** Current displayed locale (resolved, not the raw stored value). */ +export function currentLocale(): string { + return i18n.language || 'en' +} diff --git a/apps/desktop/src/i18n/locales/zh-CN/translation.json b/apps/desktop/src/i18n/locales/zh-CN/translation.json new file mode 100644 index 000000000000..578b9bfbdbd5 --- /dev/null +++ b/apps/desktop/src/i18n/locales/zh-CN/translation.json @@ -0,0 +1,227 @@ +{ + "onboarding": { + "header": { + "title": "开始使用 Hermes Agent", + "subtitle": "选择一个模型提供商即可开始对话。大部分选项只需一键点击。" + }, + "close": "关闭", + "preparing": { + "installing": "Hermes 正在完成安装。首次启动通常只需不到一分钟。", + "starting": "正在启动 Hermes…" + }, + "providers": { + "openrouter": { + "name": "OpenRouter", + "short": "一个密钥,多款模型", + "desc": "一个密钥即可接入数百款模型。新安装的首选方案。" + }, + "openai": { + "name": "OpenAI", + "short": "GPT 系列模型", + "desc": "直接访问 OpenAI 模型。" + }, + "gemini": { + "name": "Google Gemini", + "short": "Gemini 模型", + "desc": "直接访问 Google Gemini 模型。" + }, + "xai": { + "name": "xAI Grok", + "short": "Grok 模型", + "desc": "直接访问 xAI Grok 模型。" + }, + "local": { + "name": "本地 / 自定义端点", + "short": "自托管", + "desc": "将 Hermes 指向本地或自托管的 OpenAI 兼容端点(vLLM、llama.cpp、Ollama 等)。" + } + }, + "oauth_providers": { + "nous": "Nous Portal", + "anthropic": "Anthropic Claude", + "openai-codex": "OpenAI Codex / ChatGPT", + "minimax-oauth": "MiniMax", + "xai-oauth": "xAI Grok", + "claude-code": "Claude Code", + "qwen-oauth": "Qwen Code" + }, + "flow_subtitles": { + "pkce": "将在浏览器中打开登录页面,完成后返回此处", + "device_code": "将在浏览器中打开验证页面 — Hermes 会自动连接", + "loopback": "将在浏览器中打开登录页面 — Hermes 会自动连接", + "external": "在终端中完成登录,然后回来继续对话" + }, + "featured": { + "pitch": "一个订阅,300+ 前沿模型 — 运行 Hermes 的推荐方式", + "recommended": "推荐", + "connected": "已连接" + }, + "picker": { + "looking_up": "正在查找提供商…", + "key_provider_title": "OpenRouter", + "key_provider_desc": "一个密钥,数百款模型 — 稳定可靠的首选", + "collapse": "收起", + "other_providers": "其他提供商", + "i_have_key": "我有 API 密钥" + }, + "api_key": { + "back": "返回登录", + "get_key": "获取密钥", + "placeholder": "粘贴 API 密钥", + "save_error": "无法保存凭证。", + "connecting": "连接中…", + "connect": "连接" + }, + "flow": { + "starting": "正在启动 {{title}} 登录…", + "verifying": "正在验证 {{title}} 的代码…", + "success": "{{title}} 已连接。正在选择默认模型…", + "failed": "登录失败。请重试。", + "pick_other": "选择其他提供商", + "sign_in_with": "登录 {{title}}", + "step_browser_open": "我们已在浏览器中打开 {{title}}。", + "step_browser_authorize": "请在浏览器中授权 Hermes。", + "step_paste_code": "复制授权码并粘贴到下方。", + "code_placeholder": "粘贴授权码", + "reopen_auth": "重新打开授权页面", + "continue": "继续", + "reopen_signin": "重新打开登录页面", + "waiting": "等待你完成授权…", + "external_instruction": "{{title}} 通过自己的命令行登录。在终端中运行以下命令,然后点击「我已登录」:", + "external_docs": "{{title}} 文档", + "signed_in": "我已登录", + "device_code_instruction": "我们已在浏览器中打开 {{title}}。请输入以下代码:", + "reopen_verification": "重新打开验证页面", + "browser_auto_instruction": "我们已在浏览器中打开 {{title}}。在浏览器中授权 Hermes,即可自动连接 — 无需复制或粘贴任何内容。" + }, + "code_block": { + "copy": "复制", + "cancel": "取消" + }, + "confirming": { + "connected": "{{label}} 已连接。", + "default_model": "默认模型", + "change": "更换", + "start_chatting": "开始对话", + "free_tier": "免费", + "pro_tier": "Pro", + "free_price": "免费", + "price_format": "{{input}} 输入 / {{output}} 输出 每百万 token" + } + }, + + "install": { + "stage_pending": "等待中", + "stage_installing": "安装中", + "stage_done": "完成", + "stage_succeeded": "成功", + "stage_skipped": "跳过", + "stage_failed": "失败", + "unsupported": { + "title": "Hermes 需要一次性安装", + "desc": "自动首次启动安装在 {{platform}} 上暂不可用。打开终端并运行以下命令,然后重新打开此应用。后续启动将跳过此步骤。", + "command_label": "安装命令", + "copy_command": "复制命令", + "view_docs": "查看安装文档", + "will_install_to": "将安装到 {{path}}", + "retry": "我已运行 — 重试" + }, + "header": { + "failed": "安装失败", + "active": "正在设置 Hermes Agent", + "completed": "完成设置", + "failed_desc": "某个安装步骤失败了。在 Windows 上,可能是因为另一个 Hermes CLI 或桌面实例正在运行。请停止所有运行的 Hermes 实例后重试。查看下方详情或桌面日志获取完整记录。", + "active_desc": "这是一次性设置。Hermes 安装程序正在下载依赖并配置你的机器。后续启动将跳过此步骤。" + }, + "progress": { + "count": "已完成 {{count}}/{{total}} 步", + "current": "当前:{{stageName}}", + "fetching": "正在获取安装清单…" + }, + "error": "错误", + "log": { + "hide": "隐藏安装输出", + "show": "显示安装输出", + "line": "{{count}} 行", + "lines": "{{count}} 行", + "empty": "暂无输出。" + }, + "footer": { + "cancelling": "取消中…", + "cancel": "取消安装", + "saved_to": "完整记录保存至 {{path}}", + "copied": "已复制!", + "copy_output": "复制输出", + "retry": "重新加载并重试" + } + }, + + "boot_failure": { + "title": "Hermes 无法启动", + "desc": "后台网关未能启动。请尝试以下恢复步骤 — 这些操作不会删除你的聊天记录或设置。", + "retry": "重试", + "repair": "修复安装", + "use_local": "使用本地网关", + "open_logs": "查看日志", + "repair_hint": "修复会重新运行安装程序,在全新机器上可能需要几分钟。", + "hide_logs": "隐藏最近的日志", + "show_logs": "显示最近的日志" + }, + + "updates": { + "stage": { + "idle": "正在准备…", + "prepare": "正在准备…", + "fetch": "下载中…", + "pull": "快好了…", + "pydeps": "收尾中…", + "restart": "正在重启 Hermes…", + "manual": "在终端中更新", + "error": "更新已暂停" + }, + "checking": { + "title": "正在检查更新…", + "retry": "重试", + "error_title": "无法检查更新" + }, + "status": { + "close": "关闭", + "not_supported": "此版本的 Hermes 无法在应用内自行更新。", + "not_available": "无法更新", + "check_connection": "请检查网络连接后重试。", + "up_to_date": "你正在运行最新版本。", + "all_set": "一切就绪" + }, + "available": { + "title": "有新版本可用", + "desc": "新版 Hermes 已准备好安装。", + "update_now": "立即更新", + "maybe_later": "稍后再说", + "more_changes": "+ {{count}} 个其他变更" + }, + "manual": { + "title": "在终端中更新", + "desc": "你从命令行安装了 Hermes,因此更新也需要在终端中运行。复制以下命令到终端执行:", + "prompt_prefix": "$ ", + "copied": "已复制", + "copy": "复制", + "footnote": "下次启动时 Hermes 会自动加载新版本。", + "done": "完成" + }, + "applying": { + "fallback_title": "正在更新 Hermes…", + "desc": "Hermes 更新程序将在独立窗口中接管,完成后会自动重新打开。", + "footnote": "Hermes 将关闭以应用更新。" + }, + "error": { + "title": "更新未完成", + "default_msg": "别担心 — 没有丢失任何内容。现在可以重试。", + "retry": "重试", + "not_now": "稍后" + } + }, + + "gateway": { + "connecting": "连接中" + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 59341df1a1a7..e9eb1eb9247f 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,4 +1,5 @@ import './styles.css' +import './i18n' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react'