From b1b89f843e62d375e886f468532025561989d725 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?=
Date: Fri, 5 Jun 2026 21:42:48 -0500
Subject: [PATCH] Refactor desktop i18n field copy into nested structures
---
.../src/app/chat/chat-drop-overlay.tsx | 12 +-
.../src/app/chat/chat-swap-overlay.tsx | 4 +-
apps/desktop/src/app/chat/composer/index.tsx | 6 +-
.../app/chat/hooks/use-composer-actions.ts | 31 +-
.../app/chat/right-rail/preview-console.tsx | 41 +-
.../src/app/chat/right-rail/preview-file.tsx | 29 +-
.../src/app/chat/right-rail/preview-pane.tsx | 88 +-
.../src/app/chat/right-rail/preview.tsx | 12 +-
apps/desktop/src/app/chat/sidebar/index.tsx | 8 +-
.../src/app/chat/sidebar/profile-switcher.tsx | 29 +-
.../desktop/src/app/command-palette/index.tsx | 138 +-
.../src/app/gateway/hooks/use-gateway-boot.ts | 16 +-
apps/desktop/src/app/messaging/index.tsx | 155 +--
.../desktop/src/app/overlays/overlay-view.tsx | 3 +-
.../app/profiles/create-profile-dialog.tsx | 34 +-
.../app/profiles/delete-profile-dialog.tsx | 19 +-
.../app/profiles/rename-profile-dialog.tsx | 24 +-
.../src/app/right-sidebar/files/tree.tsx | 5 +-
apps/desktop/src/app/right-sidebar/index.tsx | 90 +-
.../src/app/right-sidebar/terminal/index.tsx | 6 +-
.../src/app/session/hooks/use-cwd-actions.ts | 11 +-
.../app/session/hooks/use-model-controls.ts | 7 +-
.../app/session/hooks/use-prompt-actions.ts | 71 +-
.../app/session/hooks/use-session-actions.ts | 34 +-
.../src/app/settings/config-settings.tsx | 29 +-
apps/desktop/src/app/settings/constants.ts | 241 ++--
.../src/app/settings/credential-key-ui.tsx | 25 +-
.../src/app/settings/env-credentials.tsx | 26 +-
.../src/app/settings/env-var-actions-menu.tsx | 20 +-
apps/desktop/src/app/settings/field-copy.ts | 44 +
.../src/app/settings/gateway-settings.tsx | 124 +-
apps/desktop/src/app/settings/helpers.test.ts | 44 +
.../src/app/settings/keys-settings.tsx | 6 +-
.../desktop/src/app/settings/mcp-settings.tsx | 43 +-
.../src/app/settings/model-settings.tsx | 85 +-
.../src/app/settings/providers-settings.tsx | 21 +-
.../src/app/settings/sessions-settings.tsx | 58 +-
.../src/app/settings/toolset-config-panel.tsx | 46 +-
.../src/app/shell/gateway-menu-panel.tsx | 31 +-
.../app/shell/hooks/use-statusbar-items.tsx | 74 +-
.../src/app/shell/model-edit-submenu.tsx | 29 +-
.../src/app/shell/model-menu-panel.tsx | 17 +-
.../src/app/shell/titlebar-controls.tsx | 6 +-
apps/desktop/src/app/updates-overlay.tsx | 78 +-
.../components/assistant-ui/clarify-tool.tsx | 23 +-
.../src/components/assistant-ui/thread.tsx | 103 +-
.../components/assistant-ui/tool-approval.tsx | 29 +-
.../assistant-ui/tool-fallback-model.ts | 36 +-
.../components/assistant-ui/tool-fallback.tsx | 19 +-
.../chat/image-generation-placeholder.tsx | 5 +-
.../components/chat/preview-attachment.tsx | 6 +-
.../src/components/chat/shiki-highlighter.tsx | 6 +-
.../src/components/chat/zoomable-image.tsx | 13 +-
.../components/desktop-install-overlay.tsx | 65 +-
.../components/desktop-onboarding-overlay.tsx | 158 +--
.../desktop/src/components/error-boundary.tsx | 13 +-
apps/desktop/src/components/model-picker.tsx | 42 +-
.../components/model-visibility-dialog.tsx | 11 +-
.../src/components/prompt-overlays.tsx | 39 +-
.../src/components/ui/confirm-dialog.tsx | 20 +-
apps/desktop/src/components/ui/dialog.tsx | 7 +-
apps/desktop/src/components/ui/pagination.tsx | 17 +-
.../src/components/ui/search-field.tsx | 4 +-
apps/desktop/src/components/ui/sheet.tsx | 10 +-
apps/desktop/src/components/ui/sidebar.tsx | 14 +-
apps/desktop/src/i18n/en.ts | 875 ++++++++++++-
apps/desktop/src/i18n/ja.ts | 242 ++--
apps/desktop/src/i18n/runtime.test.ts | 8 +
apps/desktop/src/i18n/types.ts | 750 +++++++++++
apps/desktop/src/i18n/zh-hant.ts | 242 ++--
apps/desktop/src/i18n/zh.ts | 1143 +++++++++++++++--
apps/desktop/src/lib/session-export.ts | 5 +-
72 files changed, 4397 insertions(+), 1428 deletions(-)
create mode 100644 apps/desktop/src/app/settings/field-copy.ts
diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
index f9d3fc370a..ff01687aac 100644
--- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx
+++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
@@ -2,11 +2,12 @@ import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
-const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
- files: { icon: 'cloud-upload', label: 'Drop files to attach' },
- session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
+const ICONS: Record<'files' | 'session', string> = {
+ files: 'cloud-upload',
+ session: 'comment-discussion'
}
/**
@@ -17,13 +18,16 @@ const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
* fade-out so the label doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
+ const { t } = useI18n()
const lastKind = useRef<'files' | 'session'>('files')
if (kind) {
lastKind.current = kind
}
- const { icon, label } = COPY[kind ?? lastKind.current]
+ const resolvedKind = kind ?? lastKind.current
+ const icon = ICONS[resolvedKind]
+ const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
return (
(profile)
@@ -38,7 +40,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) {
>
{FRAMES[frame]}
- Waking up {label}…
+ {t.composer.wakingProfile(label ?? '')}
)
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index e19128add3..0cea51661c 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1532,7 +1532,7 @@ export function ChatBar({
{queueEdit && editingQueuedPrompt && (
- Editing queued turn in composer
+ {t.composer.editingQueuedInComposer}
- Cancel
+ {t.common.cancel}
exitQueuedEdit('save')}
type="button"
>
- Save
+ {t.common.save}
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index e48ff7accf..ebfd4e58b6 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -2,6 +2,7 @@ import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
+import { useI18n } from '@/i18n'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import {
addComposerAttachment,
@@ -193,9 +194,11 @@ const attachToMain = (attachment: ComposerAttachment) => {
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
+ const { t } = useI18n()
+ const copy = t.desktop
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
- }, [])
+ }, [copy.imagePreviewFailed])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
@@ -300,7 +303,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
} catch (err) {
- notifyError(err, 'Image preview failed')
+ notifyError(err, copy.imagePreviewFailed)
return true
}
@@ -322,28 +325,28 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
if (!savedPath) {
- notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
+ notify({ kind: 'error', title: copy.imageAttach, message: copy.imageWriteFailed })
return false
}
return attachImagePath(savedPath)
} catch (err) {
- notifyError(err, 'Image attach failed')
+ notifyError(err, copy.imageAttachFailed)
return false
}
},
- [attachImagePath]
+ [attachImagePath, copy.imageAttach, copy.imageAttachFailed, copy.imageWriteFailed]
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
- title: 'Attach images',
+ title: copy.attachImages,
defaultPath: currentCwd || undefined,
filters: [
{
- name: 'Images',
+ name: t.composer.images,
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
}
]
@@ -356,7 +359,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
for (const path of paths) {
await attachImagePath(path)
}
- }, [attachImagePath, currentCwd])
+ }, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
const pasteClipboardImage = useCallback(async () => {
try {
@@ -365,8 +368,8 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
if (!path) {
notify({
kind: 'warning',
- title: 'Clipboard',
- message: 'No image found in clipboard'
+ title: copy.clipboard,
+ message: copy.noClipboardImage
})
return
@@ -374,9 +377,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
await attachImagePath(path)
} catch (err) {
- notifyError(err, 'Clipboard paste failed')
+ notifyError(err, copy.clipboardPasteFailed)
}
- }, [attachImagePath])
+ }, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
const attachContextFolderPath = useCallback(
(folderPath: string) => {
@@ -477,12 +480,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
if (!attached && lastFailure) {
- notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
+ notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure })
}
return attached
},
- [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
+ [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles]
)
const removeAttachment = useCallback(
diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx
index 70a973322b..67df7fefc2 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { CopyButton } from '@/components/ui/copy-button'
import { Tip } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify } from '@/store/notifications'
@@ -74,6 +75,9 @@ interface ConsoleRowProps {
}
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
+ const { t } = useI18n()
+ const copy = t.preview.console
+
return (
-
+
-
+
- {logCount > 0 && {logCount} console messages }
+ {logCount > 0 && {t.preview.console.messages(logCount)} }
>
)
}
@@ -152,6 +157,8 @@ export function PreviewConsolePanel({
consoleState,
startConsoleResize
}: PreviewConsolePanelProps) {
+ const { t } = useI18n()
+ const copy = t.preview.console
const consoleHeight = useStore(consoleState.$height)
const logs = useStore(consoleState.$logs)
const selectedLogIds = useStore(consoleState.$selectedLogIds)
@@ -188,14 +195,14 @@ export function PreviewConsolePanel({
return
}
- const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
+ const block = [copy.promptHeader, '```', ...entries.map(formatLogLine), '```'].join('\n')
requestComposerInsert(block, { mode: 'block', target: 'main' })
consoleState.clearSelection()
notify({
kind: 'success',
- title: 'Sent to chat',
- message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
+ title: copy.sentTitle,
+ message: copy.sentMessage(entries.length)
})
}
@@ -205,7 +212,7 @@ export function PreviewConsolePanel({
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
@@ -216,10 +223,10 @@ export function PreviewConsolePanel({
- Preview Console
+ {copy.title}
{selectedLogIds.size > 0 && (
- {selectedLogIds.size} selected
+ {copy.selected(selectedLogIds.size)}
)}
@@ -231,18 +238,18 @@ export function PreviewConsolePanel({
type="button"
>
- Send to chat
+ {copy.sendToChat}
0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
+ label={visibleSelection.length > 0 ? copy.copySelected : copy.copyAll}
text={() => formatConsoleEntries(sendableLogs)}
>
- Copy
+ {copy.copy}
- Clear
+ {copy.clear}
@@ -275,7 +282,7 @@ export function PreviewConsolePanel({
)
})
) : (
- No console messages yet.
+ {copy.empty}
)}
diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
index 86b8acf9c3..e3ebc4f228 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
+import { translateNow, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -143,7 +144,7 @@ function filePathForTarget(target: PreviewTarget) {
function formatBytes(bytes: number | undefined) {
if (!bytes) {
- return 'unknown size'
+ return translateNow('preview.unknownSize')
}
const units = ['B', 'KB', 'MB', 'GB']
@@ -296,6 +297,8 @@ function MarkdownPreview({ text }: { text: string }) {
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
+ const { t } = useI18n()
+
return (
- {asSource ? 'PREVIEW' : 'SOURCE'}
+ {asSource ? t.preview.renderedPreview : t.preview.source}
)
@@ -330,6 +333,7 @@ function startLineDrag(event: ReactDragEvent, filePath: string, { e
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
+ const { t } = useI18n()
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
@@ -373,7 +377,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
- title="Click to select · shift-click to extend · drag to composer"
+ title={t.preview.sourceLineTitle}
>
{line}
@@ -408,6 +412,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
+ const { t } = useI18n()
const [state, setState] = useState({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
@@ -482,11 +487,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
- return
+ return
}
if (state.error) {
- return
+ return
}
if (
@@ -501,11 +506,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
setForcePreview(true) }}
- title={binary ? 'This looks like a binary file' : 'This file is large'}
+ primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
+ title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
/>
)
@@ -532,7 +537,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
{state.truncated && (
- Showing first 512 KB.
+ {t.preview.truncated}
)}
{isMarkdown &&
setRenderMarkdownAsSource(s => !s)} />}
@@ -547,8 +552,8 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return (
)
}
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
index 0c8a5bb296..21cfbeb3ce 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
+import { type Translations, useI18n } from '@/i18n'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -46,18 +47,18 @@ interface PreviewLoadErrorState {
const FILE_RELOAD_DEBOUNCE_MS = 200
const SERVER_RESTART_TIMEOUT_MS = 45_000
-function loadErrorTitle(error: PreviewLoadErrorState): string {
+function loadErrorTitle(error: PreviewLoadErrorState, copy: Translations['preview']['web']): string {
const description = error.description.toLowerCase()
if (description.includes('module script') || description.includes('mime type')) {
- return 'Preview app failed to boot'
+ return copy.appFailedToBoot
}
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
- return 'Server not found'
+ return copy.serverNotFound
}
- return 'Preview failed to load'
+ return copy.failedToLoad
}
function isModuleMimeError(message: string): boolean {
@@ -79,6 +80,9 @@ function PreviewLoadError({
onRetry: () => void
restarting?: boolean
}) {
+ const { t } = useI18n()
+ const copy = t.preview.web
+
return (
}
consoleHeight={consoleHeight}
- primaryAction={{ label: 'Try again', onClick: onRetry }}
+ primaryAction={{ label: copy.tryAgain, onClick: onRetry }}
secondaryAction={
onRestartServer
? {
disabled: restarting,
- label: restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server',
+ label: restarting ? copy.restarting : copy.askRestart,
onClick: onRestartServer
}
: undefined
}
- title={loadErrorTitle(error)}
+ title={loadErrorTitle(error, copy)}
/>
)
}
@@ -122,6 +126,8 @@ export function PreviewPane({
setTitlebarToolGroup,
target
}: PreviewPaneProps) {
+ const { t } = useI18n()
+ const copy = t.preview.web
const [consoleState] = useState(() => createPreviewConsoleState())
const consoleBodyRef = useRef(null)
const consoleShouldStickRef = useRef(true)
@@ -239,23 +245,23 @@ export function PreviewPane({
appendConsoleEntry({
level: 1,
- message: `Hermes is looking for a preview server to restart (${taskId})`
+ message: copy.lookingRestart(taskId)
})
notify({
kind: 'info',
- title: 'Restarting preview server',
- message: 'Hermes is working in the background. Watch the preview console for progress.',
+ title: copy.restartingTitle,
+ message: copy.restartingMessage,
durationMs: 4000
})
} catch (error) {
appendConsoleEntry({
level: 2,
- message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}`
+ message: copy.startRestartFailed(error instanceof Error ? error.message : String(error))
})
- notifyError(error, 'Server restart failed')
+ notifyError(error, copy.restartFailed)
}
- }, [appendConsoleEntry, consoleState, currentUrl, onRestartServer])
+ }, [appendConsoleEntry, consoleState, copy, currentUrl, onRestartServer])
const toggleDevTools = useCallback(() => {
const webview = webviewRef.current
@@ -287,14 +293,14 @@ export function PreviewPane({
active: consoleOpen,
icon: ,
id: `${TITLEBAR_GROUP_ID}-console`,
- label: consoleOpen ? 'Hide preview console' : 'Show preview console',
+ label: consoleOpen ? copy.hideConsole : copy.showConsole,
onSelect: () => consoleState.setOpen(open => !open)
},
{
active: devtoolsOpen,
icon: ,
id: `${TITLEBAR_GROUP_ID}-devtools`,
- label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
+ label: devtoolsOpen ? copy.hideDevTools : copy.openDevTools,
onSelect: toggleDevTools
}
]
@@ -304,7 +310,7 @@ export function PreviewPane({
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
- }, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
+ }, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
useEffect(() => {
if (!consoleOpen) {
@@ -343,29 +349,27 @@ export function PreviewPane({
previewServerRestart.status === 'running'
? previewServerRestart.message
: previewServerRestart.status === 'complete'
- ? `Hermes finished restarting the preview server${
- previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
- }`
- : `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
+ ? copy.finishedRestarting(previewServerRestart.message)
+ : copy.failedRestarting(previewServerRestart.message || copy.unknownError)
})
if (previewServerRestart.status === 'complete') {
reloadPreview()
notify({
kind: 'success',
- title: 'Preview server restarted',
- message: previewServerRestart.message?.slice(0, 160) || 'Reloading the preview now.',
+ title: copy.restartedTitle,
+ message: previewServerRestart.message?.slice(0, 160) || copy.reloadingNow,
durationMs: 3500
})
} else if (previewServerRestart.status === 'error') {
notify({
kind: 'warning',
- title: 'Preview restart failed',
- message: previewServerRestart.message?.slice(0, 200) || 'Hermes could not restart the server.',
+ title: copy.restartFailedTitle,
+ message: previewServerRestart.message?.slice(0, 200) || copy.restartFailedMessage,
durationMs: 6000
})
}
- }, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url])
+ }, [appendConsoleEntry, copy, currentUrl, previewServerRestart, reloadPreview, target.url])
useEffect(() => {
if (!restartingServer || !previewServerRestart) {
@@ -375,14 +379,11 @@ export function PreviewPane({
const taskId = previewServerRestart.taskId
const timer = window.setTimeout(() => {
- failPreviewServerRestart(
- taskId,
- 'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.'
- )
+ failPreviewServerRestart(taskId, copy.stillWorking)
}, SERVER_RESTART_TIMEOUT_MS)
return () => window.clearTimeout(timer)
- }, [previewServerRestart, restartingServer])
+ }, [copy.stillWorking, previewServerRestart, restartingServer])
useEffect(() => {
if (reloadRequest === lastReloadRequestRef.current) {
@@ -397,10 +398,10 @@ export function PreviewPane({
appendConsoleEntry({
level: 1,
- message: 'Workspace changed, reloading preview'
+ message: copy.workspaceReloading
})
reloadPreview()
- }, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
+ }, [appendConsoleEntry, copy.workspaceReloading, reloadPreview, reloadRequest, target.kind])
useEffect(() => {
if (
@@ -432,8 +433,8 @@ export function PreviewPane({
level: 1,
message:
changedCount === 1
- ? `File changed, reloading preview: ${compactUrl(changedUrl)}`
- : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
+ ? copy.fileChanged(compactUrl(changedUrl))
+ : copy.filesChanged(changedCount, compactUrl(changedUrl))
})
reloadPreview()
@@ -471,7 +472,7 @@ export function PreviewPane({
.catch(error => {
appendConsoleEntry({
level: 2,
- message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
+ message: copy.watchFailed(error instanceof Error ? error.message : String(error))
})
})
@@ -487,7 +488,7 @@ export function PreviewPane({
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
}
}
- }, [appendConsoleEntry, reloadPreview, target.kind, target.url])
+ }, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url])
useEffect(() => {
const host = hostRef.current
@@ -535,8 +536,7 @@ export function PreviewPane({
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
setLoadError({
- description:
- 'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
+ description: copy.moduleMimeDescription,
url: webview.getURL?.() || target.url
})
setLoading(false)
@@ -567,13 +567,11 @@ export function PreviewPane({
appendConsoleEntry({
level: 3,
- message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
- detail.errorDescription || detail.validatedURL || 'unknown error'
- }`
+ message: copy.loadFailedConsole(errorCode, detail.errorDescription || detail.validatedURL || copy.unknownError)
})
setLoadError({
code: errorCode,
- description: detail.errorDescription || 'The preview page could not be reached.',
+ description: detail.errorDescription || copy.unreachableDescription,
url: detail.validatedURL || webview.getURL?.() || target.url
})
setLoading(false)
@@ -600,7 +598,7 @@ export function PreviewPane({
webview.removeEventListener('did-stop-loading', onStop)
webview.remove()
}
- }, [appendConsoleEntry, consoleState, isWebPreview, target.url])
+ }, [appendConsoleEntry, consoleState, copy, isWebPreview, target.url])
return (
@@ -608,14 +606,14 @@ export function PreviewPane({
{!embedded && (
diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx
index b6825ff6f0..dec0e36f47 100644
--- a/apps/desktop/src/app/chat/right-rail/preview.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
+import { translateNow, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
@@ -48,10 +49,11 @@ function tabLabelFor(target: PreviewTarget): string {
const value = target.label || target.path || target.source || target.url
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
- return tail || value || 'Preview'
+ return tail || value || translateNow('preview.tab')
}
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
+ const { t } = useI18n()
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const filePreviewTabs = useStore($filePreviewTabs)
@@ -59,10 +61,10 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const tabs = useMemo
(
() => [
- ...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: 'Preview', target: previewTarget } as RailTab] : []),
+ ...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
- [filePreviewTabs, previewTarget]
+ [filePreviewTabs, previewTarget, t.preview.tab]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
@@ -134,7 +136,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
closeRightRailTab(tab.id)}
type="button"
@@ -146,7 +148,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
})}
,
action: 'new-session'
},
{
id: 'skills',
- label: 'Skills & Tools',
+ label: '',
icon: props => ,
route: SKILLS_ROUTE
},
- { id: 'messaging', label: 'Messaging', icon: props => , route: MESSAGING_ROUTE },
- { id: 'artifacts', label: 'Artifacts', icon: props => , route: ARTIFACTS_ROUTE }
+ { id: 'messaging', label: '', icon: props => , route: MESSAGING_ROUTE },
+ { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE }
]
const WORKSPACE_PAGE = 5
diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
index 7e624d59d8..fbd089a9ff 100644
--- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
+++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
@@ -27,6 +27,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
@@ -84,6 +85,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
+ const { t } = useI18n()
+ const p = t.profiles
const profiles = useStore($profiles)
const scope = useStore($profileScope)
const gatewayProfile = useStore($activeGatewayProfile)
@@ -187,11 +190,11 @@ export function ProfileRail() {
(onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
) : (
- setShowAllProfiles(true)} />
+ setShowAllProfiles(true)} />
))}
{/* Single-profile: the active default's home icon next to the create +. */}
@@ -233,9 +236,9 @@ export function ProfileRail() {
)}
-
+
setCreateOpen(true)}
type="button"
@@ -246,7 +249,7 @@ export function ProfileRail() {
{multiProfile && (
- navigate(PROFILES_ROUTE)} />
+ navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
@@ -328,6 +331,8 @@ const LONG_PRESS_MS = 450
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
+ const { t } = useI18n()
+ const p = t.profiles
const hue = color ?? 'var(--ui-text-quaternary)'
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef(null)
@@ -436,27 +441,27 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) — Radix then flips the menu up instead of squishing it. */}
setPickerOpen(true)}>
- Color…
+ {p.color}
- Rename
+ {p.rename}
- Delete
+ {t.common.delete}
{PROFILE_SWATCHES.map(swatch => (
pickColor(swatch)}
@@ -483,7 +488,7 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
type="button"
>
- Auto
+ {p.autoColor}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 7fd015efec..236aac244b 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
+import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
@@ -92,48 +93,60 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
title: sessionTitle(session)
})
-const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
+type NonConfigSettingsLabel =
+ | 'about'
+ | 'archivedChats'
+ | 'gateway'
+ | 'keysSettings'
+ | 'keysTools'
+ | 'mcp'
+ | 'providerAccounts'
+ | 'providerApiKeys'
+
+const NON_CONFIG_SETTINGS: ReadonlyArray<{
+ icon: IconComponent
+ keywords?: string[]
+ labelKey: NonConfigSettingsLabel
+ tab: string
+}> = [
{
icon: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
- label: 'Providers',
+ labelKey: 'providerAccounts',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
- label: 'Provider API keys',
+ labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
- { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
+ { icon: Globe, keywords: ['connection', 'messaging'], labelKey: 'gateway', tab: 'gateway' },
{
icon: KeyRound,
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
- label: 'Tools & Keys',
+ labelKey: 'keysTools',
tab: 'keys&kview=tools'
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
- label: 'Tools & Keys settings',
+ labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},
- { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
- { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
- { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
+ { icon: Wrench, keywords: ['servers', 'tools'], labelKey: 'mcp', tab: 'mcp' },
+ { icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' },
+ { icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' }
]
-const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
- { icon: Sun, label: 'Light', mode: 'light' },
- { icon: Moon, label: 'Dark', mode: 'dark' },
- { icon: Monitor, label: 'System', mode: 'system' }
+const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
+ { icon: Sun, mode: 'light' },
+ { icon: Moon, mode: 'dark' },
+ { icon: Monitor, mode: 'system' }
]
-function fieldLabel(key: string): string {
- return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
-}
-
export function CommandPalette() {
+ const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
@@ -180,52 +193,61 @@ export function CommandPalette() {
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
+ const settingsSectionLabel = useCallback(
+ (section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
+ [t.settings.sections]
+ )
+ const configFieldLabel = useCallback(
+ (key: string) => t.settings.fieldLabels[key] ?? FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key),
+ [t.settings.fieldLabels]
+ )
const baseGroups = useMemo(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
+ const cc = t.commandCenter
return [
{
- heading: 'Go to',
+ heading: cc.goTo,
items: [
- { icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
- { icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
+ { icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
+ { icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
- label: 'Skills & Tools',
+ label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
},
- { icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
- { icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
- { icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
- { icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
- { icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
+ { icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
+ { icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
+ { icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
+ { icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
+ { icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
{
- heading: 'Command Center',
+ heading: cc.commandCenter,
items: [
{
icon: Archive,
id: 'cc-sessions',
keywords: ['command center', 'sessions', 'pin'],
- label: 'Sessions',
+ label: cc.sections.sessions,
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
{
icon: Activity,
id: 'cc-system',
keywords: ['command center', 'system', 'status', 'logs'],
- label: 'System',
+ label: cc.sections.system,
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
},
{
icon: BarChart3,
id: 'cc-usage',
keywords: ['command center', 'usage', 'tokens', 'cost'],
- label: 'Usage',
+ label: cc.sections.usage,
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
}
]
@@ -234,45 +256,45 @@ export function CommandPalette() {
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
- heading: 'Appearance',
+ heading: cc.appearance,
items: [
{
icon: Palette,
id: 'appearance-theme',
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
- label: 'Change theme…',
+ label: cc.changeTheme,
to: 'theme'
},
{
icon: Sun,
id: 'appearance-mode',
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
- label: 'Change color mode…',
+ label: cc.changeColorMode,
to: 'color-mode'
}
]
},
{
- heading: 'Settings',
+ heading: cc.settings,
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
- keywords: ['settings', section.label],
- label: section.label,
+ keywords: ['settings', section.label, settingsSectionLabel(section)],
+ label: settingsSectionLabel(section),
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
- label: entry.label,
+ label: t.settings.nav[entry.labelKey],
run: go(settingsTab(entry.tab))
}))
]
}
]
- }, [go])
+ }, [go, settingsSectionLabel, t])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@@ -286,7 +308,7 @@ export function CommandPalette() {
if (sessions.length > 0) {
result.push({
- heading: 'Sessions',
+ heading: t.commandCenter.sections.sessions,
items: sessions.map(session => ({
icon: MessageCircle,
id: `session-${session.id}`,
@@ -301,17 +323,17 @@ export function CommandPalette() {
section.keys.map(key => ({
icon: section.icon,
id: `field-${key}`,
- keywords: ['settings', key, section.label],
- label: `${section.label}: ${fieldLabel(key)}`,
+ keywords: ['settings', key, section.label, settingsSectionLabel(section)],
+ label: `${settingsSectionLabel(section)}: ${configFieldLabel(key)}`,
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
}))
)
- result.push({ heading: 'Settings fields', items: fieldItems })
+ result.push({ heading: t.commandCenter.settingsFields, items: fieldItems })
if (mcpServers.length > 0) {
result.push({
- heading: 'MCP servers',
+ heading: t.commandCenter.mcpServers,
items: mcpServers.map(name => ({
icon: Wrench,
id: `mcp-${name}`,
@@ -324,7 +346,7 @@ export function CommandPalette() {
if (archivedSessions.length > 0) {
result.push({
- heading: 'Archived chats',
+ heading: t.commandCenter.archivedChats,
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
@@ -336,7 +358,7 @@ export function CommandPalette() {
}
return result
- }, [archivedSessions, go, mcpServers, search, sessions])
+ }, [archivedSessions, configFieldLabel, go, mcpServers, search, sessions, settingsSectionLabel, t])
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
@@ -345,13 +367,13 @@ export function CommandPalette() {
const subPages = useMemo>(
() => ({
theme: {
- title: 'Theme',
- placeholder: 'Choose a theme…',
+ title: t.settings.appearance.themeTitle,
+ placeholder: t.settings.appearance.themeDesc,
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
- heading: groupMode === 'light' ? 'Light' : 'Dark',
+ heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
@@ -367,30 +389,30 @@ export function CommandPalette() {
}))
},
'color-mode': {
- title: 'Color mode',
- placeholder: 'Choose color mode…',
+ title: t.settings.appearance.colorMode,
+ placeholder: t.settings.appearance.colorModeDesc,
groups: [
{
- heading: 'Color mode',
+ heading: t.settings.appearance.colorMode,
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
- keywords: ['appearance', 'brightness', entry.label],
- label: entry.label,
+ keywords: ['appearance', 'brightness', t.settings.modeOptions[entry.mode].label],
+ label: t.settings.modeOptions[entry.mode].label,
run: () => setMode(entry.mode)
}))
}
]
}
}),
- [availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
+ [availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
)
const activePage = page ? subPages[page] : null
const visibleGroups = activePage ? activePage.groups : groups
- const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
+ const placeholder = activePage ? activePage.placeholder : t.commandCenter.searchPlaceholder
const handleSelect = (item: PaletteItem) => {
if (item.to) {
@@ -415,7 +437,7 @@ export function CommandPalette() {
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
- Command palette
+ {t.commandCenter.paletteTitle}
{activePage && (
- Back
+ {t.commandCenter.back}
/
{activePage.title}
@@ -448,7 +470,7 @@ export function CommandPalette() {
value={search}
/>
- No results found.
+ {t.commandCenter.noResults}
{visibleGroups.map(group => (
{
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
- failDesktopBoot('Hermes background process exited during startup.')
+ failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
}
notify({
kind: 'error',
- title: 'Backend stopped',
- message: 'Hermes background process exited.',
+ title: translateNow('boot.errors.backendStopped'),
+ message: translateNow('boot.errors.backgroundExited'),
durationMs: 0
})
})
@@ -301,7 +301,7 @@ export function useGatewayBoot({
setDesktopBootStep({
phase: 'renderer.gateway.connect',
- message: 'Connecting live desktop gateway',
+ message: translateNow('boot.steps.connectingGateway'),
progress: 95
})
publish(conn)
@@ -332,7 +332,7 @@ export function useGatewayBoot({
setDesktopBootStep({
phase: 'renderer.config',
- message: 'Loading Hermes settings',
+ message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await callbacksRef.current.refreshHermesConfig()
@@ -343,7 +343,7 @@ export function useGatewayBoot({
setDesktopBootStep({
phase: 'renderer.sessions',
- message: 'Loading recent sessions',
+ message: translateNow('boot.steps.loadingSessions'),
progress: 99
})
await callbacksRef.current.refreshSessions()
@@ -353,7 +353,7 @@ export function useGatewayBoot({
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
failDesktopBoot(message)
- notifyError(err, 'Desktop boot failed')
+ notifyError(err, translateNow('boot.errors.desktopBootFailed'))
setSessionsLoading(false)
}
}
diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx
index 261bf24e3d..2b5c4da494 100644
--- a/apps/desktop/src/app/messaging/index.tsx
+++ b/apps/desktop/src/app/messaging/index.tsx
@@ -66,141 +66,20 @@ const trimEdits = (edits: Record): Record =>
.filter(([, v]) => v)
)
-const FIELD_COPY: Record = {
- TELEGRAM_BOT_TOKEN: {
- label: 'Bot token',
- help: 'Create a bot with @BotFather, then paste the token it gives you.',
- placeholder: 'Paste Telegram bot token'
- },
- TELEGRAM_ALLOWED_USERS: {
- label: 'Allowed Telegram user IDs',
- help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
- },
- TELEGRAM_PROXY: {
- label: 'Proxy URL',
- help: 'Only needed on networks where Telegram is blocked.',
- advanced: true
- },
- DISCORD_BOT_TOKEN: {
- label: 'Bot token',
- help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
- },
- DISCORD_ALLOWED_USERS: {
- label: 'Allowed Discord user IDs',
- help: 'Recommended. Comma-separated Discord user IDs.'
- },
- DISCORD_REPLY_TO_MODE: {
- label: 'Reply style',
- help: 'first, all, or off.',
- advanced: true
- },
- DISCORD_ALLOW_ALL_USERS: {
- label: 'Allow all Discord users',
- help: 'Development only. When true, anyone can DM the bot without an allowlist.',
- advanced: true
- },
- DISCORD_HOME_CHANNEL: {
- label: 'Home channel ID',
- help: 'Channel where the bot sends proactive messages (cron output, reminders).',
- advanced: true
- },
- DISCORD_HOME_CHANNEL_NAME: {
- label: 'Home channel name',
- help: 'Display name for the home channel in logs and status output.',
- advanced: true
- },
- BLUEBUBBLES_ALLOW_ALL_USERS: {
- label: 'Allow all iMessage users',
- help: 'When true, skip the BlueBubbles allowlist.',
- advanced: true
- },
- MATTERMOST_ALLOW_ALL_USERS: {
- label: 'Allow all Mattermost users',
- advanced: true
- },
- MATTERMOST_HOME_CHANNEL: {
- label: 'Home channel',
- advanced: true
- },
- QQ_ALLOW_ALL_USERS: {
- label: 'Allow all QQ users',
- advanced: true
- },
- QQBOT_HOME_CHANNEL: {
- label: 'QQ home channel',
- help: 'Default channel or group for cron delivery.',
- advanced: true
- },
- QQBOT_HOME_CHANNEL_NAME: {
- label: 'QQ home channel name',
- advanced: true
- },
- SLACK_BOT_TOKEN: {
- label: 'Slack bot token',
- help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
- placeholder: 'Paste Slack bot token'
- },
- SLACK_APP_TOKEN: {
- label: 'Slack app token',
- help: 'Use the app-level token required for Socket Mode.',
- placeholder: 'Paste Slack app token'
- },
- SLACK_ALLOWED_USERS: {
- label: 'Allowed Slack user IDs',
- help: 'Recommended. Comma-separated Slack user IDs.'
- },
- MATTERMOST_URL: {
- label: 'Server URL',
- placeholder: 'https://mattermost.example.com'
- },
- MATTERMOST_TOKEN: {
- label: 'Bot token'
- },
- MATTERMOST_ALLOWED_USERS: {
- label: 'Allowed user IDs',
- help: 'Recommended. Comma-separated Mattermost user IDs.'
- },
- MATRIX_HOMESERVER: {
- label: 'Homeserver URL',
- placeholder: 'https://matrix.org'
- },
- MATRIX_ACCESS_TOKEN: {
- label: 'Access token'
- },
- MATRIX_USER_ID: {
- label: 'Bot user ID',
- placeholder: '@hermes:example.org'
- },
- MATRIX_ALLOWED_USERS: {
- label: 'Allowed Matrix user IDs',
- help: 'Recommended. Comma-separated user IDs in @user:server format.'
- },
- SIGNAL_HTTP_URL: {
- label: 'Signal bridge URL',
- placeholder: 'http://127.0.0.1:8080',
- help: 'URL of a running signal-cli REST bridge.'
- },
- SIGNAL_ACCOUNT: {
- label: 'Phone number',
- help: 'The number registered with your signal-cli bridge.'
- },
- SIGNAL_ALLOWED_USERS: {
- label: 'Allowed Signal users',
- help: 'Recommended. Comma-separated Signal identifiers.'
- },
- WHATSAPP_ENABLED: {
- label: 'Enable WhatsApp bridge',
- help: 'Set automatically by the toggle below. Leave alone unless you know you need it.',
- advanced: true
- },
- WHATSAPP_MODE: {
- label: 'Bridge mode',
- advanced: true
- },
- WHATSAPP_ALLOWED_USERS: {
- label: 'Allowed WhatsApp users',
- help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
- }
+const FIELD_COPY: Record = {
+ TELEGRAM_PROXY: { advanced: true },
+ DISCORD_REPLY_TO_MODE: { advanced: true },
+ DISCORD_ALLOW_ALL_USERS: { advanced: true },
+ DISCORD_HOME_CHANNEL: { advanced: true },
+ DISCORD_HOME_CHANNEL_NAME: { advanced: true },
+ BLUEBUBBLES_ALLOW_ALL_USERS: { advanced: true },
+ MATTERMOST_ALLOW_ALL_USERS: { advanced: true },
+ MATTERMOST_HOME_CHANNEL: { advanced: true },
+ QQ_ALLOW_ALL_USERS: { advanced: true },
+ QQBOT_HOME_CHANNEL: { advanced: true },
+ QQBOT_HOME_CHANNEL_NAME: { advanced: true },
+ WHATSAPP_ENABLED: { advanced: true },
+ WHATSAPP_MODE: { advanced: true }
}
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
@@ -208,9 +87,9 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
const localized = m.fieldCopy[field.key] || {}
return {
- label: localized.label || copy.label || field.prompt || field.key,
- help: localized.help || copy.help || field.description,
- placeholder: localized.placeholder || copy.placeholder || field.prompt,
+ label: localized.label || field.prompt || field.key,
+ help: localized.help || field.description,
+ placeholder: localized.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced)
}
}
diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx
index 6fb9ab38c3..8e429c3884 100644
--- a/apps/desktop/src/app/overlays/overlay-view.tsx
+++ b/apps/desktop/src/app/overlays/overlay-view.tsx
@@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { translateNow } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -17,7 +18,7 @@ interface OverlayViewProps {
export function OverlayView({
children,
onClose,
- closeLabel = 'Close',
+ closeLabel = translateNow('common.close'),
contentClassName,
headerContent,
rootClassName
diff --git a/apps/desktop/src/app/profiles/create-profile-dialog.tsx b/apps/desktop/src/app/profiles/create-profile-dialog.tsx
index 1fc34725e3..cd9b3fa0d5 100644
--- a/apps/desktop/src/app/profiles/create-profile-dialog.tsx
+++ b/apps/desktop/src/app/profiles/create-profile-dialog.tsx
@@ -7,14 +7,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
+import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
-export const PROFILE_NAME_HINT =
- 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
-
export function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
@@ -31,6 +29,8 @@ export function CreateProfileDialog({
onCreated?: (name: string) => Promise | void
open: boolean
}) {
+ const { t } = useI18n()
+ const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [soul, setSoul] = useState('')
@@ -57,7 +57,7 @@ export function CreateProfileDialog({
event.preventDefault()
if (!trimmed || invalid) {
- setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
+ setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
return
}
@@ -77,7 +77,7 @@ export function CreateProfileDialog({
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
- setError(err instanceof Error ? err.message : 'Failed to create profile')
+ setError(err instanceof Error ? err.message : p.failedCreate)
}
}
@@ -85,16 +85,14 @@ export function CreateProfileDialog({
!value && !busy && onClose()} open={open}>
- New profile
-
- Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
-
+ {p.newProfile}
+ {p.createDesc}
diff --git a/apps/desktop/src/app/profiles/delete-profile-dialog.tsx b/apps/desktop/src/app/profiles/delete-profile-dialog.tsx
index a0406203d6..2d0e209f68 100644
--- a/apps/desktop/src/app/profiles/delete-profile-dialog.tsx
+++ b/apps/desktop/src/app/profiles/delete-profile-dialog.tsx
@@ -1,5 +1,6 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteProfile } from '@/hermes'
+import { useI18n } from '@/i18n'
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
@@ -16,20 +17,26 @@ export function DeleteProfileDialog({
onDeleted?: () => Promise | void
open: boolean
}) {
+ const { t } = useI18n()
+ const p = t.profiles
+
return (
- This will delete {profile.name} and remove its{' '}
- {profile.path} directory. This cannot be undone.
+ {p.deleteDescPrefix}
+ {profile.name}
+ {p.deleteDescMid}
+ {profile.path}
+ {p.deleteDescSuffix}
>
) : null
}
destructive
- doneLabel="Deleted"
+ doneLabel={p.deleted}
onClose={onClose}
onConfirm={async () => {
if (!profile) {
@@ -52,7 +59,7 @@ export function DeleteProfileDialog({
}
}}
open={open}
- title="Delete profile?"
+ title={p.deleteTitle}
/>
)
}
diff --git a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx
index e0bf820266..3fbd0aaced 100644
--- a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx
+++ b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx
@@ -5,10 +5,11 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
+import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
+import { isValidProfileName } from './create-profile-dialog'
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
@@ -23,6 +24,8 @@ export function RenameProfileDialog({
onRenamed?: (name: string) => Promise | void
open: boolean
}) {
+ const { t } = useI18n()
+ const p = t.profiles
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState(null)
@@ -52,7 +55,7 @@ export function RenameProfileDialog({
}
if (!trimmed || invalid) {
- setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
+ setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
return
}
@@ -67,7 +70,7 @@ export function RenameProfileDialog({
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
- setError(err instanceof Error ? err.message : 'Failed to rename profile')
+ setError(err instanceof Error ? err.message : p.failedRename)
}
}
@@ -75,17 +78,18 @@ export function RenameProfileDialog({
!value && !busy && onClose()} open={open}>
- Rename profile
+ {p.renameTitle}
- Renaming updates the profile directory and any wrapper scripts in{' '}
- ~/.local/bin .
+ {p.renameDescPrefix}
+ ~/.local/bin
+ {p.renameDescSuffix}
@@ -108,10 +112,10 @@ export function RenameProfileDialog({
- Cancel
+ {t.common.cancel}
-
+
diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx
index 6f6da603e9..6421581ca8 100644
--- a/apps/desktop/src/app/right-sidebar/files/tree.tsx
+++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx
@@ -4,6 +4,7 @@ import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-
import { PageLoader } from '@/components/page-loader'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import type { TreeNode } from './use-project-tree'
@@ -122,7 +123,9 @@ export function ProjectTree({
}
function TreeSizingState() {
- return
+ const { t } = useI18n()
+
+ return
}
function ProjectTreeRow({
diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx
index 2e025c4cc1..d5707873c3 100644
--- a/apps/desktop/src/app/right-sidebar/index.tsx
+++ b/apps/desktop/src/app/right-sidebar/index.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
@@ -29,15 +30,17 @@ interface RightSidebarPaneProps {
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
- label: string
+ labelKey: 'files' | 'terminal'
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
- { id: 'files', label: 'File system', icon: 'list-tree' },
- { id: 'terminal', label: 'Terminal', icon: 'terminal' }
+ { id: 'files', labelKey: 'files', icon: 'list-tree' },
+ { id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
+ const { t } = useI18n()
+ const r = t.rightSidebar
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
@@ -50,7 +53,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
- : 'No folder selected'
+ : r.noFolderSelected
const {
collapseAll,
@@ -72,7 +75,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
- title: 'Change working directory'
+ title: r.changeCwdTitle
})
if (selected?.[0]) {
@@ -85,12 +88,12 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
- throw new Error(`Could not preview ${path}`)
+ throw new Error(r.couldNotPreview(path))
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
} catch (error) {
- notifyError(error, 'Preview unavailable')
+ notifyError(error, r.previewUnavailable)
}
}
@@ -98,7 +101,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
return (
-
- {tabs.map(tab => (
-
- setRightSidebarTab(tab.id)}
- size="icon-xs"
- variant="ghost"
- >
-
-
-
- ))}
+
+ {tabs.map(tab => {
+ const label = r[tab.labelKey]
+
+ return (
+
+ setRightSidebarTab(tab.id)}
+ size="icon-xs"
+ variant="ghost"
+ >
+
+
+
+ )
+ })}
{branch && (
@@ -214,10 +224,13 @@ function FilesystemTab({
onRefresh,
openState
}: FilesystemTabProps) {
+ const { t } = useI18n()
+ const r = t.rightSidebar
+
return (
-
+
void onChangeFolder()}
@@ -227,7 +240,7 @@ function FilesystemTab({
void onChangeFolder()}
size="icon-xs"
@@ -246,7 +259,7 @@ function FilesystemTab({
+ return
}
if (error) {
- return
+ return
}
if (loading && data.length === 0) {
@@ -317,20 +333,20 @@ function FileTreeBody({
}
if (data.length === 0) {
- return
+ return
}
return (
(
-
+
- Try again
+ {r.tryAgain}
)}
@@ -353,8 +369,10 @@ function FileTreeBody({
}
function FileTreeLoadingState() {
+ const { t } = useI18n()
+
return (
-
+
{
// Pre-select the Terminal tab so the slot is ready to host us on return.
@@ -77,7 +79,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
type="button"
variant="secondary"
>
- Add to chat
+ {t.rightSidebar.addToChat}
{addSelectionShortcutLabel()}
diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts
index ab301e5930..e10f34e929 100644
--- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts
@@ -1,5 +1,6 @@
import { type MutableRefObject, useCallback } from 'react'
+import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionRuntimeInfo } from '@/types/hermes'
@@ -17,6 +18,8 @@ export function useCwdActions({
onSessionRuntimeInfo,
requestGateway
}: CwdActionsOptions) {
+ const { t } = useI18n()
+ const copy = t.desktop
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()
@@ -85,7 +88,7 @@ export function useCwdActions({
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('unknown method')) {
- notifyError(err, 'Working directory change failed')
+ notifyError(err, copy.cwdChangeFailed)
return
}
@@ -94,12 +97,12 @@ export function useCwdActions({
setCurrentBranch('')
notify({
kind: 'warning',
- title: 'Working directory staged',
- message: 'Restart the desktop backend to apply cwd changes to this active session.'
+ title: copy.cwdStagedTitle,
+ message: copy.cwdStagedMessage
})
}
},
- [activeSessionId, onSessionRuntimeInfo, requestGateway]
+ [activeSessionId, copy, onSessionRuntimeInfo, requestGateway]
)
return { changeSessionCwd, refreshProjectBranch }
diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts
index 3d1348434a..1a04b19da7 100644
--- a/apps/desktop/src/app/session/hooks/use-model-controls.ts
+++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts
@@ -2,6 +2,7 @@ import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
+import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -19,6 +20,8 @@ interface ModelControlsOptions {
}
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
+ const { t } = useI18n()
+ const copy = t.desktop
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@@ -91,12 +94,12 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
- notifyError(err, 'Model switch failed')
+ notifyError(err, copy.modelSwitchFailed)
return false
}
},
- [activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
+ [activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index b966841344..e31a0ce07e 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -2,7 +2,8 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
-import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
+import { type Translations, translateNow, useI18n } from '@/i18n'
+import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
parseCommandDispatch,
@@ -57,10 +58,10 @@ function blobToDataUrl(blob: Blob): Promise
{
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
- reject(new Error('Could not read recorded audio'))
+ reject(new Error(translateNow('desktop.audioReadFailed')))
}
})
- reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio')))
+ reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
reader.readAsDataURL(blob)
})
}
@@ -101,12 +102,12 @@ interface SubmitTextOptions {
fromQueue?: boolean
}
-function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
+function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
- : [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }]
+ : [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
@@ -118,8 +119,8 @@ function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
.join('\n\n')
const tail = [
- desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '',
- desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : ''
+ desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
+ desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
]
.filter(Boolean)
.join('\n')
@@ -156,6 +157,8 @@ export function usePromptActions({
sttEnabled,
updateSessionState
}: PromptActionsOptions) {
+ const { t } = useI18n()
+ const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
const body = text.trim()
@@ -326,7 +329,7 @@ export function usePromptActions({
} catch (err) {
dropOptimistic(null)
releaseBusy()
- notifyError(err, 'Session unavailable')
+ notifyError(err, copy.sessionUnavailable)
return false
}
@@ -334,7 +337,7 @@ export function usePromptActions({
if (!sessionId) {
dropOptimistic(null)
releaseBusy()
- notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
+ notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return false
}
@@ -354,7 +357,7 @@ export function usePromptActions({
return true
} catch (err) {
- const message = inlineErrorMessage(err, 'Prompt failed')
+ const message = inlineErrorMessage(err, copy.promptFailed)
releaseBusy()
updateSessionState(sessionId, state => ({
@@ -365,7 +368,7 @@ export function usePromptActions({
id: `assistant-error-${Date.now()}`,
role: 'assistant',
parts: [],
- error: message || 'Prompt failed',
+ error: message || copy.promptFailed,
branchGroupId: state.pendingBranchGroup ?? undefined
}
],
@@ -376,12 +379,12 @@ export function usePromptActions({
}))
if (isProviderSetupError(err)) {
- requestDesktopOnboarding('Add a provider credential before sending your first message.')
+ requestDesktopOnboarding(copy.providerCredentialRequired)
return false
}
- notifyError(err, 'Prompt failed')
+ notifyError(err, copy.promptFailed)
return false
}
@@ -389,6 +392,7 @@ export function usePromptActions({
[
activeSessionId,
busyRef,
+ copy,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
@@ -408,7 +412,7 @@ export function usePromptActions({
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (sessionId) {
- appendSessionTextMessage(sessionId, 'system', 'empty slash command')
+ appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
@@ -435,16 +439,16 @@ export function usePromptActions({
if (!sid) {
setYoloActive(next)
- notify({ kind: 'success', message: next ? 'YOLO armed for this chat' : 'YOLO off' })
+ notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
- appendSessionTextMessage(sid, 'system', `YOLO ${active ? 'on' : 'off'} for this session`)
+ appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
- notify({ kind: 'error', title: 'YOLO', message: 'Could not toggle YOLO' })
+ notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
return
@@ -467,7 +471,7 @@ export function usePromptActions({
if (!target) {
notify({
kind: 'success',
- message: `Profile: ${current}. Use /profile or the "New session" picker to start a chat in another profile.`
+ message: copy.profileStatus(current)
})
return
@@ -480,8 +484,8 @@ export function usePromptActions({
if (!match) {
notify({
kind: 'error',
- title: 'Unknown profile',
- message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
+ title: copy.unknownProfile,
+ message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
@@ -493,9 +497,9 @@ export function usePromptActions({
// Swap the live gateway now so an empty draft sends into this
// profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
- notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
+ notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
- notifyError(err, 'Failed to set profile')
+ notifyError(err, copy.setProfileFailed)
}
return
@@ -506,8 +510,8 @@ export function usePromptActions({
if (!sessionId) {
notify({
kind: 'error',
- title: 'Session unavailable',
- message: 'Could not create a new session'
+ title: copy.sessionUnavailable,
+ message: copy.createSessionFailed
})
return
@@ -570,7 +574,7 @@ export function usePromptActions({
try {
const catalog = await requestGateway('commands.catalog', { session_id: sessionId })
- renderSlashOutput(renderCommandsCatalog(catalog))
+ renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
@@ -658,6 +662,7 @@ export function usePromptActions({
appendSessionTextMessage,
branchCurrentSession,
busyRef,
+ copy,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
@@ -687,7 +692,7 @@ export function usePromptActions({
const transcribeVoiceAudio = useCallback(
async (audio: Blob) => {
if (!sttEnabled) {
- throw new Error('Speech-to-text is disabled in settings.')
+ throw new Error(copy.sttDisabled)
}
const dataUrl = await blobToDataUrl(audio)
@@ -695,7 +700,7 @@ export function usePromptActions({
return result.transcript
},
- [sttEnabled]
+ [copy.sttDisabled, sttEnabled]
)
const cancelRun = useCallback(async () => {
@@ -745,9 +750,9 @@ export function usePromptActions({
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
- notifyError(err, 'Stop failed')
+ notifyError(err, copy.stopFailed)
}
- }, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
+ }, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
@@ -853,10 +858,10 @@ export function usePromptActions({
busy: false,
awaitingResponse: false
}))
- notifyError(err, 'Regenerate failed')
+ notifyError(err, copy.regenerateFailed)
}
},
- [activeSessionId, requestGateway, updateSessionState]
+ [activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
)
const editMessage = useCallback(
@@ -926,10 +931,10 @@ export function usePromptActions({
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
- notifyError(surfaced, 'Edit failed')
+ notifyError(surfaced, copy.editFailed)
}
},
- [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]
+ [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts
index b0a4c7efc1..ca39d77853 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts
@@ -3,6 +3,7 @@ import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
+import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
@@ -285,6 +286,8 @@ export function useSessionActions({
syncSessionStateToView,
updateSessionState
}: SessionActionsOptions) {
+ const { t } = useI18n()
+ const copy = t.desktop
const resumeRequestRef = useRef(0)
const startFreshSessionDraft = useCallback(
@@ -602,7 +605,7 @@ export function useSessionActions({
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
- notifyError(err, 'Resume failed')
+ notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {
busyRef.current = false
@@ -614,6 +617,7 @@ export function useSessionActions({
[
activeSessionIdRef,
busyRef,
+ copy,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
@@ -630,8 +634,8 @@ export function useSessionActions({
if (!sourceSessionId) {
notify({
kind: 'warning',
- title: 'Nothing to branch',
- message: 'Start or resume a chat before branching.'
+ title: copy.nothingToBranch,
+ message: copy.branchNeedsChat
})
return false
@@ -640,8 +644,8 @@ export function useSessionActions({
if (busyRef.current) {
notify({
kind: 'warning',
- title: 'Session busy',
- message: 'Stop the current turn before branching this chat.'
+ title: copy.sessionBusy,
+ message: copy.branchStopCurrent
})
return false
@@ -671,8 +675,8 @@ export function useSessionActions({
if (!branchMessages.length) {
notify({
kind: 'warning',
- title: 'Nothing to branch',
- message: 'This message has no text to branch from.'
+ title: copy.nothingToBranch,
+ message: copy.branchNoText
})
return false
@@ -686,14 +690,14 @@ export function useSessionActions({
cols: 96,
...(cwd && { cwd }),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
- title: 'Branch'
+ title: copy.branchTitle
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
setFreshDraftReady(false)
- upsertOptimisticSession(branched, routedSessionId, 'Branch', preview)
+ upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
@@ -723,7 +727,7 @@ export function useSessionActions({
return true
} catch (err) {
- notifyError(err, 'Branch failed')
+ notifyError(err, copy.branchFailed)
return false
} finally {
@@ -735,6 +739,7 @@ export function useSessionActions({
[
activeSessionIdRef,
busyRef,
+ copy,
creatingSessionRef,
ensureSessionState,
navigate,
@@ -812,12 +817,13 @@ export function useSessionActions({
}
}
- notifyError(err, 'Delete failed')
+ notifyError(err, copy.deleteFailed)
}
},
[
activeSessionId,
activeSessionIdRef,
+ copy,
navigate,
requestGateway,
selectedStoredSessionId,
@@ -851,7 +857,7 @@ export function useSessionActions({
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
- notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
+ notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
@@ -859,10 +865,10 @@ export function useSessionActions({
}
$pinnedSessionIds.set(previousPinned)
- notifyError(err, 'Archive failed')
+ notifyError(err, copy.archiveFailed)
}
},
- [selectedStoredSessionId, startFreshSessionDraft]
+ [copy, selectedStoredSessionId, startFreshSessionDraft]
)
return {
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 8645162b78..0c12d55118 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -39,6 +39,7 @@ function ConfigField({
onChange: (value: unknown) => void
}) {
const { t } = useI18n()
+ const c = t.settings.config
const label =
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
@@ -88,8 +89,8 @@ function ConfigField({
{option
? (optionLabels?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
- ? 'None'
- : '(none)'}
+ ? c.none
+ : c.noneParen}
))}
@@ -109,7 +110,7 @@ function ConfigField({
onChange(n)
}
}}
- placeholder="Not set"
+ placeholder={c.notSet}
type="number"
value={value === undefined || value === null ? '' : String(value)}
/>
@@ -128,7 +129,7 @@ function ConfigField({
.filter(Boolean)
)
}
- placeholder="comma-separated values"
+ placeholder={c.commaSeparated}
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
/>
)
@@ -145,7 +146,7 @@ function ConfigField({
/* keep last valid */
}
}}
- placeholder="Not set"
+ placeholder={c.notSet}
spellCheck={false}
value={JSON.stringify(value, null, 2)}
/>,
@@ -160,14 +161,14 @@ function ConfigField({
onChange(e.target.value)}
- placeholder="Not set"
+ placeholder={c.notSet}
value={String(value ?? '')}
/>
) : (
onChange(e.target.value)}
- placeholder="Not set"
+ placeholder={c.notSet}
value={String(value ?? '')}
/>
),
@@ -186,6 +187,8 @@ export function ConfigSettings({
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject
}) {
+ const { t } = useI18n()
+ const c = t.settings.config
const [config, setConfig] = useState(null)
const [_defaults, setDefaults] = useState(null)
const [schema, setSchema] = useState | null>(null)
@@ -206,7 +209,7 @@ export function ConfigSettings({
setDefaults(d)
setSchema(s.fields)
})
- .catch(err => notifyError(err, 'Settings failed to load'))
+ .catch(err => notifyError(err, c.failedLoad))
return () => void (cancelled = true)
}, [])
@@ -250,7 +253,7 @@ export function ConfigSettings({
}
} catch (err) {
if (saveVersionRef.current === v) {
- notifyError(err, 'Autosave failed')
+ notifyError(err, c.autosaveFailed)
}
}
})()
@@ -323,9 +326,9 @@ export function ConfigSettings({
reader.onload = () => {
try {
updateConfig(JSON.parse(String(reader.result)))
- notify({ kind: 'success', title: 'Config imported', message: 'Saving…' })
+ notify({ kind: 'success', title: c.imported, message: t.common.saving })
} catch (err) {
- notifyError(err, 'Invalid config JSON')
+ notifyError(err, c.invalidJson)
}
}
@@ -334,7 +337,7 @@ export function ConfigSettings({
}
if (!config || !schema) {
- return
+ return
}
return (
@@ -345,7 +348,7 @@ export function ConfigSettings({
)}
{fields.length === 0 ? (
-
+
) : (
{fields.map(([key, field]) => (
diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts
index 99efb34258..6fb8ad9e97 100644
--- a/apps/desktop/src/app/settings/constants.ts
+++ b/apps/desktop/src/app/settings/constants.ts
@@ -14,6 +14,7 @@ import {
import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
+import { defineFieldCopy } from './field-copy'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
@@ -245,103 +246,175 @@ export const ENUM_OPTIONS: Record = {
'updates.non_interactive_local_changes': ['stash', 'discard']
}
-export const FIELD_LABELS: Record = {
+export const FIELD_LABELS: Record = defineFieldCopy({
model: 'Default Model',
model_context_length: 'Context Window',
fallback_providers: 'Fallback Models',
toolsets: 'Enabled Toolsets',
timezone: 'Timezone',
- 'display.personality': 'Personality',
- 'display.show_reasoning': 'Reasoning Blocks',
- 'agent.max_turns': 'Max Agent Steps',
- 'agent.image_input_mode': 'Image Attachments',
- 'terminal.cwd': 'Working Directory',
- 'terminal.backend': 'Execution Backend',
- 'terminal.timeout': 'Command Timeout',
- 'terminal.persistent_shell': 'Persistent Shell',
- 'terminal.env_passthrough': 'Environment Passthrough',
+ display: {
+ personality: 'Personality',
+ show_reasoning: 'Reasoning Blocks'
+ },
+ agent: {
+ max_turns: 'Max Agent Steps',
+ image_input_mode: 'Image Attachments',
+ api_max_retries: 'API Retries',
+ service_tier: 'Service Tier',
+ tool_use_enforcement: 'Tool-Use Enforcement'
+ },
+ terminal: {
+ cwd: 'Working Directory',
+ backend: 'Execution Backend',
+ timeout: 'Command Timeout',
+ persistent_shell: 'Persistent Shell',
+ env_passthrough: 'Environment Passthrough'
+ },
file_read_max_chars: 'File Read Limit',
- 'tool_output.max_bytes': 'Terminal Output Limit',
- 'tool_output.max_lines': 'File Page Limit',
- 'tool_output.max_line_length': 'Line Length Limit',
- 'code_execution.mode': 'Code Execution Mode',
- 'approvals.mode': 'Approval Mode',
- 'approvals.timeout': 'Approval Timeout',
- 'approvals.mcp_reload_confirm': 'Confirm MCP Reloads',
+ tool_output: {
+ max_bytes: 'Terminal Output Limit',
+ max_lines: 'File Page Limit',
+ max_line_length: 'Line Length Limit'
+ },
+ code_execution: {
+ mode: 'Code Execution Mode'
+ },
+ approvals: {
+ mode: 'Approval Mode',
+ timeout: 'Approval Timeout',
+ mcp_reload_confirm: 'Confirm MCP Reloads'
+ },
command_allowlist: 'Command Allowlist',
- 'security.redact_secrets': 'Redact Secrets',
- 'security.allow_private_urls': 'Allow Private URLs',
- 'browser.allow_private_urls': 'Browser Private URLs',
- 'browser.auto_local_for_private_urls': 'Local Browser For Private URLs',
- 'checkpoints.enabled': 'File Checkpoints',
- 'checkpoints.max_snapshots': 'Checkpoint Limit',
- 'voice.record_key': 'Voice Shortcut',
- 'voice.max_recording_seconds': 'Max Recording Length',
- 'voice.auto_tts': 'Read Responses Aloud',
- 'stt.enabled': 'Speech To Text',
- 'stt.provider': 'Speech-To-Text Provider',
- 'stt.local.model': 'Local Transcription Model',
- 'stt.local.language': 'Transcription Language',
- 'stt.elevenlabs.model_id': 'ElevenLabs STT Model',
- 'stt.elevenlabs.language_code': 'ElevenLabs Language',
- 'stt.elevenlabs.tag_audio_events': 'Tag Audio Events',
- 'stt.elevenlabs.diarize': 'Speaker Diarization',
- 'tts.provider': 'Text-To-Speech Provider',
- 'tts.edge.voice': 'Edge Voice',
- 'tts.openai.model': 'OpenAI TTS Model',
- 'tts.openai.voice': 'OpenAI Voice',
- 'tts.elevenlabs.voice_id': 'ElevenLabs Voice',
- 'tts.elevenlabs.model_id': 'ElevenLabs Model',
- 'memory.memory_enabled': 'Persistent Memory',
- 'memory.user_profile_enabled': 'User Profile',
- 'memory.memory_char_limit': 'Memory Budget',
- 'memory.user_char_limit': 'Profile Budget',
- 'memory.provider': 'Memory Provider',
- 'context.engine': 'Context Engine',
- 'compression.enabled': 'Auto-Compression',
- 'compression.threshold': 'Compression Threshold',
- 'compression.target_ratio': 'Compression Target',
- 'compression.protect_last_n': 'Protected Recent Messages',
- 'agent.api_max_retries': 'API Retries',
- 'agent.service_tier': 'Service Tier',
- 'agent.tool_use_enforcement': 'Tool-Use Enforcement',
- 'delegation.model': 'Subagent Model',
- 'delegation.provider': 'Subagent Provider',
- 'delegation.max_iterations': 'Subagent Turn Limit',
- 'delegation.max_concurrent_children': 'Parallel Subagents',
- 'delegation.child_timeout_seconds': 'Subagent Timeout',
- 'delegation.reasoning_effort': 'Subagent Reasoning Effort',
- 'updates.non_interactive_local_changes': 'In-App Update Local Changes'
-}
+ security: {
+ redact_secrets: 'Redact Secrets',
+ allow_private_urls: 'Allow Private URLs'
+ },
+ browser: {
+ allow_private_urls: 'Browser Private URLs',
+ auto_local_for_private_urls: 'Local Browser For Private URLs'
+ },
+ checkpoints: {
+ enabled: 'File Checkpoints',
+ max_snapshots: 'Checkpoint Limit'
+ },
+ voice: {
+ record_key: 'Voice Shortcut',
+ max_recording_seconds: 'Max Recording Length',
+ auto_tts: 'Read Responses Aloud'
+ },
+ stt: {
+ enabled: 'Speech To Text',
+ provider: 'Speech-To-Text Provider',
+ local: {
+ model: 'Local Transcription Model',
+ language: 'Transcription Language'
+ },
+ elevenlabs: {
+ model_id: 'ElevenLabs STT Model',
+ language_code: 'ElevenLabs Language',
+ tag_audio_events: 'Tag Audio Events',
+ diarize: 'Speaker Diarization'
+ }
+ },
+ tts: {
+ provider: 'Text-To-Speech Provider',
+ edge: {
+ voice: 'Edge Voice'
+ },
+ openai: {
+ model: 'OpenAI TTS Model',
+ voice: 'OpenAI Voice'
+ },
+ elevenlabs: {
+ voice_id: 'ElevenLabs Voice',
+ model_id: 'ElevenLabs Model'
+ }
+ },
+ memory: {
+ memory_enabled: 'Persistent Memory',
+ user_profile_enabled: 'User Profile',
+ memory_char_limit: 'Memory Budget',
+ user_char_limit: 'Profile Budget',
+ provider: 'Memory Provider'
+ },
+ context: {
+ engine: 'Context Engine'
+ },
+ compression: {
+ enabled: 'Auto-Compression',
+ threshold: 'Compression Threshold',
+ target_ratio: 'Compression Target',
+ protect_last_n: 'Protected Recent Messages'
+ },
+ delegation: {
+ model: 'Subagent Model',
+ provider: 'Subagent Provider',
+ max_iterations: 'Subagent Turn Limit',
+ max_concurrent_children: 'Parallel Subagents',
+ child_timeout_seconds: 'Subagent Timeout',
+ reasoning_effort: 'Subagent Reasoning Effort'
+ },
+ updates: {
+ non_interactive_local_changes: 'In-App Update Local Changes'
+ }
+})
-export const FIELD_DESCRIPTIONS: Record = {
+export const FIELD_DESCRIPTIONS: Record = defineFieldCopy({
model: 'Used for new chats unless you pick a different model in the composer.',
model_context_length: "Leave at 0 to use the selected model's detected context window.",
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
- 'display.personality': 'Default assistant style for new sessions.',
+ display: {
+ personality: 'Default assistant style for new sessions.',
+ show_reasoning: 'Show reasoning sections when the backend provides them.'
+ },
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
- 'display.show_reasoning': 'Show reasoning sections when the backend provides them.',
- 'agent.image_input_mode': 'Controls how image attachments are sent to the model.',
- 'terminal.cwd': 'Default project folder for tool and terminal work.',
- 'code_execution.mode': 'How strictly code execution is scoped to the current project.',
- 'terminal.persistent_shell': 'Keep shell state between commands when the backend supports it.',
- 'terminal.env_passthrough': 'Environment variables to pass into tool execution.',
+ agent: {
+ image_input_mode: 'Controls how image attachments are sent to the model.',
+ max_turns: 'Upper bound for tool-calling turns before Hermes stops a run.'
+ },
+ terminal: {
+ cwd: 'Default project folder for tool and terminal work.',
+ persistent_shell: 'Keep shell state between commands when the backend supports it.',
+ env_passthrough: 'Environment variables to pass into tool execution.'
+ },
+ code_execution: {
+ mode: 'How strictly code execution is scoped to the current project.'
+ },
file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
- 'approvals.mode': 'How Hermes handles commands that need explicit approval.',
- 'approvals.timeout': 'How long approval prompts wait before timing out.',
- 'security.redact_secrets': 'Hide detected secrets from model-visible content when possible.',
- 'checkpoints.enabled': 'Create rollback snapshots before file edits.',
- 'memory.memory_enabled': 'Save durable memories that can help future sessions.',
- 'memory.user_profile_enabled': 'Maintain a compact profile of user preferences.',
- 'context.engine': 'Strategy for managing long conversations near the context limit.',
- 'compression.enabled': 'Summarize older context when conversations get large.',
- 'voice.auto_tts': 'Automatically speak assistant responses.',
- 'stt.enabled': 'Enable local or provider-backed speech transcription.',
- 'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
- 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
- 'updates.non_interactive_local_changes':
- 'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
-}
+ approvals: {
+ mode: 'How Hermes handles commands that need explicit approval.',
+ timeout: 'How long approval prompts wait before timing out.'
+ },
+ security: {
+ redact_secrets: 'Hide detected secrets from model-visible content when possible.'
+ },
+ checkpoints: {
+ enabled: 'Create rollback snapshots before file edits.'
+ },
+ memory: {
+ memory_enabled: 'Save durable memories that can help future sessions.',
+ user_profile_enabled: 'Maintain a compact profile of user preferences.'
+ },
+ context: {
+ engine: 'Strategy for managing long conversations near the context limit.'
+ },
+ compression: {
+ enabled: 'Summarize older context when conversations get large.'
+ },
+ voice: {
+ auto_tts: 'Automatically speak assistant responses.'
+ },
+ stt: {
+ enabled: 'Enable local or provider-backed speech transcription.',
+ elevenlabs: {
+ language_code: 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.'
+ }
+ },
+ updates: {
+ non_interactive_local_changes:
+ 'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
+ }
+})
// Curated desktop config surface: only fields a user might tune from the app.
export const SECTIONS: DesktopConfigSection[] = [
diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx
index 8003b34875..614fdcf34e 100644
--- a/apps/desktop/src/app/settings/credential-key-ui.tsx
+++ b/apps/desktop/src/app/settings/credential-key-ui.tsx
@@ -2,6 +2,7 @@ import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
+import { translateNow, useI18n } from '@/i18n'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
@@ -27,7 +28,11 @@ export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
- isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
+ isKeyVar(key, info)
+ ? translateNow('settings.credentials.pasteLabelKey', label)
+ : /URL$/i.test(key)
+ ? 'https://…'
+ : translateNow('settings.credentials.optional')
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
@@ -43,6 +48,7 @@ export function KeyField({
rowProps: KeyRowProps
varKey: string
}) {
+ const { t } = useI18n()
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
@@ -84,14 +90,14 @@ export function KeyField({
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
- placeholder={placeholder ?? 'Paste key'}
+ placeholder={placeholder ?? t.settings.credentials.pasteKey}
type={editType}
value={draft}
/>
{dirty && (
void onSave(varKey)} size="sm">
{busy ? : }
- {busy ? 'Saving' : 'Save'}
+ {busy ? t.settings.credentials.saving : t.common.save}
)}
@@ -106,12 +112,12 @@ export function KeyField({
type="button"
variant="text"
>
- Remove
+ {t.settings.credentials.remove}
- or
+ {t.settings.credentials.or}
>
)}
- esc to cancel
+ {t.settings.credentials.escToCancel}
)}
@@ -119,6 +125,8 @@ export function KeyField({
}
function CredentialDocsLink({ href }: { href: string }) {
+ const { t } = useI18n()
+
return (
- Get a key
+ {t.settings.credentials.getKey}
)
@@ -223,6 +231,7 @@ export function CredentialKeyCard({
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
+ const { t } = useI18n()
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
@@ -283,7 +292,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
>
diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx
index f0ea858ad1..5442ae5dd8 100644
--- a/apps/desktop/src/app/settings/env-credentials.tsx
+++ b/apps/desktop/src/app/settings/env-credentials.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
+import { useI18n } from '@/i18n'
import { type IconComponent } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
@@ -41,6 +42,9 @@ export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHe
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing.
export function useEnvCredentials(): UseEnvCredentials {
+ const { t } = useI18n()
+ const credentials = t.settings.credentials
+ const toolsets = t.settings.toolsets
const [vars, setVars] = useState | null>(null)
const [edits, setEdits] = useState>({})
const [revealed, setRevealed] = useState>({})
@@ -67,7 +71,7 @@ export function useEnvCredentials(): UseEnvCredentials {
setVars(next)
}
} catch (err) {
- notifyError(err, 'API keys failed to load')
+ notifyError(err, t.settings.keys.failedLoad)
}
})()
@@ -96,9 +100,9 @@ export function useEnvCredentials(): UseEnvCredentials {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
- notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
+ notify({ kind: 'success', title: toolsets.savedTitle, message: toolsets.savedMessage(key) })
} catch (err) {
- notifyError(err, `Failed to save ${key}`)
+ notifyError(err, toolsets.failedSave(key))
} finally {
setSaving(null)
}
@@ -111,7 +115,7 @@ export function useEnvCredentials(): UseEnvCredentials {
const trimmed = value.trim()
if (!trimmed) {
- return { message: 'Enter a value first.', ok: false }
+ return { message: credentials.enterValueFirst, ok: false }
}
setSaving(key)
@@ -120,20 +124,20 @@ export function useEnvCredentials(): UseEnvCredentials {
await setEnvVar(key, trimmed)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
- notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
+ notify({ kind: 'success', message: toolsets.savedMessage(key), title: toolsets.savedTitle })
return { ok: true }
} catch (err) {
- notifyError(err, `Failed to save ${key}`)
+ notifyError(err, toolsets.failedSave(key))
- return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
+ return { message: err instanceof Error ? err.message : credentials.couldNotSave, ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
- if (!window.confirm(`Remove ${key} from .env?`)) {
+ if (!window.confirm(toolsets.removeConfirm(key))) {
return
}
@@ -143,9 +147,9 @@ export function useEnvCredentials(): UseEnvCredentials {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
- notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
+ notify({ kind: 'success', title: toolsets.removedTitle, message: toolsets.removedMessage(key) })
} catch (err) {
- notifyError(err, `Failed to remove ${key}`)
+ notifyError(err, toolsets.failedRemove(key))
} finally {
setSaving(null)
}
@@ -162,7 +166,7 @@ export function useEnvCredentials(): UseEnvCredentials {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
- notifyError(err, `Failed to reveal ${key}`)
+ notifyError(err, toolsets.failedReveal(key))
}
}
diff --git a/apps/desktop/src/app/settings/env-var-actions-menu.tsx b/apps/desktop/src/app/settings/env-var-actions-menu.tsx
index 709d3aee91..31da15a05e 100644
--- a/apps/desktop/src/app/settings/env-var-actions-menu.tsx
+++ b/apps/desktop/src/app/settings/env-var-actions-menu.tsx
@@ -9,6 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
+import { useI18n } from '@/i18n'
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -41,6 +42,8 @@ export function EnvVarActionsMenu({
showReveal = true,
sideOffset = 6
}: EnvVarActionsMenuProps) {
+ const { t } = useI18n()
+ const copy = t.settings.envActions
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasDocs = Boolean(docsUrl?.trim())
@@ -50,7 +53,7 @@ export function EnvVarActionsMenu({
{children}
@@ -63,7 +66,7 @@ export function EnvVarActionsMenu({
}}
>
- Docs
+ {copy.docs}
)}
@@ -75,7 +78,7 @@ export function EnvVarActionsMenu({
}}
>
{isRevealed ? : }
- {isRevealed ? 'Hide value' : 'Reveal value'}
+ {isRevealed ? copy.hideValue : copy.revealValue}
)}
@@ -86,7 +89,7 @@ export function EnvVarActionsMenu({
}}
>
- {isSet ? 'Replace' : 'Set'}
+ {isSet ? copy.replace : copy.set}
{hasClear && (
@@ -101,7 +104,7 @@ export function EnvVarActionsMenu({
variant="destructive"
>
- Clear
+ {copy.clear}
>
)}
@@ -115,12 +118,15 @@ interface EnvVarActionsTriggerProps extends Omit
diff --git a/apps/desktop/src/app/settings/field-copy.ts b/apps/desktop/src/app/settings/field-copy.ts
new file mode 100644
index 0000000000..9077f6d685
--- /dev/null
+++ b/apps/desktop/src/app/settings/field-copy.ts
@@ -0,0 +1,44 @@
+export interface FieldCopyTree {
+ [key: string]: string | FieldCopyTree
+}
+
+function isFieldCopyTree(value: unknown): value is FieldCopyTree {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+export function defineFieldCopy(copy: FieldCopyTree): Record {
+ const result: Record = {}
+
+ const visit = (node: FieldCopyTree, prefix: string[] = []) => {
+ for (const [key, value] of Object.entries(node)) {
+ const parts = key.split('.')
+
+ if (parts.some(part => part.length === 0)) {
+ throw new Error(`Invalid field copy key: ${[...prefix, key].join('.')}`)
+ }
+
+ const path = [...prefix, ...parts]
+
+ if (typeof value === 'string') {
+ const flatKey = path.join('.')
+
+ if (Object.prototype.hasOwnProperty.call(result, flatKey)) {
+ throw new Error(`Duplicate field copy key: ${flatKey}`)
+ }
+
+ result[flatKey] = value
+ continue
+ }
+
+ if (!isFieldCopyTree(value)) {
+ throw new Error(`Invalid field copy value for key: ${path.join('.')}`)
+ }
+
+ visit(value, path)
+ }
+ }
+
+ visit(copy)
+
+ return result
+}
diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx
index 7c6368e97f..7e9f9be595 100644
--- a/apps/desktop/src/app/settings/gateway-settings.tsx
+++ b/apps/desktop/src/app/settings/gateway-settings.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
+import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -94,6 +95,8 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string
}
export function GatewaySettings() {
+ const { t } = useI18n()
+ const g = t.settings.gateway
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
@@ -144,7 +147,7 @@ export function GatewaySettings() {
setState(config)
})
- .catch(err => notifyError(err, 'Gateway settings failed to load'))
+ .catch(err => notifyError(err, g.failedLoad))
.finally(() => {
if (!cancelled) {
setLoading(false)
@@ -242,8 +245,8 @@ export function GatewaySettings() {
return providers.map(p => p.displayName || p.name).join(' / ')
}
- return 'your identity provider'
- }, [probe])
+ return t.boot.failure.identityProvider
+ }, [probe, t.boot.failure.identityProvider])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
@@ -288,11 +291,11 @@ export function GatewaySettings() {
if (state.mode === 'remote' && !canUseRemote) {
notify({
kind: 'warning',
- title: 'Remote gateway incomplete',
+ title: g.incompleteTitle,
message:
authMode === 'oauth'
- ? 'Enter a remote URL and sign in before switching to remote.'
- : 'Enter a remote URL and session token before switching to remote.'
+ ? g.incompleteSignIn
+ : g.incompleteToken
})
return
@@ -309,11 +312,11 @@ export function GatewaySettings() {
setRemoteToken('')
notify({
kind: 'success',
- title: apply ? 'Gateway connection restarting' : 'Gateway settings saved',
- message: apply ? 'Hermes Desktop will reconnect using the saved settings.' : 'Saved for the next restart.'
+ title: apply ? g.restartingTitle : g.savedTitle,
+ message: apply ? g.restartingMessage : g.savedMessage
})
} catch (err) {
- notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings')
+ notifyError(err, apply ? g.applyFailed : g.saveFailed)
} finally {
setSaving(false)
}
@@ -324,7 +327,7 @@ export function GatewaySettings() {
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
if (!trimmedUrl) {
- notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
+ notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })
return
}
@@ -348,16 +351,16 @@ export function GatewaySettings() {
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
- notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
+ notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
} else {
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, g.signInFailed)
} finally {
setSigningIn(false)
}
@@ -370,9 +373,9 @@ export function GatewaySettings() {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
- notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
+ notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
} catch (err) {
- notifyError(err, 'Sign-out failed')
+ notifyError(err, g.signOutFailed)
} finally {
setSigningIn(false)
}
@@ -382,11 +385,11 @@ export function GatewaySettings() {
if (!canUseRemote) {
notify({
kind: 'warning',
- title: 'Remote gateway incomplete',
+ title: g.incompleteTitle,
message:
authMode === 'oauth'
- ? 'Enter a remote URL and sign in before testing.'
- : 'Enter a remote URL and session token before testing.'
+ ? g.incompleteSignInTest
+ : g.incompleteTokenTest
})
return
@@ -404,25 +407,25 @@ export function GatewaySettings() {
remoteUrl: trimmedUrl
})
- const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
+ const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
setLastTest(message)
- notify({ kind: 'success', title: 'Remote gateway reachable', message })
+ notify({ kind: 'success', title: g.reachableTitle, message })
} catch (err) {
- notifyError(err, 'Remote gateway test failed')
+ notifyError(err, g.testFailed)
} finally {
setTesting(false)
}
}
if (loading) {
- return
+ return
}
if (!window.hermesDesktop?.getConnectionConfig) {
return (
)
}
@@ -432,23 +435,21 @@ export function GatewaySettings() {
- Gateway Connection
- {state.envOverride ?
env override : null}
+ {g.title}
+ {state.envOverride ?
{g.envOverride} : null}
- Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
- an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
- its own remote host.
+ {g.intro}
{namedProfiles.length > 0 ? (
- Applies to
+ {g.appliesTo}
-
setScope(null)} />
+ setScope(null)} />
{namedProfiles.map(profile => (
- {scope === null
- ? 'Default connection for every profile that has no override of its own.'
- : `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
+ {scope === null ? g.defaultConnection : g.profileConnection(scope)}
) : null}
@@ -470,10 +469,9 @@ export function GatewaySettings() {
-
Environment variables are controlling this desktop session.
+
{g.envOverrideTitle}
- Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved
- setting below.
+ {g.envOverrideDesc}
@@ -482,19 +480,19 @@ export function GatewaySettings() {
setState(current => ({ ...current, mode: 'local' }))}
- title="Local gateway"
+ title={g.localTitle}
/>
setState(current => ({ ...current, mode: 'remote' }))}
- title="Remote gateway"
+ title={g.remoteTitle}
/>
@@ -509,21 +507,21 @@ export function GatewaySettings() {
value={state.remoteUrl}
/>
}
- description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
- title="Remote URL"
+ description={g.remoteUrlDesc}
+ title={g.remoteUrlTitle}
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
- Checking how this gateway authenticates…
+ {g.probing}
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
- Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.
+ {g.probeError}
) : null}
@@ -534,30 +532,30 @@ export function GatewaySettings() {
oauthConnected ? (
- Signed in
+ {g.signedIn}
void signOut()} variant="outline">
{signingIn ? : null}
- Sign out
+ {g.signOut}
) : (
void signIn()}>
{signingIn ? : }
- {isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
+ {isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
)
}
description={
oauthConnected
? isPasswordProvider
- ? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
- : 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
+ ? g.authSignedInPassword
+ : g.authSignedInOauth
: isPasswordProvider
- ? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
- : `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
+ ? g.authNeedsPassword
+ : g.authNeedsOauth(providerLabel)
}
- title="Authentication"
+ title={g.authTitle}
/>
) : null}
@@ -571,14 +569,14 @@ export function GatewaySettings() {
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
- state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
+ state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken
}
type="password"
value={remoteToken}
/>
}
- description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
- title="Session token"
+ description={g.tokenDesc}
+ title={g.tokenTitle}
/>
) : null}
@@ -594,14 +592,14 @@ export function GatewaySettings() {
variant="text"
>
{testing ? : null}
- Test remote
+ {g.testRemote}
void save(false)} size="sm" variant="textStrong">
- Save for next restart
+ {g.saveForRestart}
void save(true)} size="sm">
{saving ? : null}
- Save and reconnect
+ {g.saveAndReconnect}
@@ -610,11 +608,11 @@ export function GatewaySettings() {
action={
void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
- Open logs
+ {g.openLogs}
}
- description="Reveal desktop.log in your file manager — useful when the gateway fails to start."
- title="Diagnostics"
+ description={g.diagnosticsDesc}
+ title={g.diagnostics}
/>
diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts
index ff793e4a00..e607c165bc 100644
--- a/apps/desktop/src/app/settings/helpers.test.ts
+++ b/apps/desktop/src/app/settings/helpers.test.ts
@@ -2,9 +2,53 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
+import { defineFieldCopy } from './field-copy'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
+ describe('defineFieldCopy', () => {
+ it('flattens nested field copy paths', () => {
+ const copy = defineFieldCopy({
+ display: {
+ personality: 'Personality'
+ },
+ stt: {
+ elevenlabs: {
+ language_code: 'Language'
+ }
+ }
+ })
+
+ expect(copy[['display', 'personality'].join('.')]).toBe('Personality')
+ expect(copy[['stt', 'elevenlabs', 'language_code'].join('.')]).toBe('Language')
+ })
+
+ it('keeps top-level flat field keys', () => {
+ expect(
+ defineFieldCopy({
+ model_context_length: 'Context Window',
+ file_read_max_chars: 'File Read Limit'
+ })
+ ).toEqual({
+ model_context_length: 'Context Window',
+ file_read_max_chars: 'File Read Limit'
+ })
+ })
+
+ it('rejects duplicate flattened paths', () => {
+ const duplicateKey = ['display', 'personality'].join('.')
+
+ expect(() =>
+ defineFieldCopy({
+ display: {
+ personality: 'Personality'
+ },
+ [duplicateKey]: 'Duplicate'
+ })
+ ).toThrow('Duplicate field copy key: display.personality')
+ })
+ })
+
it('reads and writes nested config paths', () => {
const config: HermesConfigRecord = { display: { theme: 'mono' } }
const next = setNested(config, 'display.theme', 'slate')
diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx
index 89545acc4f..3f69c0166b 100644
--- a/apps/desktop/src/app/settings/keys-settings.tsx
+++ b/apps/desktop/src/app/settings/keys-settings.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
+import { useI18n } from '@/i18n'
import type { EnvVarInfo } from '@/types/hermes'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
@@ -27,6 +28,7 @@ const VIEW_CATEGORIES: Record = {
}
export function KeysSettings({ view }: KeysSettingsProps) {
+ const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [openKey, setOpenKey] = useState(null)
@@ -51,7 +53,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
}, [vars])
if (!vars) {
- return
+ return
}
const visible = groups.filter(g => g.category === view)
@@ -82,7 +84,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
{visible.length === 0 && (
- Nothing configured in this category yet.
+ {t.settings.keys.empty}
)}
diff --git a/apps/desktop/src/app/settings/mcp-settings.tsx b/apps/desktop/src/app/settings/mcp-settings.tsx
index 7ff9a7c5e9..d342428ed6 100644
--- a/apps/desktop/src/app/settings/mcp-settings.tsx
+++ b/apps/desktop/src/app/settings/mcp-settings.tsx
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
+import { useI18n } from '@/i18n'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -43,6 +44,8 @@ const transportLabel = (server: Record) =>
: 'custom'
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
+ const { t } = useI18n()
+ const m = t.settings.mcp
const activeSessionId = useStore($activeSessionId)
const [config, setConfig] = useState(null)
const [selected, setSelected] = useState(null)
@@ -64,7 +67,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const first = Object.keys(getServers(next)).sort()[0] ?? null
setSelected(first)
})
- .catch(err => notifyError(err, 'MCP config failed to load'))
+ .catch(err => notifyError(err, m.failedLoad))
return () => void (cancelled = true)
}, [])
@@ -88,14 +91,14 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
}, [selected, servers])
if (!config) {
- return
+ return
}
const saveServer = async () => {
const nextName = name.trim()
if (!nextName) {
- notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' })
+ notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
return
}
@@ -106,12 +109,12 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const raw = JSON.parse(body)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
- throw new Error('Server config must be a JSON object')
+ throw new Error(m.objectRequired)
}
parsed = raw as Record
} catch (err) {
- notifyError(err, 'Invalid MCP JSON')
+ notifyError(err, m.invalidJson)
return
}
@@ -132,9 +135,9 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
setConfig(nextConfig)
setSelected(nextName)
onConfigSaved?.()
- notify({ kind: 'success', title: 'MCP server saved', message: `${nextName} applies after MCP reload.` })
+ notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
} catch (err) {
- notifyError(err, 'Save failed')
+ notifyError(err, m.saveFailed)
} finally {
setSaving(false)
}
@@ -153,7 +156,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
setSelected(Object.keys(nextServers).sort()[0] ?? null)
onConfigSaved?.()
} catch (err) {
- notifyError(err, 'Remove failed')
+ notifyError(err, m.removeFailed)
} finally {
setSaving(false)
}
@@ -161,7 +164,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const reloadMcp = async () => {
if (!gateway) {
- notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' })
+ notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
return
}
@@ -173,9 +176,9 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
confirm: true,
session_id: activeSessionId ?? undefined
})
- notify({ kind: 'success', title: 'MCP tools reloaded', message: 'New tool schemas apply to fresh turns.' })
+ notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
} catch (err) {
- notifyError(err, 'MCP reload failed')
+ notifyError(err, m.reloadFailed)
} finally {
setReloading(false)
}
@@ -185,17 +188,17 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
setSelected(null)} size="xs" variant="text">
- New server
+ {m.newServer}
void reloadMcp()} size="xs" variant="text">
- {reloading ? 'Reloading...' : 'Reload MCP'}
+ {reloading ? m.reloading : m.reload}
{names.length === 0 ? (
-
+
) : (
{names.map(serverName => {
@@ -216,7 +219,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
{serverName}
{transportLabel(server)}
- {server.disabled === true &&
disabled }
+ {server.disabled === true &&
{m.disabled} }
)
@@ -228,14 +231,14 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx
index 1beeca62ee..642fba5bf8 100644
--- a/apps/desktop/src/app/settings/model-settings.tsx
+++ b/apps/desktop/src/app/settings/model-settings.tsx
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes'
+import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -14,43 +15,34 @@ import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// hints make the assignments readable; raw task keys (vision, mcp, …) are
// opaque to most users.
interface AuxTaskMeta {
- hint: string
key: string
- label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
- { key: 'vision', label: 'Vision', hint: 'Image analysis' },
- { key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
- { key: 'compression', label: 'Compression', hint: 'Context compaction' },
- { key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
- { key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
- { key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
- { key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
- { key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
+ { key: 'vision' },
+ { key: 'web_extract' },
+ { key: 'compression' },
+ { key: 'skills_hub' },
+ { key: 'approval' },
+ { key: 'mcp' },
+ { key: 'title_generation' },
+ { key: 'curator' }
]
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
-const AUX_TASK_LABELS: Record
= Object.fromEntries(
- AUX_TASKS.map(meta => [meta.key, meta.label])
-)
-
-function taskLabel(key: string): string {
- return AUX_TASK_LABELS[key] ?? key
-}
-
interface StaleAuxWarningProps {
applying: boolean
onReset: () => void
slots: readonly StaleAuxAssignment[]
+ taskLabel: (key: string) => string
}
// Shared notice: auxiliary tasks still pinned to a provider that isn't the
// current main. Surfaces the silent credit-burn path (e.g. aux pinned to a
// $0-balance provider after switching main away from it) and offers the
// existing one-click reset rather than auto-clearing legitimate pins.
-function StaleAuxWarning({ applying, onReset, slots }: StaleAuxWarningProps) {
+function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
if (!slots.length) {
return null
}
@@ -79,6 +71,8 @@ interface ModelSettingsProps {
}
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
+ const { t } = useI18n()
+ const m = t.settings.model
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
@@ -132,6 +126,8 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
[auxDraft.provider, providers]
)
+ const auxiliaryTaskLabel = useCallback((key: string) => m.tasks[key]?.label ?? key, [m.tasks])
+
// Persistent mismatch: any aux slot pinned to a provider different from the
// current main, regardless of whether the user just switched. Catches the
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
@@ -253,19 +249,19 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [mainModel, refresh])
if (loading && !mainModel) {
- return
+ return
}
return (
- Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
+ {m.appliesDesc}
-
+
{providerOptions.map(provider => (
@@ -277,7 +273,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
-
+
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
@@ -293,39 +289,50 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
size="sm"
>
{applying && }
- {applying ? 'Applying...' : 'Apply'}
+ {applying ? m.applying : t.common.apply}
{error && {error}
}
{switchStaleAux.length > 0 && (
- void resetAuxiliaryModels()} slots={switchStaleAux} />
+ void resetAuxiliaryModels()}
+ slots={switchStaleAux}
+ taskLabel={auxiliaryTaskLabel}
+ />
)}
-
+
void resetAuxiliaryModels()}
size="sm"
variant="textStrong"
>
- Reset all to main
+ {m.resetAllToMain}
- Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
+ {m.auxiliaryDesc}
{switchStaleAux.length === 0 && persistentStaleAux.length > 0 && (
- void resetAuxiliaryModels()} slots={persistentStaleAux} />
+ void resetAuxiliaryModels()}
+ slots={persistentStaleAux}
+ taskLabel={auxiliaryTaskLabel}
+ />
)}
{AUX_TASKS.map(meta => {
+ const copy = m.tasks[meta.key] ?? { label: meta.key, hint: meta.key }
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
@@ -341,7 +348,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
size="sm"
variant="text"
>
- Set to main
+ {m.setToMain}
- Change
+ {m.change}
)
@@ -362,7 +369,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
value={auxDraft.provider}
>
-
+
{providerOptions.map(provider => (
@@ -377,7 +384,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
value={auxDraft.model}
>
-
+
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
@@ -392,10 +399,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
onClick={() => void applyAuxiliaryDraft(meta.key)}
size="sm"
>
- {applying ? 'Applying...' : 'Apply'}
+ {applying ? m.applying : t.common.apply}
setEditingAuxTask(null)} size="sm" variant="ghost">
- Cancel
+ {t.common.cancel}
)
@@ -403,15 +410,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
description={
{isAuto
- ? 'auto · use main model'
- : `${current.provider} · ${current.model || '(provider default)'}`}
+ ? m.autoUseMain
+ : `${current.provider} · ${current.model || m.providerDefault}`}
}
key={meta.key}
title={
- {meta.label}
- {meta.hint}
+ {copy.label}
+ {copy.hint}
}
/>
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
index 413ebd2827..f95ad1977a 100644
--- a/apps/desktop/src/app/settings/providers-settings.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -10,6 +10,7 @@ import {
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { listOAuthProviders } from '@/hermes'
+import { useI18n } from '@/i18n'
import { ChevronDown, KeyRound } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
@@ -85,6 +86,8 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
+ const { t } = useI18n()
+ const p = t.settings.providers
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
@@ -106,25 +109,24 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
return (
-
+
- Have an API key instead?
+ {p.haveApiKey}
- Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
- app.
+ {p.intro}
{featured && }
{connected.length > 0 && (
<>
- Connected
+ {p.connected}
{connected.map(p => (
@@ -146,7 +148,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
type="button"
variant="text"
>
- {showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
+ {showAll ? p.collapse : connected.length > 0 ? p.connectAnother : p.otherProviders}
)}
@@ -155,14 +157,17 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
}
function NoProviderKeys() {
+ const { t } = useI18n()
+
return (
- No provider API keys available.
+ {t.settings.providers.noProviderKeys}
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
+ const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState([])
const [openProvider, setOpenProvider] = useState(null)
@@ -195,7 +200,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
}, [onboardingActive])
if (!vars) {
- return
+ return
}
const hasOauth = oauthProviders.length > 0
diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx
index 060088ba16..e37c9d7896 100644
--- a/apps/desktop/src/app/settings/sessions-settings.tsx
+++ b/apps/desktop/src/app/settings/sessions-settings.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
+import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
@@ -32,6 +33,8 @@ function workspaceLabel(cwd: null | string | undefined): string {
}
export function SessionsSettings() {
+ const { t } = useI18n()
+ const s = t.settings.sessions
const [sessions, setLocalSessions] = useState([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState(null)
@@ -43,7 +46,7 @@ export function SessionsSettings() {
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
- notifyError(err, 'Could not load archived sessions')
+ notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
@@ -62,16 +65,16 @@ export function SessionsSettings() {
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
- notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
+ notify({ durationMs: 2_000, kind: 'success', message: s.restored })
} catch (err) {
- notifyError(err, 'Unarchive failed')
+ notifyError(err, s.unarchiveFailed)
} finally {
setBusyId(null)
}
- }, [])
+ }, [s])
const remove = useCallback(async (session: SessionInfo) => {
- if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
+ if (!window.confirm(s.deleteConfirm(sessionTitle(session)))) {
return
}
@@ -82,11 +85,11 @@ export function SessionsSettings() {
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
- notifyError(err, 'Delete failed')
+ notifyError(err, s.deleteFailed)
} finally {
setBusyId(null)
}
- }, [])
+ }, [s])
useDeepLinkHighlight({
elementId: id => `archived-session-${id}`,
@@ -95,7 +98,7 @@ export function SessionsSettings() {
})
if (loading) {
- return
+ return
}
return (
@@ -105,15 +108,14 @@ export function SessionsSettings() {
- Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
- archive it.
+ {s.archivedIntro}
{sessions.length === 0 ? (
-
+
) : (
{sessions.map(session => {
@@ -133,11 +135,11 @@ export function SessionsSettings() {
variant="textStrong"
>
{busy ?
:
}
-
Unarchive
+
{s.unarchive}
-
+
void remove(session)}
@@ -151,7 +153,7 @@ export function SessionsSettings() {
}
description={session.preview || undefined}
- hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
+ hint={label ? `${label} · ${s.messages(session.message_count)}` : s.messages(session.message_count)}
title={sessionTitle(session)}
/>
@@ -167,6 +169,8 @@ export function SessionsSettings() {
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.
function DefaultProjectDirSetting() {
+ const { t } = useI18n()
+ const s = t.settings.sessions
const [dir, setDir] = useState
(null)
const [fallback, setFallback] = useState('')
const [busy, setBusy] = useState(false)
@@ -217,13 +221,13 @@ function DefaultProjectDirSetting() {
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
- notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
+ notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
- notifyError(err, 'Could not update default directory')
+ notifyError(err, s.updateDirFailed)
} finally {
setBusy(false)
}
- }, [])
+ }, [s])
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
@@ -238,34 +242,34 @@ function DefaultProjectDirSetting() {
await settings.setDefaultProjectDir(null)
setDir(null)
} catch (err) {
- notifyError(err, 'Could not clear default directory')
+ notifyError(err, s.clearDirFailed)
} finally {
setBusy(false)
}
- }, [])
+ }, [s])
return (
-
+
- New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
+ {s.defaultDirDesc}
void choose()} size="sm" type="button" variant="textStrong">
- {dir ? 'Change' : 'Choose'}
+ {dir ? s.change : s.choose}
{dir && (
void clear()} size="sm" type="button" variant="text">
- Clear
+ {s.clear}
)}
}
- description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
- title={dir ? dir : 'Not set'}
+ description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
+ title={dir ? dir : s.notSet}
/>
)
diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx
index d766f92675..aeff184e39 100644
--- a/apps/desktop/src/app/settings/toolset-config-panel.tsx
+++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx
@@ -4,6 +4,7 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
+import { useI18n } from '@/i18n'
import { Check, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -35,6 +36,8 @@ interface EnvVarFieldProps {
}
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
+ const { t } = useI18n()
+ const copy = t.settings.toolsets
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [revealed, setRevealed] = useState(null)
@@ -52,16 +55,16 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
setEditing(false)
setValue('')
onSaved(envVar.key)
- notify({ kind: 'success', title: 'Credential saved', message: `${envVar.key} updated.` })
+ notify({ kind: 'success', title: copy.savedTitle, message: copy.savedMessage(envVar.key) })
} catch (err) {
- notifyError(err, `Failed to save ${envVar.key}`)
+ notifyError(err, copy.failedSave(envVar.key))
} finally {
setBusy(false)
}
}
async function handleClear() {
- if (!window.confirm(`Remove ${envVar.key} from .env?`)) {
+ if (!window.confirm(copy.removeConfirm(envVar.key))) {
return
}
@@ -71,9 +74,9 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
await deleteEnvVar(envVar.key)
setRevealed(null)
onCleared(envVar.key)
- notify({ kind: 'success', title: 'Credential removed', message: `${envVar.key} removed.` })
+ notify({ kind: 'success', title: copy.removedTitle, message: copy.removedMessage(envVar.key) })
} catch (err) {
- notifyError(err, `Failed to remove ${envVar.key}`)
+ notifyError(err, copy.failedRemove(envVar.key))
} finally {
setBusy(false)
}
@@ -90,7 +93,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
const result = await revealEnvVar(envVar.key)
setRevealed(result.value)
} catch (err) {
- notifyError(err, `Failed to reveal ${envVar.key}`)
+ notifyError(err, copy.failedReveal(envVar.key))
}
}
@@ -102,7 +105,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{envVar.key}
{isSet && }
- {isSet ? 'Set' : 'Not set'}
+ {isSet ? copy.set : copy.notSet}
{envVar.prompt && envVar.prompt !== envVar.key && (
@@ -143,10 +146,10 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
/>
void handleSave()} size="sm">
{busy ? : }
- Save
+ {t.common.save}
setEditing(false)} size="sm" variant="text">
- Cancel
+ {t.common.cancel}
)}
@@ -155,6 +158,8 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
}
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
+ const { t } = useI18n()
+ const copy = t.settings.toolsets
const [cfg, setCfg] = useState(null)
const [loading, setLoading] = useState(true)
const [selecting, setSelecting] = useState(null)
@@ -178,7 +183,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
setEnvState(seeded)
} catch (err) {
- notifyError(err, 'Tool configuration failed to load')
+ notifyError(err, copy.failedLoad)
} finally {
setLoading(false)
}
@@ -215,10 +220,10 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
try {
await selectToolsetProvider(toolset, provider.name)
- notify({ kind: 'success', title: 'Provider selected', message: `${provider.name} is now active.` })
+ notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) })
onConfiguredChange?.()
} catch (err) {
- notifyError(err, `Failed to select ${provider.name}`)
+ notifyError(err, copy.failedSelect(provider.name))
} finally {
setSelecting(null)
}
@@ -235,18 +240,18 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}
if (!cfg.has_category) {
- return 'This toolset has no provider options — enable it and it works with your current setup.'
+ return copy.noProviderOptions
}
if (providers.length === 0) {
- return 'No providers are available for this toolset right now.'
+ return copy.noProviders
}
return null
- }, [cfg, loading, providers.length])
+ }, [cfg, copy, loading, providers.length])
if (loading) {
- return
+ return
}
if (emptyMessage) {
@@ -276,7 +281,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
{configured && (
- Ready
+ {copy.ready}
)}
@@ -288,11 +293,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
{provider.tag && {provider.tag}
}
{provider.requires_nous_auth && (
- Included with a Nous subscription — sign in to Nous Portal to activate.
+ {copy.nousIncluded}
)}
{provider.env_vars.length === 0 ? (
- No API key required.
+ {copy.noApiKeyRequired}
) : (
provider.env_vars.map(ev => (
- This provider needs an extra setup step ({provider.post_setup}). Run it from the CLI with{' '}
- hermes tools for now.
+ {copy.postSetup(provider.post_setup)}
)}
diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx
index 9d37cf30ae..326f66f3f4 100644
--- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx
@@ -3,6 +3,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
import { Activity, AlertCircle } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
@@ -40,23 +41,25 @@ export function GatewayMenuPanel({
onOpenSystem,
statusSnapshot
}: GatewayMenuPanelProps) {
+ const { t } = useI18n()
+ const copy = t.shell.gatewayMenu
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
const connectionLabel = gatewayOpen
- ? 'Connected'
+ ? copy.connected
: gatewayConnecting
- ? 'Connecting'
- : prettyState(gatewayState || 'offline')
+ ? copy.connecting
+ : prettyState(gatewayState || copy.offline)
const inferenceLabel = gatewayOpen
? inferenceStatus?.ready
- ? 'Inference ready'
+ ? copy.inferenceReady
: inferenceStatus
- ? 'Inference not ready'
- : 'Checking inference'
- : 'Disconnected'
+ ? copy.inferenceNotReady
+ : copy.checkingInference
+ : copy.disconnected
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
const recentLogs = logLines.slice(-5)
@@ -70,16 +73,16 @@ export function GatewayMenuPanel({
) : (
)}
- Gateway
+ {copy.gateway}
{inferenceLabel}
-
+
-
Connection: {connectionLabel}
+
{copy.connection(connectionLabel)}
{inferenceStatus?.reason &&
{inferenceStatus.reason}
}
{recentLogs.length > 0 && (
-
Recent activity
+
{copy.recentActivity}
{recentLogs.map((line, index) => (
@@ -113,14 +116,14 @@ export function GatewayMenuPanel({
onClick={onOpenSystem}
type="button"
>
- View all logs →
+ {copy.viewAllLogs}
)}
{platforms.length > 0 && (
-
Messaging platforms
+
{copy.messagingPlatforms}
{platforms.map(([name, platform]) => (
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index a1331bd515..c700cb5101 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -16,6 +16,7 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
+import { useI18n } from '@/i18n'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
@@ -78,6 +79,8 @@ export function useStatusbarItems({
statusSnapshot,
toggleCommandCenter
}: StatusbarItemsOptions) {
+ const { t } = useI18n()
+ const copy = t.shell.statusbar
const activeSessionId = useStore($activeSessionId)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
@@ -160,13 +163,13 @@ export function useStatusbarItems({
const gatewayDetail = gatewayOpen
? inferenceStatus?.ready
- ? 'ready'
+ ? copy.gatewayReady
: inferenceStatus
- ? 'needs setup'
- : 'checking'
+ ? copy.gatewayNeedsSetup
+ : copy.gatewayChecking
: gatewayConnecting
- ? 'connecting'
- : 'offline'
+ ? copy.gatewayConnecting
+ : copy.gatewayOffline
const gatewayClassName = inferenceReady
? undefined
@@ -179,21 +182,21 @@ export function useStatusbarItems({
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
- const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
+ const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? updateApply.stage === 'restart'
- ? `${base} · restart`
- : `${base} · update`
+ ? `${base} · ${copy.restart}`
+ : `${base} · ${copy.update}`
: `${base}${behindHint}`
const tooltip = [
- applying ? updateApply.message || 'Update in progress' : null,
- !applying && behind > 0 && `${behind} commit${behind === 1 ? '' : 's'} behind ${updateStatus?.branch ?? '…'}`,
- appVersion && `Hermes Desktop v${appVersion}`,
- sha && `commit ${sha}`,
- updateStatus?.branch && `branch ${updateStatus.branch}`
+ applying ? updateApply.message || copy.updateInProgress : null,
+ !applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'),
+ appVersion && copy.desktopVersion(appVersion),
+ sha && copy.commit(sha),
+ updateStatus?.branch && copy.branch(updateStatus.branch)
]
.filter(Boolean)
.join(' · ')
@@ -211,6 +214,7 @@ export function useStatusbarItems({
}
}, [
desktopVersion?.appVersion,
+ copy,
updateApply.applying,
updateApply.message,
updateApply.stage,
@@ -226,7 +230,7 @@ export function useStatusbarItems({
icon: ,
id: 'command-center',
onSelect: toggleCommandCenter,
- title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
+ title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
variant: 'action'
},
{
@@ -234,10 +238,10 @@ export function useStatusbarItems({
detail: gatewayDetail,
icon: inferenceReady ? : ,
id: 'gateway-health',
- label: 'Gateway',
+ label: copy.gateway,
menuClassName: 'w-72',
menuContent: gatewayMenuContent,
- title: inferenceStatus?.reason || 'Hermes inference gateway status',
+ title: inferenceStatus?.reason || copy.gatewayTitle,
variant: 'menu'
},
{
@@ -247,11 +251,11 @@ export function useStatusbarItems({
),
detail:
subagentsRunning > 0
- ? `${subagentsRunning} subagent${subagentsRunning === 1 ? '' : 's'}`
+ ? copy.subagents(subagentsRunning)
: bgFailed > 0
- ? `${bgFailed} failed`
+ ? copy.failed(bgFailed)
: bgRunning > 0
- ? `${bgRunning} running`
+ ? copy.running(bgRunning)
: undefined,
icon:
bgFailed > 0 ? (
@@ -262,16 +266,16 @@ export function useStatusbarItems({
),
id: 'agents',
- label: 'Agents',
+ label: copy.agents,
onSelect: openAgents,
- title: agentsOpen ? 'Close agents' : 'Open agents',
+ title: agentsOpen ? copy.closeAgents : copy.openAgents,
variant: 'action'
},
{
icon: ,
id: 'cron',
- label: 'Cron',
- title: 'Open cron jobs',
+ label: copy.cron,
+ title: copy.openCron,
to: CRON_ROUTE,
variant: 'action'
}
@@ -281,6 +285,7 @@ export function useStatusbarItems({
bgFailed,
bgRunning,
commandCenterOpen,
+ copy,
gatewayMenuContent,
gatewayClassName,
gatewayDetail,
@@ -299,8 +304,8 @@ export function useStatusbarItems({
hidden: !busy || !turnStartedAt,
icon: ,
id: 'running-timer',
- label: 'Running',
- title: 'Current turn elapsed',
+ label: copy.turnRunning,
+ title: copy.currentTurnElapsed,
variant: 'text'
},
{
@@ -308,15 +313,15 @@ export function useStatusbarItems({
hidden: !contextUsage,
id: 'context-usage',
label: contextUsage,
- title: 'Context usage',
+ title: copy.contextUsage,
variant: 'text'
},
{
detail: ,
hidden: !sessionStartedAt,
id: 'session-timer',
- label: 'Session',
- title: 'Runtime session elapsed',
+ label: copy.session,
+ title: copy.runtimeSessionElapsed,
variant: 'text'
},
{
@@ -329,9 +334,7 @@ export function useStatusbarItems({
),
id: 'yolo',
onSelect: () => void toggleYolo(),
- title: yoloActive
- ? 'YOLO on — auto-approving dangerous commands. Click to turn off.'
- : 'YOLO off — click to auto-approve dangerous commands.',
+ title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
{
@@ -352,12 +355,16 @@ export function useStatusbarItems({
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
- title: currentProvider ? `Model · ${currentProvider}: ${currentModel || 'none'}` : 'Switch model',
+ title: currentProvider
+ ? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
+ : copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
- title: currentProvider ? `${currentProvider} · ${currentModel || 'no model'}` : 'Open model picker',
+ title: currentProvider
+ ? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
+ : copy.openModelPicker,
variant: 'action' as const
})
},
@@ -367,6 +374,7 @@ export function useStatusbarItems({
busy,
contextBar,
contextUsage,
+ copy,
currentFastMode,
currentModel,
currentProvider,
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx
index 610d23f9d3..6872cca7f5 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx
@@ -11,6 +11,7 @@ import {
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
+import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@@ -22,11 +23,11 @@ import {
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
- { value: 'minimal', label: 'Minimal' },
- { value: 'low', label: 'Low' },
- { value: 'medium', label: 'Medium' },
- { value: 'high', label: 'High' },
- { value: 'xhigh', label: 'Max' }
+ { value: 'minimal', labelKey: 'minimal' },
+ { value: 'low', labelKey: 'low' },
+ { value: 'medium', labelKey: 'medium' },
+ { value: 'high', labelKey: 'high' },
+ { value: 'xhigh', labelKey: 'max' }
] as const
/** How "fast" is achieved for a given model — two different mechanisms:
@@ -97,6 +98,8 @@ export function ModelEditSubmenu({
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
+ const { t } = useI18n()
+ const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
@@ -133,7 +136,7 @@ export function ModelEditSubmenu({
})
} catch (err) {
setCurrentReasoningEffort(rollback)
- notifyError(err, 'Model option update failed')
+ notifyError(err, copy.updateFailed)
}
}
@@ -163,7 +166,7 @@ export function ModelEditSubmenu({
})
} catch (err) {
setCurrentFastMode(!enabled)
- notifyError(err, 'Fast mode update failed')
+ notifyError(err, copy.fastFailed)
}
})()
}
@@ -175,13 +178,13 @@ export function ModelEditSubmenu({
return (
{!hasFast && !reasoning ? (
- No options for this model
+ {copy.noOptions}
) : (
<>
- Options
+ {copy.options}
{reasoning ? (
event.preventDefault()}>
- Thinking
+ {copy.thinking}
event.preventDefault()}>
- Fast
+ {copy.fast}
) : null}
{reasoning ? (
<>
- Effort
+ {copy.effort}
void patchReasoning(value, currentReasoningEffort)}
value={effort}
@@ -213,7 +216,7 @@ export function ModelEditSubmenu({
onSelect={event => event.preventDefault()}
value={option.value}
>
- {option.label}
+ {copy[option.labelKey]}
))}
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx
index d22869487c..d66761d0b8 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.tsx
@@ -17,6 +17,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
+import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
@@ -50,6 +51,8 @@ interface ProviderGroup {
}
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
+ const { t } = useI18n()
+ const copy = t.shell.modelMenu
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
@@ -95,9 +98,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
return (
<>
@@ -122,7 +125,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
) : groups.length === 0 ? (
- No models found
+ {copy.noModels}
) : (
@@ -158,13 +161,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// others show a fast-capability hint.
const meta = isCurrent
? [
- fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
- reasoningEffortLabel(currentReasoningEffort) || 'Med'
+ fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
+ reasoningEffortLabel(currentReasoningEffort) || copy.medium
]
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId
- ? 'Fast'
+ ? copy.fast
: ''
// Every row is a hover-Edit submenu trigger. Activating it
@@ -218,7 +221,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
- Edit Models…
+ {copy.editModels}
>
)
diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx
index 0a54d51c32..a939a18051 100644
--- a/apps/desktop/src/app/shell/titlebar-controls.tsx
+++ b/apps/desktop/src/app/shell/titlebar-controls.tsx
@@ -143,7 +143,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
return (
<>
{leftToolbarTools
@@ -163,7 +163,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
*/}
{visiblePaneTools.length > 0 && (
{visiblePaneTools.map(tool => (
@@ -173,7 +173,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
)}
{visibleSystemToolsBeforeSettings.map(tool => (
diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx
index 014214b9eb..5f0c350e10 100644
--- a/apps/desktop/src/app/updates-overlay.tsx
+++ b/apps/desktop/src/app/updates-overlay.tsx
@@ -6,6 +6,7 @@ import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorState } from '@/components/ui/error-state'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
+import { useI18n } 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,17 +22,6 @@ import {
type UpdateApplyState
} 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'
-}
-
function totalItems(groups: readonly CommitGroup[]) {
return groups.reduce((sum, g) => sum + g.items.length, 0)
}
@@ -124,9 +114,12 @@ function IdleView({
onRetryCheck: () => void
status: DesktopUpdateStatus | null
}) {
+ const { t } = useI18n()
+ const u = t.updates
+
if (!status && checking) {
return (
- } title="Looking for updates…" />
+ } title={u.checking} />
)
}
@@ -135,11 +128,11 @@ function IdleView({
- Try again
+ {u.tryAgain}
}
icon={ }
- title="Couldn’t check for updates"
+ title={u.checkFailedTitle}
/>
)
}
@@ -147,9 +140,9 @@ function IdleView({
if (!status.supported) {
return (
}
- title="Update not available"
+ title={u.notAvailableTitle}
/>
)
}
@@ -159,12 +152,12 @@ function IdleView({
- Try again
+ {u.tryAgain}
}
- body="Check your connection and try again."
+ body={u.connectionRetry}
icon={ }
- title="Couldn’t check for updates"
+ title={u.checkFailedTitle}
/>
)
}
@@ -172,9 +165,9 @@ function IdleView({
if (behind === 0) {
return (
}
- title="You’re all set"
+ title={u.allSetTitle}
/>
)
}
@@ -190,9 +183,9 @@ function IdleView({
- New update available
+ {u.availableTitle}
- A new version of Hermes is ready to install.
+ {u.availableBody}
@@ -214,20 +207,20 @@ function IdleView({
- Update now
+ {u.updateNow}
- Maybe later
+ {u.maybeLater}
{remaining > 0 && (
- + {remaining} more change{remaining === 1 ? '' : 's'} included.
+ {u.moreChanges(remaining)}
)}
@@ -235,6 +228,8 @@ function IdleView({
}
function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
+ const { t } = useI18n()
+ const u = t.updates
const [copied, setCopied] = useState(false)
const handleCopy = () => {
@@ -251,9 +246,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
-
Update from your terminal
+
{u.manualTitle}
- You installed Hermes from the command line, so updates run there too. Paste this into your terminal:
+ {u.manualBody}
@@ -270,30 +265,32 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
{copied ? (
<>
- Copied
+ {u.copied}
>
) : (
<>
- Copy
+ {u.copy}
>
)}
- Hermes will pick up the new version next time you launch it.
+ {u.manualPickedUp}
- Done
+ {u.done}
)
}
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
- const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
+ const { t } = useI18n()
+ const u = t.updates
+ const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
@@ -309,7 +306,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
{label}
- The Hermes updater will take over in its own window and reopen Hermes when it’s done.
+ {u.applyingBody}
@@ -323,29 +320,32 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
/>
- Hermes will close to apply the update.
+ {u.applyingClose}
)
}
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
+ const { t } = useI18n()
+ const u = t.updates
+
return (
- {message || 'No worries — nothing was lost. You can try again now.'}
+ {message || u.errorBody}
}
title={
- Update didn’t finish
+ {u.errorTitle}
}
>
- Try again
+ {u.tryAgain}
- Not now
+ {u.notNow}
)
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
index cad01efe6e..6663bbb597 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -7,6 +7,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -63,6 +64,8 @@ export const ClarifyTool = (props: ToolCallMessagePartProps) => {
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
+ const { t } = useI18n()
+ const copy = t.assistant.clarify
const request = useStore($clarifyRequest)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
@@ -102,13 +105,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const respond = useCallback(
async (answer: string) => {
if (!ready || !matchingRequest) {
- notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
+ notifyError(new Error(copy.notReady), copy.sendFailed)
return
}
if (!gateway) {
- notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
+ notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -125,7 +128,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
- notifyError(error, 'Could not send clarify response')
+ notifyError(error, copy.sendFailed)
setSubmitting(false)
}
},
@@ -172,7 +175,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
- {question || Loading question… }
+ {question || {copy.loadingQuestion} }
@@ -209,7 +212,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
>
- Other (type your answer)
+ {copy.other}
)}
@@ -221,12 +224,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
- placeholder="Type your answer…"
+ placeholder={copy.placeholder}
ref={textareaRef}
value={draft}
/>
-
⌘/Ctrl + Enter to send
+
{copy.shortcut}
{hasChoices && (
- Back
+ {copy.back}
)}
- Skip
+ {copy.skip}
- {submitting ? : 'Send'}
+ {submitting ? : copy.send}
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index a38ebce4f3..a815b61f75 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -75,6 +75,7 @@ import {
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
+import { useI18n } from '@/i18n'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
@@ -183,22 +184,26 @@ function pickPrimaryPreviewTarget(targets: string[]): string[] {
return [localUrl || targets[targets.length - 1]]
}
-const CenteredThreadSpinner: FC = () => (
-
-
-
-)
+const CenteredThreadSpinner: FC = () => {
+ const { t } = useI18n()
+
+ return (
+
+
+
+ )
+}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
@@ -277,10 +282,11 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
)
const ResponseLoadingIndicator: FC = () => {
+ const { t } = useI18n()
const elapsed = useElapsedSeconds()
return (
-
+
@@ -329,6 +335,7 @@ const ThinkingDisclosure: FC<{
pending?: boolean
timerKey?: string
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
+ const { t } = useI18n()
// `null` = no explicit user toggle yet, defer to the streaming default.
// The default is "auto-open while streaming, auto-collapse when done" so
// reasoning surfaces a live preview without manual interaction. The first
@@ -385,7 +392,7 @@ const ThinkingDisclosure: FC<{
pending && 'shimmer text-foreground/55'
)}
>
- Thinking
+ {t.assistant.thread.thinking}
{pending && (
string; yesterday: (time: string) => string }
+): string {
if (!value) {
return ''
}
@@ -501,17 +511,19 @@ function formatMessageTimestamp(value: Date | string | number | undefined): stri
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
if (dayDelta === 0) {
- return `Today, ${TIME_FMT.format(date)}`
+ return labels.today(TIME_FMT.format(date))
}
if (dayDelta === 1) {
- return `Yesterday, ${TIME_FMT.format(date)}`
+ return labels.yesterday(TIME_FMT.format(date))
}
return SHORT_FMT.format(date)
}
const AssistantActionBar: FC = ({ messageId, messageText, onBranchInNewChat }) => {
+ const { t } = useI18n()
+ const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
@@ -530,15 +542,15 @@ const AssistantActionBar: FC = ({ messageId, messageText, on
)}
data-slot="aui_msg-actions"
>
-
+
- triggerHaptic('submit')} tooltip="Refresh">
+ triggerHaptic('submit')} tooltip={copy.refresh}>
-
+
@@ -546,7 +558,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on
onBranchInNewChat?.(messageId)}>
- Branch in new chat
+ {copy.branchNewChat}
@@ -557,6 +569,8 @@ const AssistantActionBar: FC = ({ messageId, messageText, on
}
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
+ const { t } = useI18n()
+ const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const readAloudStatus =
@@ -575,9 +589,9 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
} catch (error) {
- notifyError(error, 'Read aloud failed')
+ notifyError(error, copy.readAloudFailed)
}
- }, [messageId, text])
+ }, [copy.readAloudFailed, messageId, text])
return (
= ({ messageId, tex
}}
>
- {isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
+ {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
)
}
const MessageTimestamp: FC = () => {
+ const { t } = useI18n()
const createdAt = useAuiState(s => s.message.createdAt)
- const label = formatMessageTimestamp(createdAt)
+ const label = formatMessageTimestamp(createdAt, t.assistant.thread)
if (!label) {
return null
@@ -662,6 +677,8 @@ const StopGlyph = triggerHaptic('selection')}
- title="Edit message"
+ title={copy.editMessage}
type="button"
>
{bubbleContent}
@@ -767,14 +784,14 @@ const UserMessage: FC<{
{showStop ? (
{
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
- title="Stop"
+ title={copy.stop}
type="button"
>
{StopGlyph}
@@ -783,7 +800,7 @@ const UserMessage: FC<{
@@ -798,18 +815,18 @@ const UserMessage: FC<{
- Restore checkpoint
+ {copy.restoreCheckpoint}
/
- Go forward
+ {copy.goForward}
@@ -880,6 +897,8 @@ interface UserEditComposerProps {
}
const UserEditComposer: FC = ({ cwd, gateway, sessionId }) => {
+ const { t } = useI18n()
+ const copy = t.assistant.thread
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
const rootRef = useRef(null)
@@ -1356,7 +1375,7 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
data-expanded={expanded ? 'true' : undefined}
>
= ({ cwd, gateway, sessionId }
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
- data-placeholder="Edit message"
+ data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleDragOver}
@@ -1382,7 +1401,7 @@ const UserEditComposer: FC
= ({ cwd, gateway, sessionId }
/>
{
@@ -1392,7 +1411,7 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
submitEdit(editor)
}
}}
- title="Send edited message"
+ title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : }
diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
index ad3a1cdbe3..068573131a 100644
--- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@@ -52,6 +53,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
+ const { t } = useI18n()
+ const copy = t.assistant.approval
const gateway = useStore($gateway)
const [submitting, setSubmitting] = useState(null)
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
@@ -68,7 +71,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}
if (!gateway) {
- notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
+ notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -83,7 +86,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest(request.sessionId)
} catch (error) {
- notifyError(error, 'Could not send approval response')
+ notifyError(error, copy.sendFailed)
setSubmitting(null)
}
},
@@ -123,14 +126,14 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
- {submitting === 'once' ? : 'Run'}
+ {submitting === 'once' ? : copy.run}
{submitting !== 'once' && {isMac ? '⌘⏎' : 'Ctrl⏎'} }
= ({ request }) => {
- void respond('session')}>Allow this session
+ void respond('session')}>{copy.allowSession}
{
// Defer one tick so the menu fully unmounts before the dialog
@@ -149,10 +152,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
setTimeout(() => setConfirmAlways(true), 0)
}}
>
- Always allow…
+ {copy.alwaysAllowMenu}
void respond('deny')} variant="destructive">
- Reject
+ {copy.reject}
@@ -165,18 +168,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
- {submitting === 'deny' ? : 'Reject'}
+ {submitting === 'deny' ? : copy.reject}
{submitting !== 'deny' && Esc }
- Always allow this command?
+ {copy.alwaysTitle}
- This adds the “{request.description}” pattern to your permanent allowlist (
- ~/.hermes/config.yaml). Hermes won’t ask again for commands
- like this — in this session or any future one.
+ {copy.alwaysDescription(request.description)}
@@ -188,7 +189,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
setConfirmAlways(false)} size="sm" variant="ghost">
- Cancel
+ {t.common.cancel}
{
@@ -198,7 +199,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="sm"
variant="destructive"
>
- Always allow
+ {copy.alwaysAllow}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
index c5b4d7a789..0767e06deb 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
@@ -1,5 +1,6 @@
import { normalizeExternalUrl } from '@/lib/external-link'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
+import { translateNow } from '@/i18n'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@@ -1081,6 +1082,17 @@ function toolDetailText(
}
export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
+ const copy = {
+ command: translateNow('assistant.tool.copyCommand'),
+ content: translateNow('assistant.tool.copyContent'),
+ file: translateNow('assistant.tool.copyFile'),
+ output: translateNow('assistant.tool.copyOutput'),
+ path: translateNow('assistant.tool.copyPath'),
+ query: translateNow('assistant.tool.copyQuery'),
+ results: translateNow('assistant.tool.copyResults'),
+ url: translateNow('assistant.tool.copyUrl'),
+ generic: translateNow('common.copy')
+ }
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const detail = view.detail.trim()
@@ -1088,25 +1100,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
if (hasSubstantialOutput) {
- return { label: 'Copy output', text: detail }
+ return { label: copy.output, text: detail }
}
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
- return { label: 'Copy command', text: command }
+ return { label: copy.command, text: command }
}
}
if (part.toolName === 'web_extract') {
if (hasSubstantialOutput) {
- return { label: 'Copy content', text: detail }
+ return { label: copy.content, text: detail }
}
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
- return { label: 'Copy URL', text: url }
+ return { label: copy.url, text: url }
}
}
@@ -1114,7 +1126,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
- return { label: 'Copy URL', text: url }
+ return { label: copy.url, text: url }
}
}
@@ -1122,25 +1134,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (view.searchHits?.length) {
const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
- return { label: 'Copy results', text }
+ return { label: copy.results, text }
}
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
if (query) {
- return { label: 'Copy query', text: query }
+ return { label: copy.query, text: query }
}
}
if (part.toolName === 'read_file') {
if (hasSubstantialOutput) {
- return { label: 'Copy file', text: detail }
+ return { label: copy.file, text: detail }
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
- return { label: 'Copy path', text: path }
+ return { label: copy.path, text: path }
}
}
@@ -1148,15 +1160,15 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
- return { label: 'Copy path', text: path }
+ return { label: copy.path, text: path }
}
}
if (detail) {
- return { label: 'Copy output', text: detail }
+ return { label: copy.output, text: detail }
}
- return { label: 'Copy', text: view.title }
+ return { label: copy.generic, text: view.title }
}
function dynamicTitle(
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index ff0a4652fc..e120e6f6fd 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -17,6 +17,7 @@ import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
+import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -188,6 +189,8 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
}
function ToolEntry({ part }: ToolEntryProps) {
+ const { t } = useI18n()
+ const copy = t.assistant.tool
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
@@ -319,7 +322,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{view.imageUrl && (
-
+
)}
{hasSearchHits && view.searchHits && (
@@ -390,7 +393,7 @@ function ToolEntry({ part }: ToolEntryProps) {
))}
{showRawSearchDrilldown && (
- Raw response
+ {copy.rawResponse}
{view.rawResult}
@@ -432,6 +435,8 @@ export const ToolGroupSlot: FC {
+ const { t } = useI18n()
+ const copy = t.assistant.tool
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
@@ -489,11 +494,11 @@ export const ToolGroupSlot: FC buildGroupCopyText(visibleParts), [visibleParts])
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
@@ -508,7 +513,7 @@ export const ToolGroupSlot: FC
+
) : undefined
}
>
diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx
index 6bb9983e0e..972c3aaf96 100644
--- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx
+++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx
@@ -1,6 +1,7 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
+import { useI18n } from '@/i18n'
type Rgb = { r: number; g: number; b: number }
@@ -266,8 +267,10 @@ const DiffusionCanvas: FC = () => {
}
export const ImageGenerationPlaceholder: FC = () => {
+ const { t } = useI18n()
+
return (
-
+
diff --git a/apps/desktop/src/components/chat/preview-attachment.tsx b/apps/desktop/src/components/chat/preview-attachment.tsx
index cc4c8ef2d4..b85d1b8b05 100644
--- a/apps/desktop/src/components/chat/preview-attachment.tsx
+++ b/apps/desktop/src/components/chat/preview-attachment.tsx
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
+import { useI18n } from '@/i18n'
import { MonitorPlay } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { previewName } from '@/lib/preview-targets'
@@ -14,6 +15,7 @@ import {
import { $currentCwd } from '@/store/session'
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
+ const { t } = useI18n()
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
const [opening, setOpening] = useState(false)
@@ -93,7 +95,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
return
}
- notifyError(error, 'Preview unavailable')
+ notifyError(error, t.preview.unavailable)
} finally {
if (mountedRef.current && requestTokenRef.current === requestToken) {
setOpening(false)
@@ -116,7 +118,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
onClick={() => void togglePreview()}
type="button"
>
- {opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
+ {opening ? t.preview.opening : isActive ? t.preview.hide : t.preview.openPreview}
)
diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx
index e53fd9da4b..4993b993bf 100644
--- a/apps/desktop/src/components/chat/shiki-highlighter.tsx
+++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx
@@ -13,6 +13,7 @@ import {
CodeCardTitle
} from '@/components/chat/code-card'
import { CopyButton } from '@/components/ui/copy-button'
+import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
/**
@@ -48,6 +49,7 @@ export const SyntaxHighlighter: FC
= ({
code,
defer = false
}) => {
+ const { t } = useI18n()
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
// Streaming may hand us empty/incomplete fences — render nothing rather
@@ -68,14 +70,14 @@ export const SyntaxHighlighter: FC = ({
- Code
+ {t.assistant.tool.code}
{label && · {label} }
diff --git a/apps/desktop/src/components/chat/zoomable-image.tsx b/apps/desktop/src/components/chat/zoomable-image.tsx
index bc4882d07d..e160258cb8 100644
--- a/apps/desktop/src/components/chat/zoomable-image.tsx
+++ b/apps/desktop/src/components/chat/zoomable-image.tsx
@@ -3,6 +3,7 @@
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
+import { useI18n } from '@/i18n'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -51,6 +52,8 @@ export interface ZoomableImageProps extends ComponentProps<'img'> {
}
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
+ const { t } = useI18n()
+ const copy = t.desktop
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
@@ -67,7 +70,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
- notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
+ notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
}
return
@@ -80,17 +83,17 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
await startBrowserDownload(src)
notify({
kind: 'info',
- title: 'Download started',
- message: 'Restart Hermes Desktop to use Save Image.'
+ title: copy.downloadStarted,
+ message: copy.restartToUseSaveImage
})
} catch (fallbackError) {
- notifyError(fallbackError, 'Restart Hermes Desktop to save images')
+ notifyError(fallbackError, copy.restartToSaveImages)
}
return
}
- notifyError(error, 'Image download failed')
+ notifyError(error, copy.imageDownloadFailed)
} finally {
setSaving(false)
}
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx
index 66e086fb1c..e03f33b078 100644
--- a/apps/desktop/src/components/desktop-install-overlay.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.tsx
@@ -8,6 +8,7 @@ import type {
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
+import { useI18n } from '@/i18n'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -49,14 +50,6 @@ interface StageRowProps {
now: number
}
-const STATE_LABEL: Record = {
- pending: 'Pending',
- running: 'Installing',
- succeeded: 'Done',
- skipped: 'Skipped',
- failed: 'Failed'
-}
-
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) {
@@ -104,6 +97,8 @@ function formatElapsed(ms: number): string {
}
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
+ const { t } = useI18n()
+ const copy = t.install
const state: DesktopBootstrapStageState = result?.state || 'pending'
const elapsed =
@@ -147,9 +142,13 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
{formatStageName(descriptor.name)}
- {state === 'running' ? (elapsed ? `${STATE_LABEL[state]} · ${elapsed}` : STATE_LABEL[state]) : null}
+ {state === 'running'
+ ? elapsed
+ ? `${copy.stageStates[state]} · ${elapsed}`
+ : copy.stageStates[state]
+ : null}
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
- {state === 'failed' ? STATE_LABEL[state] : null}
+ {state === 'failed' ? copy.stageStates[state] : null}
{reason && state !== 'pending' && {reason}
}
@@ -242,6 +241,8 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
+ const { t } = useI18n()
+ const copy = t.install
const [state, setState] = useState(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
@@ -350,14 +351,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
-
Hermes needs a one-time install
+
{copy.oneTimeTitle}
- 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.
+ {copy.unsupportedDesc(platformLabel)}
-
Install command
+
{copy.installCommand}
{ups.installCommand}
@@ -369,7 +369,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="secondary"
>
- Copy command
+ {copy.copyCommand}
{
@@ -378,17 +378,17 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="ghost"
>
- View install docs
+ {copy.viewDocs}
- Will install to {ups.activeRoot}
+ {copy.installTo} {ups.activeRoot}
window.location.reload()} size="sm" variant="default">
- I{'\u2019'}ve run it -- retry
+ {copy.retryAfterRun}
@@ -415,13 +415,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{/* Header -- always visible, never scrolls */}
- {failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'}
+ {failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
- {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.'}
+ {failed ? copy.failedDesc : copy.activeDesc}
@@ -431,8 +428,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
- {completedCount} of {totalCount} steps complete
- {currentStage && ` -- now: ${formatStageName(currentStage)}`}
+ {copy.progress(completedCount, totalCount)}
+ {currentStage && copy.currentStage(formatStageName(currentStage))}
{currentElapsed && ` (${currentElapsed})`}
{progressPct}%
@@ -449,7 +446,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{totalCount === 0 && state.active && (
- Fetching installer manifest...
+ {copy.fetchingManifest}
)}
@@ -457,7 +454,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
@@ -484,9 +481,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
type="button"
>
{logOpen ?
:
}
-
{logOpen ? 'Hide installer output' : 'Show installer output'}
+
{logOpen ? copy.hideOutput : copy.showOutput}
- ({state.log.length} line{state.log.length === 1 ? '' : 's'})
+ ({copy.lines(state.log.length)})
@@ -498,7 +495,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
)}
>
{state.log.length === 0 ? (
-
No output yet.
+
{copy.noOutput}
) : (
<>
{state.log.map((entry, i) => (
@@ -540,7 +537,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
variant="ghost"
>
{cancelling ?
: null}
- {cancelling ? 'Cancelling...' : 'Cancel install'}
+ {cancelling ? copy.cancelling : copy.cancelInstall}
@@ -551,7 +548,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
- Full transcript saved to{' '}
+ {copy.transcriptSaved}{' '}
%LOCALAPPDATA%\hermes\logs\
@@ -574,7 +571,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="secondary"
>
- {copied ? 'Copied!' : 'Copy output'}
+ {copied ? copy.copiedOutput : copy.copyOutput}
{
@@ -593,7 +590,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="default"
>
- Reload and retry
+ {copy.reloadRetry}
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index 08f4da29dc..de9cfe560f 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
+import { useI18n } from '@/i18n'
import {
Check,
ChevronDown,
@@ -51,7 +52,7 @@ interface DesktopOnboardingOverlayProps {
}
export interface ApiKeyOption {
- description: string
+ description?: string
docsUrl: string
envKey: string
id: string
@@ -64,41 +65,31 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
{
id: 'openrouter',
name: 'OpenRouter',
- short: 'one key, many models',
envKey: 'OPENROUTER_API_KEY',
- description: 'Hosts hundreds of models behind a single key. Good default for new installs.',
docsUrl: 'https://openrouter.ai/keys'
},
{
id: 'openai',
name: 'OpenAI',
- short: 'GPT-class models',
envKey: 'OPENAI_API_KEY',
- description: 'Direct access to OpenAI models.',
docsUrl: 'https://platform.openai.com/api-keys'
},
{
id: 'gemini',
name: 'Google Gemini',
- short: 'Gemini models',
envKey: 'GEMINI_API_KEY',
- description: 'Direct access to Google Gemini models.',
docsUrl: 'https://aistudio.google.com/app/apikey'
},
{
id: 'xai',
name: 'xAI Grok',
- short: 'Grok models',
envKey: 'XAI_API_KEY',
- description: 'Direct access to xAI Grok models.',
docsUrl: 'https://console.x.ai/'
},
{
id: 'local',
name: 'Local / custom endpoint',
- short: 'self-hosted',
envKey: 'OPENAI_BASE_URL',
- description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).',
docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint',
placeholder: 'http://127.0.0.1:8000/v1'
}
@@ -118,13 +109,6 @@ const PROVIDER_DISPLAY: Record
= {
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'
-}
-
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
@@ -242,6 +226,7 @@ function ReasonNotice({ reason }: { reason: string }) {
}
function Preparing({ boot }: { boot: DesktopBootState }) {
+ const { t } = useI18n()
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
const installing = boot.phase.startsWith('runtime.')
@@ -250,8 +235,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
{installing
- ? 'Hermes is finishing install. This usually takes under a minute on first run.'
- : 'Starting Hermes…'}
+ ? t.onboarding.preparingInstall
+ : t.onboarding.starting}
@@ -279,9 +266,9 @@ function Header() {
-
Let's get you setup with Hermes Agent
+
{t.onboarding.headerTitle}
- Connect a model provider to start chatting. Most options take one click.
+ {t.onboarding.headerDesc}
@@ -290,7 +277,6 @@ function Header() {
}
export const FEATURED_ID = 'nous'
-const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
const readShowAll = () => {
@@ -312,6 +298,7 @@ const persistShowAll = (value: boolean) => {
}
export function Picker({ ctx }: { ctx: OnboardingContext }) {
+ const { t } = useI18n()
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
@@ -335,7 +322,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
}
if (providers === null) {
- return
Looking up providers...
+ return
{t.onboarding.lookingUpProviders}
}
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
@@ -363,7 +350,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
onClick={() => setShowAll(persistShowAll(!showAll))}
type="button"
>
- {showAll ? 'Collapse' : 'Other providers'}
+ {showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
) : null}
@@ -377,7 +364,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
onClick={() => setOnboardingMode('apikey')}
type="button"
>
- I have an API key
+ {t.onboarding.haveApiKey}
@@ -388,13 +375,15 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
// the skip so it never re-nags. The user connects a provider any time from
// Settings → Providers. Rendered only on the unconfigured first-run flow.
function ChooseLaterLink() {
+ const { t } = useI18n()
+
return (
dismissFirstRunOnboarding()}
type="button"
>
- I'll choose a provider later
+ {t.onboarding.chooseLater}
)
}
@@ -406,6 +395,7 @@ export function FeaturedProviderRow({
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
+ const { t } = useI18n()
const loggedIn = provider.status?.logged_in
return (
@@ -426,11 +416,11 @@ export function FeaturedProviderRow({
) : (
- Recommended
+ {t.onboarding.recommended}
)}
- {FEATURED_PITCH}
+ {t.onboarding.featuredPitch}
@@ -438,15 +428,19 @@ export function FeaturedProviderRow({
}
function ConnectedTag() {
+ const { t } = useI18n()
+
return (
- Connected
+ {t.onboarding.connected}
)
}
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
+ const { t } = useI18n()
+
return (
void }) {
>
OpenRouter
-
One key, hundreds of models — a solid default
+
{t.onboarding.openRouterPitch}
@@ -469,6 +463,7 @@ export function ProviderRow({
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
+ const { t } = useI18n()
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
@@ -485,7 +480,9 @@ export function ProviderRow({
{loggedIn ? : null}
- {FLOW_SUBTITLES[provider.flow]}
+
+ {t.onboarding.flowSubtitles[provider.flow]}
+
@@ -514,6 +511,7 @@ export function ApiKeyForm({
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
+ const { t } = useI18n()
const [option, setOption] = useState(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
@@ -551,6 +549,8 @@ export function ApiKeyForm({
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
+ const optionCopy = t.onboarding.apiKeyOptions[option.id]
+ const optionDescription = optionCopy?.description ?? option.description
const submit = async () => {
if (!canSave || saving) {
@@ -564,7 +564,7 @@ export function ApiKeyForm({
if (result.ok) {
setValue('')
} else {
- setError(result.message ?? 'Could not save credential.')
+ setError(result.message ?? t.onboarding.couldNotSave)
}
setSaving(false)
@@ -579,7 +579,7 @@ export function ApiKeyForm({
type="button"
>
- Back to sign in
+ {t.onboarding.backToSignIn}
) : null}
@@ -602,15 +602,19 @@ export function ApiKeyForm({
) : null}
- {o.short ? {o.short}
: null}
+ {(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
+
+ {t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
+
+ ) : null}
))}
-
{option.description}
- {option.docsUrl ?
Get a key : null}
+
{optionDescription}
+ {option.docsUrl ?
{t.onboarding.getKey} : null}
setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={
- currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
+ currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
}
type={isLocal ? 'text' : 'password'}
value={value}
@@ -631,13 +635,13 @@ export function ApiKeyForm({
{alreadySet && onClear ? (
onClear(option.envKey)} size="sm" variant="ghost">
- Remove
+ {t.common.remove}
) : null}
void submit()}>
{saving ? : }
- {saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
+ {saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
@@ -645,21 +649,22 @@ export function ApiKeyForm({
}
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
+ const { t } = useI18n()
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
if (flow.status === 'starting') {
- return Starting sign-in for {title}...
+ return {t.onboarding.startingSignIn(title)}
}
if (flow.status === 'submitting') {
- return Verifying your code with {title}...
+ return {t.onboarding.verifyingCode(title)}
}
if (flow.status === 'success') {
return (
- {title} connected. Picking a default model...
+ {t.onboarding.connectedPicking(title)}
)
}
@@ -672,11 +677,11 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
return (
- {flow.message || 'Sign-in failed. Try again.'}
+ {flow.message || t.onboarding.signInFailed}
- Pick a different provider
+ {t.onboarding.pickDifferentProvider}
@@ -685,23 +690,23 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'awaiting_user') {
return (
-
+
- We opened {title} in your browser.
- Authorize Hermes there.
- Copy the authorization code and paste it below.
+ {t.onboarding.openedBrowser(title)}
+ {t.onboarding.authorizeThere}
+ {t.onboarding.copyAuthCode}
setOnboardingCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
- placeholder="Paste authorization code"
+ placeholder={t.onboarding.pasteAuthCode}
value={flow.code}
/>
- Re-open authorization page}>
+ {t.onboarding.reopenAuthPage}}>
void submitOnboardingCode(ctx)}>
- Continue
+ {t.common.continue}
@@ -710,15 +715,14 @@ 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.
+ {t.onboarding.autoBrowser(title)}
- Re-open sign-in page}>
+ {t.onboarding.reopenSignInPage}}>
- Waiting for you to authorize...
+ {t.onboarding.waitingAuthorize}
@@ -728,19 +732,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.externalPending(title)}
void copyExternalCommand()} text={flow.provider.cli_command} />
{title} docs : null}
+ left={flow.provider.docs_url ? {t.onboarding.docs(title)} : null}
>
void recheckExternalSignin(ctx)}>
- I've signed in
+ {t.onboarding.signedIn}
@@ -752,13 +755,13 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
}
return (
-
- We opened {title} in your browser. Enter this code there:
+
+ {t.onboarding.deviceCodeOpened(title)}
void copyDeviceCode()} text={flow.start.user_code} />
- Re-open verification page}>
+ {t.onboarding.reopenVerification}}>
- Waiting for you to authorize...
+ {t.onboarding.waitingAuthorize}
@@ -786,11 +789,13 @@ function CodeBlock({
onCopy: () => void
text: string
}) {
+ const { t } = useI18n()
+
return (
{text}
- {copied ? : 'Copy'}
+ {copied ? : t.onboarding.copy}
)
@@ -806,9 +811,11 @@ function FlowFooter({ children, left }: { children: React.ReactNode; left?: Reac
}
function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
+ const { t } = useI18n()
+
return (
- Cancel
+ {t.common.cancel}
)
}
@@ -820,6 +827,7 @@ function ConfirmingModelPanel({
ctx: OnboardingContext
flow: Extract
}) {
+ const { t } = useI18n()
// 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
@@ -845,34 +853,34 @@ function ConfirmingModelPanel({
- {flow.label} connected.
+ {t.onboarding.connectedProvider(flow.label)}
-
Default model
+
{t.onboarding.defaultModel}
{freeTier === true && (
- Free tier
+ {t.onboarding.freeTier}
)}
{freeTier === false && (
- Pro
+ {t.onboarding.pro}
)}
{flow.currentModel}
{price && (price.input || price.output) && (
- {price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
+ {price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
)}
setPickerOpen(true)} size="sm" variant="outline">
- Change
+ {t.onboarding.change}
@@ -880,7 +888,7 @@ function ConfirmingModelPanel({
confirmOnboardingModel(ctx)}>
{flow.saving ? : }
- Start chatting
+ {t.onboarding.startChatting}
diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx
index e78b70a4a8..87b6b7743c 100644
--- a/apps/desktop/src/components/error-boundary.tsx
+++ b/apps/desktop/src/components/error-boundary.tsx
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
+import { useI18n } from '@/i18n'
export interface ErrorBoundaryFallbackProps {
error: Error
@@ -52,21 +53,23 @@ export class ErrorBoundary extends Component
- Try again
+ {t.common.retry}
window.location.reload()} variant="text">
- Reload window
+ {t.errors.reloadWindow}
void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
- Open logs
+ {t.errors.openLogs}
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx
index 6678dd5288..c19443ffab 100644
--- a/apps/desktop/src/components/model-picker.tsx
+++ b/apps/desktop/src/components/model-picker.tsx
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
+import { useI18n } from '@/i18n'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@@ -42,6 +43,8 @@ export function ModelPickerDialog({
onSelect,
contentClassName
}: ModelPickerDialogProps) {
+ const { t } = useI18n()
+ const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
@@ -97,9 +100,9 @@ export function ModelPickerDialog({
- Switch model
+ {copy.title}
- current: {optionsModel || currentModel || '(unknown)'}
+ {copy.current} {optionsModel || currentModel || copy.unknown}
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
@@ -108,11 +111,11 @@ export function ModelPickerDialog({
- {!loading && !error && No models found. }
+ {!loading && !error && {copy.noModels} }
setPersistGlobal(checked === true)}
/>
- {sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
+ {sessionId ? copy.persistGlobalSession : copy.persistGlobal}
- Add provider
+ {copy.addProvider}
onOpenChange(false)} variant="outline">
- Cancel
+ {t.common.cancel}
@@ -166,6 +169,9 @@ function ModelResults({
onSelectModel: (provider: ModelOptionProvider, model: string) => void
search: string
}) {
+ const { t } = useI18n()
+ const copy = t.modelPicker
+
if (loading) {
return
}
@@ -173,7 +179,7 @@ function ModelResults({
if (error) {
return (
-
+
{error}
@@ -181,7 +187,7 @@ function ModelResults({
}
if (providers.length === 0) {
- return No authenticated providers.
+ return {copy.noAuthenticatedProviders}
}
const q = search.trim().toLowerCase()
@@ -241,14 +247,14 @@ function ModelResults({
value={`${provider.slug}:${model}`}
>
{model}
- {locked && Pro }
+ {locked && {copy.pro} }
)
})}
{unavailable.size > 0 && (
- Pro models need a paid Nous subscription.
+ {copy.proNeedsSubscription}
)}
@@ -261,6 +267,9 @@ function ModelResults({
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
// Renders nothing when pricing is unavailable for the model.
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
+ const { t } = useI18n()
+ const copy = t.modelPicker
+
if (!price || (!price.input && !price.output)) {
return null
}
@@ -273,7 +282,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
)}
>
- Free
+ {copy.free}
)
}
@@ -284,7 +293,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
'shrink-0 text-[0.66rem] tabular-nums',
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
)}
- title="Input / Output price per million tokens"
+ title={copy.priceTitle}
>
{price.input || '?'} / {price.output || '?'}
@@ -304,15 +313,18 @@ function LoadingResults() {
}
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
+ const { t } = useI18n()
+ const copy = t.modelPicker
+
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
const tierBadge =
provider.free_tier === true ? (
- Free tier
+ {copy.freeTier}
) : provider.free_tier === false ? (
- Pro
+ {copy.pro}
) : null
diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx
index 12230533b2..47757e2874 100644
--- a/apps/desktop/src/components/model-visibility-dialog.tsx
+++ b/apps/desktop/src/components/model-visibility-dialog.tsx
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
+import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import {
$visibleModels,
@@ -32,6 +33,8 @@ export function ModelVisibilityDialog({
open,
sessionId
}: ModelVisibilityDialogProps) {
+ const { t } = useI18n()
+ const copy = t.modelVisibility
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
@@ -76,7 +79,7 @@ export function ModelVisibilityDialog({
- Models
+ {copy.title}
@@ -84,7 +87,7 @@ export function ModelVisibilityDialog({
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearch(event.target.value)}
- placeholder="Search models"
+ placeholder={copy.search}
type="text"
value={search}
/>
@@ -93,7 +96,7 @@ export function ModelVisibilityDialog({
{providers.length === 0 ? (
- {modelOptions.isPending ? : 'No authenticated providers.'}
+ {modelOptions.isPending ? : copy.noAuthenticatedProviders}
) : (
providers.map(provider => {
@@ -140,7 +143,7 @@ export function ModelVisibilityDialog({
}}
type="button"
>
- Add provider…
+ {copy.addProvider}
diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx
index 551bbf047e..1782a59ffc 100644
--- a/apps/desktop/src/components/prompt-overlays.tsx
+++ b/apps/desktop/src/components/prompt-overlays.tsx
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@@ -34,6 +35,8 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr
// backdrop-dismiss path.
function SudoDialog() {
+ const { t } = useI18n()
+ const copy = t.prompts
const request = useStore($sudoRequest)
const gateway = useStore($gateway)
const [password, setPassword] = useState('')
@@ -51,7 +54,7 @@ function SudoDialog() {
}
if (!gateway) {
- notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
+ notifyError(new Error(copy.gatewayDisconnected), copy.sudoSendFailed)
return
}
@@ -66,11 +69,11 @@ function SudoDialog() {
triggerHaptic('submit')
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
- notifyError(error, 'Could not send sudo password')
+ notifyError(error, copy.sudoSendFailed)
setSubmitting(false)
}
},
- [gateway, request]
+ [copy.gatewayDisconnected, copy.sudoSendFailed, gateway, request]
)
// Cancel → empty password. The backend treats an empty sudo response as a
@@ -102,11 +105,9 @@ function SudoDialog() {
- Administrator password
+ {copy.sudoTitle}
-
- Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
-
+ {copy.sudoDesc}
@@ -114,16 +115,16 @@ function SudoDialog() {
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
- placeholder="sudo password"
+ placeholder={copy.sudoPlaceholder}
type="password"
value={password}
/>
void send('')} type="button" variant="ghost">
- Cancel
+ {t.common.cancel}
- {submitting ? : 'Send'}
+ {submitting ? : t.common.send}
@@ -133,6 +134,8 @@ function SudoDialog() {
}
function SecretDialog() {
+ const { t } = useI18n()
+ const copy = t.prompts
const request = useStore($secretRequest)
const gateway = useStore($gateway)
const [value, setValue] = useState('')
@@ -150,7 +153,7 @@ function SecretDialog() {
}
if (!gateway) {
- notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
+ notifyError(new Error(copy.gatewayDisconnected), copy.secretSendFailed)
return
}
@@ -165,11 +168,11 @@ function SecretDialog() {
triggerHaptic('submit')
clearSecretRequest(request.sessionId, request.requestId)
} catch (error) {
- notifyError(error, 'Could not send secret')
+ notifyError(error, copy.secretSendFailed)
setSubmitting(false)
}
},
- [gateway, request]
+ [copy.gatewayDisconnected, copy.secretSendFailed, gateway, request]
)
const onOpenChange = useCallback(
@@ -199,9 +202,9 @@ function SecretDialog() {
- {request.envVar || 'Secret required'}
+ {request.envVar || copy.secretTitle}
- {request.prompt || 'Hermes needs a credential to continue.'}
+ {request.prompt || copy.secretDesc}
@@ -209,16 +212,16 @@ function SecretDialog() {
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
- placeholder={request.envVar || 'secret value'}
+ placeholder={request.envVar || copy.secretPlaceholder}
type="password"
value={value}
/>
void send('')} type="button" variant="ghost">
- Cancel
+ {t.common.cancel}
- {submitting ? : 'Send'}
+ {submitting ? : t.common.send}
diff --git a/apps/desktop/src/components/ui/confirm-dialog.tsx b/apps/desktop/src/components/ui/confirm-dialog.tsx
index e67bbb7686..ddb4f17950 100644
--- a/apps/desktop/src/components/ui/confirm-dialog.tsx
+++ b/apps/desktop/src/components/ui/confirm-dialog.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
interface ConfirmDialogProps {
@@ -29,15 +30,20 @@ export function ConfirmDialog({
onConfirm,
title,
description,
- confirmLabel = 'Confirm',
- busyLabel = 'Working…',
- doneLabel = 'Done',
- cancelLabel = 'Cancel',
+ confirmLabel,
+ busyLabel,
+ doneLabel,
+ cancelLabel,
destructive = false
}: ConfirmDialogProps) {
+ const { t } = useI18n()
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState
(null)
const busy = status === 'saving' || status === 'done'
+ const resolvedConfirmLabel = confirmLabel ?? t.common.confirm
+ const resolvedBusyLabel = busyLabel ?? t.common.loading
+ const resolvedDoneLabel = doneLabel ?? t.common.done
+ const resolvedCancelLabel = cancelLabel ?? t.common.cancel
useEffect(() => {
if (open) {
@@ -60,7 +66,7 @@ export function ConfirmDialog({
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
- setError(err instanceof Error ? err.message : 'Something went wrong')
+ setError(err instanceof Error ? err.message : t.errors.genericFailure)
}
}
@@ -91,10 +97,10 @@ export function ConfirmDialog({
- {cancelLabel}
+ {resolvedCancelLabel}
void run()} variant={destructive ? 'destructive' : 'default'}>
-
+
diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx
index 099bef305d..10bc68bcab 100644
--- a/apps/desktop/src/components/ui/dialog.tsx
+++ b/apps/desktop/src/components/ui/dialog.tsx
@@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps) {
@@ -42,6 +43,8 @@ function DialogContent({
}: React.ComponentProps & {
showCloseButton?: boolean
}) {
+ const { t } = useI18n()
+
return (
@@ -60,13 +63,13 @@ function DialogContent({
{showCloseButton && (
- Close
+ {t.common.close}
)}
diff --git a/apps/desktop/src/components/ui/pagination.tsx b/apps/desktop/src/components/ui/pagination.tsx
index f8be000782..b635595fc9 100644
--- a/apps/desktop/src/components/ui/pagination.tsx
+++ b/apps/desktop/src/components/ui/pagination.tsx
@@ -1,12 +1,15 @@
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
+ const { t } = useI18n()
+
return (
) {
+ const { t } = useI18n()
+
return (
- Prev
+ {t.ui.pagination.previous}
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
+ const { t } = useI18n()
+
return (
)
type="button"
{...props}
>
- Next
+ {t.ui.pagination.next}
)
diff --git a/apps/desktop/src/components/ui/search-field.tsx b/apps/desktop/src/components/ui/search-field.tsx
index 90d8d7c74b..88b131fdd0 100644
--- a/apps/desktop/src/components/ui/search-field.tsx
+++ b/apps/desktop/src/components/ui/search-field.tsx
@@ -2,6 +2,7 @@ import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -35,6 +36,7 @@ export function SearchField({
trailingAction,
'aria-label': ariaLabel
}: SearchFieldProps) {
+ const { t } = useI18n()
const clear = onClear ?? (() => onChange(''))
return (
@@ -64,7 +66,7 @@ export function SearchField({
) : value ? (
) {
@@ -45,6 +46,8 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
+ const { t } = useI18n()
+
return (
@@ -66,9 +69,12 @@ function SheetContent({
>
{children}
{showCloseButton && (
-
+
- Close
+ {t.common.close}
)}
diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx
index bb93547834..c6c3d5e97a 100644
--- a/apps/desktop/src/components/ui/sidebar.tsx
+++ b/apps/desktop/src/components/ui/sidebar.tsx
@@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
+import { useI18n } from '@/i18n'
import { PanelLeftIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -152,6 +153,7 @@ function Sidebar({
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+ const { t } = useI18n()
if (collapsible === 'none') {
return (
@@ -181,8 +183,8 @@ function Sidebar({
}
>
- Sidebar
- Displays the mobile sidebar.
+ {t.ui.sidebar.title}
+ {t.ui.sidebar.description}
{children}
@@ -240,6 +242,7 @@ function Sidebar({
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) {
const { toggleSidebar } = useSidebar()
+ const { t } = useI18n()
return (
- Toggle Sidebar
+ {t.ui.sidebar.toggle}
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
+ const { t } = useI18n()
return (
) {
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
- title="Toggle Sidebar"
+ title={t.ui.sidebar.toggle}
{...props}
/>
)
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 22f6b6b5a9..17c42e2d36 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -4,14 +4,38 @@ import type { Translations } from './types'
export const en: Translations = {
common: {
+ apply: 'Apply',
+ back: 'Back',
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
+ change: 'Change',
+ choose: 'Choose',
+ clear: 'Clear',
close: 'Close',
+ collapse: 'Collapse',
confirm: 'Confirm',
+ connect: 'Connect',
+ connecting: 'Connecting',
+ continue: 'Continue',
+ copied: 'Copied',
+ copy: 'Copy',
delete: 'Delete',
+ docs: 'Docs',
+ done: 'Done',
+ error: 'Error',
+ free: 'Free',
+ loading: 'Loading…',
+ notSet: 'Not set',
refresh: 'Refresh',
+ remove: 'Remove',
+ replace: 'Replace',
retry: 'Retry',
+ run: 'Run',
+ send: 'Send',
+ set: 'Set',
+ skip: 'Skip',
+ update: 'Update',
on: 'On',
off: 'Off'
},
@@ -198,6 +222,227 @@ export const en: Translations = {
hoursAgo: count => `${count} hours ago`,
daysAgo: count => `${count} days ago`
}
+ ,
+ config: {
+ none: 'None',
+ noneParen: '(none)',
+ notSet: 'Not set',
+ commaSeparated: 'comma-separated values',
+ loading: 'Loading Hermes configuration...',
+ emptyTitle: 'Nothing to configure',
+ emptyDesc: 'This section has no adjustable settings.',
+ failedLoad: 'Settings failed to load',
+ autosaveFailed: 'Autosave failed',
+ imported: 'Config imported',
+ invalidJson: 'Invalid config JSON'
+ },
+ credentials: {
+ pasteKey: 'Paste key',
+ pasteLabelKey: label => `Paste ${label} key`,
+ optional: 'Optional',
+ enterValueFirst: 'Enter a value first.',
+ couldNotSave: 'Could not save credential.',
+ remove: 'Remove',
+ or: 'or',
+ escToCancel: 'esc to cancel',
+ getKey: 'Get a key',
+ saving: 'Saving'
+ },
+ envActions: {
+ actionsFor: label => `Actions for ${label}`,
+ credentialActions: 'Credential actions',
+ docs: 'Docs',
+ hideValue: 'Hide value',
+ revealValue: 'Reveal value',
+ replace: 'Replace',
+ set: 'Set',
+ clear: 'Clear'
+ },
+ gateway: {
+ loading: 'Loading gateway settings...',
+ unavailableTitle: 'Gateway settings unavailable',
+ unavailableDesc: 'The desktop IPC bridge does not expose gateway settings.',
+ title: 'Gateway Connection',
+ envOverride: 'env override',
+ intro:
+ 'Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it its own remote host.',
+ appliesTo: 'Applies to',
+ allProfiles: 'All profiles',
+ defaultConnection: 'Default connection for every profile that has no override of its own.',
+ profileConnection: profile => `Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`,
+ envOverrideTitle: 'Environment variables are controlling this desktop session.',
+ envOverrideDesc:
+ 'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
+ localTitle: 'Local gateway',
+ localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
+ remoteTitle: 'Remote gateway',
+ remoteDesc:
+ 'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
+ remoteUrlTitle: 'Remote URL',
+ remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
+ probing: 'Checking how this gateway authenticates…',
+ probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
+ signedIn: 'Signed in',
+ signIn: 'Sign in',
+ signOut: 'Sign out',
+ signInWith: provider => `Sign in with ${provider}`,
+ authTitle: 'Authentication',
+ authSignedInPassword:
+ 'This gateway uses a username and password. You are signed in; the session refreshes automatically.',
+ authSignedInOauth: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.',
+ authNeedsPassword: 'This gateway uses a username and password. Sign in to authorize this desktop app.',
+ authNeedsOauth: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this desktop app.`,
+ tokenTitle: 'Session token',
+ tokenDesc:
+ 'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.',
+ existingToken: value => `Existing token ${value}`,
+ savedToken: 'saved',
+ pasteSessionToken: 'Paste session token',
+ testRemote: 'Test remote',
+ saveForRestart: 'Save for next restart',
+ saveAndReconnect: 'Save and reconnect',
+ diagnostics: 'Diagnostics',
+ diagnosticsDesc: 'Reveal desktop.log in your file manager — useful when the gateway fails to start.',
+ openLogs: 'Open logs',
+ incompleteTitle: 'Remote gateway incomplete',
+ incompleteSignIn: 'Enter a remote URL and sign in before switching to remote.',
+ incompleteToken: 'Enter a remote URL and session token before switching to remote.',
+ incompleteSignInTest: 'Enter a remote URL and sign in before testing.',
+ incompleteTokenTest: 'Enter a remote URL and session token before testing.',
+ enterUrlFirst: 'Enter a remote URL first.',
+ restartingTitle: 'Gateway connection restarting',
+ savedTitle: 'Gateway settings saved',
+ restartingMessage: 'Hermes Desktop will reconnect using the saved settings.',
+ savedMessage: 'Saved for the next restart.',
+ connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`,
+ reachableTitle: 'Remote gateway reachable',
+ signedOutTitle: 'Signed out',
+ signedOutMessage: 'Cleared the remote gateway session.',
+ failedLoad: 'Gateway settings failed to load',
+ signInFailed: 'Sign-in failed',
+ signOutFailed: 'Sign-out failed',
+ testFailed: 'Remote gateway test failed',
+ applyFailed: 'Could not apply gateway settings',
+ saveFailed: 'Could not save gateway settings'
+ },
+ keys: {
+ loading: 'Loading API keys and credentials...',
+ failedLoad: 'API keys failed to load',
+ empty: 'Nothing configured in this category yet.'
+ },
+ mcp: {
+ loading: 'Loading MCP servers...',
+ failedLoad: 'MCP config failed to load',
+ nameRequiredTitle: 'Name required',
+ nameRequiredMessage: 'Give this MCP server a config key.',
+ objectRequired: 'Server config must be a JSON object',
+ invalidJson: 'Invalid MCP JSON',
+ saveFailed: 'Save failed',
+ removeFailed: 'Remove failed',
+ gatewayUnavailableTitle: 'Gateway unavailable',
+ gatewayUnavailableMessage: 'Reconnect the gateway before reloading MCP.',
+ reloadedTitle: 'MCP tools reloaded',
+ reloadedMessage: 'New tool schemas apply to fresh turns.',
+ reloadFailed: 'MCP reload failed',
+ savedTitle: 'MCP server saved',
+ savedMessage: name => `${name} applies after MCP reload.`,
+ newServer: 'New server',
+ reload: 'Reload MCP',
+ reloading: 'Reloading...',
+ emptyTitle: 'No MCP servers',
+ emptyDesc: 'Add a stdio or HTTP server to expose MCP tools.',
+ disabled: 'disabled',
+ editServer: 'Edit server',
+ name: 'Name',
+ serverJson: 'Server JSON',
+ remove: 'Remove',
+ saveServer: 'Save server'
+ },
+ model: {
+ loading: 'Loading model configuration...',
+ appliesDesc: 'Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.',
+ provider: 'Provider',
+ model: 'Model',
+ applying: 'Applying...',
+ auxiliaryTitle: 'Auxiliary models',
+ resetAllToMain: 'Reset all to main',
+ auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
+ setToMain: 'Set to main',
+ change: 'Change',
+ autoUseMain: 'auto · use main model',
+ providerDefault: '(provider default)',
+ tasks: {
+ vision: { label: 'Vision', hint: 'Image analysis' },
+ web_extract: { label: 'Web extract', hint: 'Page summarization' },
+ compression: { label: 'Compression', hint: 'Context compaction' },
+ skills_hub: { label: 'Skills hub', hint: 'Skill search' },
+ approval: { label: 'Approval', hint: 'Smart auto-approve' },
+ mcp: { label: 'MCP', hint: 'MCP tool routing' },
+ title_generation: { label: 'Title gen', hint: 'Session titles' },
+ curator: { label: 'Curator', hint: 'Skill-usage review' }
+ }
+ },
+ providers: {
+ connectAccount: 'Connect an account',
+ haveApiKey: 'Have an API key instead?',
+ intro: 'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.',
+ connected: 'Connected',
+ collapse: 'Collapse',
+ connectAnother: 'Connect another provider',
+ otherProviders: 'Other providers',
+ noProviderKeys: 'No provider API keys available.',
+ loading: 'Loading providers...'
+ },
+ sessions: {
+ loading: 'Loading archived sessions…',
+ archivedTitle: 'Archived sessions',
+ archivedIntro:
+ 'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
+ emptyArchivedTitle: 'Nothing archived',
+ emptyArchivedDesc: 'Archive a chat to hide it here.',
+ unarchive: 'Unarchive',
+ deletePermanently: 'Delete permanently',
+ messages: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
+ restored: 'Restored',
+ deleteConfirm: title => `Permanently delete "${title}"? This cannot be undone.`,
+ defaultDirTitle: 'Default project directory',
+ defaultDirDesc:
+ 'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
+ defaultDirUpdated: 'Default project directory updated',
+ defaultsTo: label => `Defaults to ${label}.`,
+ change: 'Change',
+ choose: 'Choose',
+ clear: 'Clear',
+ notSet: 'Not set',
+ failedLoad: 'Could not load archived sessions',
+ unarchiveFailed: 'Unarchive failed',
+ deleteFailed: 'Delete failed',
+ updateDirFailed: 'Could not update default directory',
+ clearDirFailed: 'Could not clear default directory'
+ },
+ toolsets: {
+ loadingConfig: 'Loading configuration',
+ savedTitle: 'Credential saved',
+ savedMessage: key => `${key} updated.`,
+ removedTitle: 'Credential removed',
+ removedMessage: key => `${key} removed.`,
+ failedSave: key => `Failed to save ${key}`,
+ failedRemove: key => `Failed to remove ${key}`,
+ failedReveal: key => `Failed to reveal ${key}`,
+ removeConfirm: key => `Remove ${key} from .env?`,
+ set: 'Set',
+ notSet: 'Not set',
+ selectedTitle: 'Provider selected',
+ selectedMessage: provider => `${provider} is now active.`,
+ failedSelect: provider => `Failed to select ${provider}`,
+ failedLoad: 'Tool configuration failed to load',
+ noProviderOptions: 'This toolset has no provider options — enable it and it works with your current setup.',
+ noProviders: 'No providers are available for this toolset right now.',
+ ready: 'Ready',
+ nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
+ noApiKeyRequired: 'No API key required.',
+ postSetup: step => `This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.`
+ }
},
skills: {
@@ -262,7 +507,18 @@ export const en: Translations = {
commandCenter: {
close: 'Close command center',
+ paletteTitle: 'Command palette',
+ back: 'Back',
searchPlaceholder: 'Search sessions, views, and actions',
+ goTo: 'Go to',
+ commandCenter: 'Command Center',
+ appearance: 'Appearance',
+ settings: 'Settings',
+ changeTheme: 'Change theme...',
+ changeColorMode: 'Change color mode...',
+ settingsFields: 'Settings fields',
+ mcpServers: 'MCP servers',
+ archivedChats: 'Archived chats',
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
sectionDescriptions: {
sessions: 'Search and manage sessions',
@@ -371,7 +627,79 @@ export const en: Translations = {
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
- fieldCopy: {},
+ fieldCopy: {
+ TELEGRAM_BOT_TOKEN: {
+ label: 'Bot token',
+ help: 'Create a bot with @BotFather, then paste the token it gives you.',
+ placeholder: 'Paste Telegram bot token'
+ },
+ TELEGRAM_ALLOWED_USERS: {
+ label: 'Allowed Telegram user IDs',
+ help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
+ },
+ TELEGRAM_PROXY: { label: 'Proxy URL', help: 'Only needed on networks where Telegram is blocked.' },
+ DISCORD_BOT_TOKEN: {
+ label: 'Bot token',
+ help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
+ },
+ DISCORD_ALLOWED_USERS: { label: 'Allowed Discord user IDs', help: 'Recommended. Comma-separated Discord user IDs.' },
+ DISCORD_REPLY_TO_MODE: { label: 'Reply style', help: 'first, all, or off.' },
+ DISCORD_ALLOW_ALL_USERS: {
+ label: 'Allow all Discord users',
+ help: 'Development only. When true, anyone can DM the bot without an allowlist.'
+ },
+ DISCORD_HOME_CHANNEL: {
+ label: 'Home channel ID',
+ help: 'Channel where the bot sends proactive messages (cron output, reminders).'
+ },
+ DISCORD_HOME_CHANNEL_NAME: {
+ label: 'Home channel name',
+ help: 'Display name for the home channel in logs and status output.'
+ },
+ BLUEBUBBLES_ALLOW_ALL_USERS: { label: 'Allow all iMessage users', help: 'When true, skip the BlueBubbles allowlist.' },
+ MATTERMOST_ALLOW_ALL_USERS: { label: 'Allow all Mattermost users' },
+ MATTERMOST_HOME_CHANNEL: { label: 'Home channel' },
+ QQ_ALLOW_ALL_USERS: { label: 'Allow all QQ users' },
+ QQBOT_HOME_CHANNEL: { label: 'QQ home channel', help: 'Default channel or group for cron delivery.' },
+ QQBOT_HOME_CHANNEL_NAME: { label: 'QQ home channel name' },
+ SLACK_BOT_TOKEN: {
+ label: 'Slack bot token',
+ help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
+ placeholder: 'Paste Slack bot token'
+ },
+ SLACK_APP_TOKEN: {
+ label: 'Slack app token',
+ help: 'Use the app-level token required for Socket Mode.',
+ placeholder: 'Paste Slack app token'
+ },
+ SLACK_ALLOWED_USERS: { label: 'Allowed Slack user IDs', help: 'Recommended. Comma-separated Slack user IDs.' },
+ MATTERMOST_URL: { label: 'Server URL', placeholder: 'https://mattermost.example.com' },
+ MATTERMOST_TOKEN: { label: 'Bot token' },
+ MATTERMOST_ALLOWED_USERS: { label: 'Allowed user IDs', help: 'Recommended. Comma-separated Mattermost user IDs.' },
+ MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' },
+ MATRIX_ACCESS_TOKEN: { label: 'Access token' },
+ MATRIX_USER_ID: { label: 'Bot user ID', placeholder: '@hermes:example.org' },
+ MATRIX_ALLOWED_USERS: {
+ label: 'Allowed Matrix user IDs',
+ help: 'Recommended. Comma-separated user IDs in @user:server format.'
+ },
+ SIGNAL_HTTP_URL: {
+ label: 'Signal bridge URL',
+ placeholder: 'http://127.0.0.1:8080',
+ help: 'URL of a running signal-cli REST bridge.'
+ },
+ SIGNAL_ACCOUNT: { label: 'Phone number', help: 'The number registered with your signal-cli bridge.' },
+ SIGNAL_ALLOWED_USERS: { label: 'Allowed Signal users', help: 'Recommended. Comma-separated Signal identifiers.' },
+ WHATSAPP_ENABLED: {
+ label: 'Enable WhatsApp bridge',
+ help: 'Set automatically by the toggle below. Leave alone unless you know you need it.'
+ },
+ WHATSAPP_MODE: { label: 'Bridge mode' },
+ WHATSAPP_ALLOWED_USERS: {
+ label: 'Allowed WhatsApp users',
+ help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
+ }
+ },
platformIntro: {}
},
@@ -382,6 +710,15 @@ export const en: Translations = {
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
loading: 'Loading profiles...',
newProfile: 'New profile',
+ allProfiles: 'All profiles',
+ showAllProfiles: 'Show all profiles',
+ switchToProfile: name => `Switch to ${name}`,
+ manageProfiles: 'Manage profiles...',
+ actionsFor: name => `Actions for ${name}`,
+ color: 'Color...',
+ colorFor: name => `Color for ${name}`,
+ setColor: color => `Set color ${color}`,
+ autoColor: 'Auto',
noProfiles: 'No profiles yet.',
selectPrompt: 'Select a profile to view its details.',
refresh: 'Refresh profiles',
@@ -397,6 +734,10 @@ export const en: Translations = {
skillsLabel: 'Skills',
notSet: 'Not set',
soulDesc: 'The system prompt and persona instructions baked into this profile.',
+ soulOptional: 'optional',
+ soulPlaceholder: mode => `The system prompt / persona for this profile.\nLeave blank to keep the ${mode} default.`,
+ soulPlaceholderCloned: 'cloned',
+ soulPlaceholderEmpty: 'empty',
unsavedChanges: 'Unsaved changes',
loadingSoul: 'Loading SOUL.md...',
emptySoul: 'Empty SOUL.md — start writing the persona...',
@@ -632,6 +973,7 @@ export const en: Translations = {
composer: {
message: 'Message',
+ wakingProfile: profile => `Waking up ${profile}…`,
placeholderStarting: 'Starting Hermes...',
placeholderReconnecting: 'Reconnecting to Hermes…',
placeholderFollowUp: 'Send follow-up',
@@ -703,6 +1045,7 @@ export const en: Translations = {
emptyTurn: 'Empty turn',
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
+ editingQueuedInComposer: 'Editing queued turn in composer',
editQueued: 'Edit queued turn',
sendQueuedNext: 'Send queued turn next',
sendQueuedNow: 'Send queued turn now',
@@ -730,6 +1073,8 @@ export const en: Translations = {
tipPost: ' to reference files inline.',
snippetsTitle: 'Prompt snippets',
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
+ dropFiles: 'Drop files to attach',
+ dropSession: 'Drop to link this chat',
snippets: {
codeReview: {
label: 'Code review',
@@ -747,5 +1092,533 @@ export const en: Translations = {
text: 'Please explain how this works and point me to the key files.'
}
}
+ },
+
+ updates: {
+ stages: {
+ 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'
+ },
+ checking: 'Looking for updates…',
+ checkFailedTitle: 'Couldn’t check for updates',
+ tryAgain: 'Try again',
+ notAvailableTitle: 'Update not available',
+ unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
+ connectionRetry: 'Check your connection and try again.',
+ latestBody: 'You’re running the latest version.',
+ allSetTitle: 'You’re all set',
+ availableTitle: 'New update available',
+ availableBody: 'A new version of Hermes is ready to install.',
+ updateNow: 'Update now',
+ maybeLater: 'Maybe later',
+ moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
+ manualTitle: 'Update from your terminal',
+ manualBody: 'You installed Hermes from the command line, so updates run there too. Paste this into your terminal:',
+ manualPickedUp: 'Hermes will pick up the new version next time you launch it.',
+ copy: 'Copy',
+ copied: 'Copied',
+ done: 'Done',
+ applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.',
+ applyingClose: 'Hermes will close to apply the update.',
+ errorTitle: 'Update didn’t finish',
+ errorBody: 'No worries — nothing was lost. You can try again now.',
+ notNow: 'Not now'
+ },
+
+ install: {
+ stageStates: {
+ pending: 'Pending',
+ running: 'Installing',
+ succeeded: 'Done',
+ skipped: 'Skipped',
+ failed: 'Failed'
+ },
+ oneTimeTitle: 'Hermes needs a one-time install',
+ unsupportedDesc: platform =>
+ `Automated first-launch install isn’t available on ${platform} yet. Open Terminal and run the command below, then relaunch this app. Subsequent launches will skip this step.`,
+ installCommand: 'Install command',
+ copyCommand: 'Copy command',
+ viewDocs: 'View install docs',
+ installTo: 'Will install to',
+ retryAfterRun: 'I’ve run it -- retry',
+ failedTitle: 'Installation failed',
+ settingUpTitle: 'Setting up Hermes Agent',
+ finishingTitle: 'Finishing up',
+ failedDesc:
+ '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.',
+ activeDesc:
+ 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.',
+ progress: (completed, total) => `${completed} of ${total} steps complete`,
+ currentStage: stage => ` -- now: ${stage}`,
+ fetchingManifest: 'Fetching installer manifest...',
+ error: 'Error',
+ hideOutput: 'Hide installer output',
+ showOutput: 'Show installer output',
+ lines: count => `${count} line${count === 1 ? '' : 's'}`,
+ noOutput: 'No output yet.',
+ cancelling: 'Cancelling...',
+ cancelInstall: 'Cancel install',
+ transcriptSaved: 'Full transcript saved to',
+ copiedOutput: 'Copied!',
+ copyOutput: 'Copy output',
+ reloadRetry: 'Reload and retry'
+ },
+
+ onboarding: {
+ headerTitle: "Let's get you setup with Hermes Agent",
+ headerDesc: 'Connect a model provider to start chatting. Most options take one click.',
+ preparingInstall: 'Hermes is finishing install. This usually takes under a minute on first run.',
+ starting: 'Starting Hermes…',
+ lookingUpProviders: 'Looking up providers...',
+ collapse: 'Collapse',
+ otherProviders: 'Other providers',
+ haveApiKey: 'I have an API key',
+ chooseLater: "I'll choose a provider later",
+ recommended: 'Recommended',
+ connected: 'Connected',
+ featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes',
+ openRouterPitch: 'One key, hundreds of models — a solid default',
+ apiKeyOptions: {
+ openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' },
+ openai: { short: 'GPT-class models', description: 'Direct access to OpenAI models.' },
+ gemini: { short: 'Gemini models', description: 'Direct access to Google Gemini models.' },
+ xai: { short: 'Grok models', description: 'Direct access to xAI Grok models.' },
+ local: {
+ short: 'self-hosted',
+ description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).'
+ }
+ },
+ backToSignIn: 'Back to sign in',
+ getKey: 'Get a key',
+ replaceCurrent: 'Replace current value',
+ pasteApiKey: 'Paste API key',
+ couldNotSave: 'Could not save credential.',
+ connecting: 'Connecting',
+ update: 'Update',
+ flowSubtitles: {
+ 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'
+ },
+ startingSignIn: provider => `Starting sign-in for ${provider}...`,
+ verifyingCode: provider => `Verifying your code with ${provider}...`,
+ connectedProvider: provider => `${provider} connected.`,
+ connectedPicking: provider => `${provider} connected. Picking a default model...`,
+ signInFailed: 'Sign-in failed. Try again.',
+ pickDifferentProvider: 'Pick a different provider',
+ signInWith: provider => `Sign in with ${provider}`,
+ openedBrowser: provider => `We opened ${provider} in your browser.`,
+ authorizeThere: 'Authorize Hermes there.',
+ copyAuthCode: 'Copy the authorization code and paste it below.',
+ pasteAuthCode: 'Paste authorization code',
+ reopenAuthPage: 'Re-open authorization page',
+ autoBrowser: provider =>
+ `We opened ${provider} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to copy or paste.`,
+ reopenSignInPage: 'Re-open sign-in page',
+ waitingAuthorize: 'Waiting for you to authorize...',
+ externalPending: provider =>
+ `${provider} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed in":`,
+ signedIn: "I've signed in",
+ deviceCodeOpened: provider => `We opened ${provider} in your browser. Enter this code there:`,
+ reopenVerification: 'Re-open verification page',
+ copy: 'Copy',
+ defaultModel: 'Default model',
+ freeTier: 'Free tier',
+ pro: 'Pro',
+ free: 'Free',
+ price: (input, output) => `${input} in / ${output} out per Mtok`,
+ change: 'Change',
+ startChatting: 'Start chatting',
+ docs: provider => `${provider} docs`
+ },
+
+ modelPicker: {
+ title: 'Switch model',
+ current: 'current:',
+ unknown: '(unknown)',
+ search: 'Filter providers and models...',
+ noModels: 'No models found.',
+ persistGlobalSession: 'Persist globally (otherwise this session only)',
+ persistGlobal: 'Persist globally',
+ addProvider: 'Add provider',
+ loadFailed: 'Could not load models',
+ noAuthenticatedProviders: 'No authenticated providers.',
+ pro: 'Pro',
+ proNeedsSubscription: 'Pro models need a paid Nous subscription.',
+ free: 'Free',
+ freeTier: 'Free tier',
+ priceTitle: 'Input / Output price per million tokens'
+ },
+
+ modelVisibility: {
+ title: 'Models',
+ search: 'Search models',
+ noAuthenticatedProviders: 'No authenticated providers.',
+ addProvider: 'Add provider…'
+ },
+
+ shell: {
+ windowControls: 'Window controls',
+ paneControls: 'Pane controls',
+ appControls: 'App controls',
+ modelMenu: {
+ search: 'Search models',
+ noModels: 'No models found',
+ editModels: 'Edit Models…',
+ fast: 'Fast',
+ medium: 'Med'
+ },
+ modelOptions: {
+ noOptions: 'No options for this model',
+ options: 'Options',
+ thinking: 'Thinking',
+ fast: 'Fast',
+ effort: 'Effort',
+ minimal: 'Minimal',
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+ max: 'Max',
+ updateFailed: 'Model option update failed',
+ fastFailed: 'Fast mode update failed'
+ },
+ gatewayMenu: {
+ gateway: 'Gateway',
+ connected: 'Connected',
+ connecting: 'Connecting',
+ offline: 'Offline',
+ inferenceReady: 'Inference ready',
+ inferenceNotReady: 'Inference not ready',
+ checkingInference: 'Checking inference',
+ disconnected: 'Disconnected',
+ openSystem: 'Open system panel',
+ connection: label => `Connection: ${label}`,
+ recentActivity: 'Recent activity',
+ viewAllLogs: 'View all logs →',
+ messagingPlatforms: 'Messaging platforms'
+ },
+ statusbar: {
+ unknown: 'unknown',
+ restart: 'restart',
+ update: 'update',
+ updateInProgress: 'Update in progress',
+ commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
+ desktopVersion: version => `Hermes Desktop v${version}`,
+ commit: sha => `commit ${sha}`,
+ branch: branch => `branch ${branch}`,
+ closeCommandCenter: 'Close Command Center',
+ openCommandCenter: 'Open Command Center',
+ gateway: 'Gateway',
+ gatewayReady: 'ready',
+ gatewayNeedsSetup: 'needs setup',
+ gatewayChecking: 'checking',
+ gatewayConnecting: 'connecting',
+ gatewayOffline: 'offline',
+ gatewayTitle: 'Hermes inference gateway status',
+ agents: 'Agents',
+ closeAgents: 'Close agents',
+ openAgents: 'Open agents',
+ subagents: count => `${count} subagent${count === 1 ? '' : 's'}`,
+ failed: count => `${count} failed`,
+ running: count => `${count} running`,
+ cron: 'Cron',
+ openCron: 'Open cron jobs',
+ turnRunning: 'Running',
+ currentTurnElapsed: 'Current turn elapsed',
+ contextUsage: 'Context usage',
+ session: 'Session',
+ runtimeSessionElapsed: 'Runtime session elapsed',
+ yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
+ yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
+ modelNone: 'none',
+ noModel: 'no model',
+ switchModel: 'Switch model',
+ openModelPicker: 'Open model picker',
+ modelTitle: (provider, model) => `Model · ${provider}: ${model}`,
+ providerModelTitle: (provider, model) => `${provider} · ${model}`
+ }
+ },
+
+ rightSidebar: {
+ aria: 'Right sidebar',
+ panelsAria: 'Right sidebar panels',
+ files: 'File system',
+ terminal: 'Terminal',
+ noFolderSelected: 'No folder selected',
+ changeCwdTitle: 'Change working directory',
+ folderTip: cwd => `${cwd} — click to change folder`,
+ openFolder: 'Open folder',
+ refreshTree: 'Refresh tree',
+ collapseAll: 'Collapse all folders',
+ previewUnavailable: 'Preview unavailable',
+ couldNotPreview: path => `Could not preview ${path}`,
+ noProjectTitle: 'No project',
+ noProjectBody: 'Set a working directory from the status bar to browse files.',
+ unreadableTitle: 'Unreadable',
+ unreadableBody: error => `Could not read this folder (${error}).`,
+ emptyTitle: 'Empty',
+ emptyBody: 'This folder is empty.',
+ treeErrorTitle: 'Tree error',
+ treeErrorBody: 'The file tree hit an error rendering this folder.',
+ tryAgain: 'Try again',
+ loadingTree: 'Loading file tree',
+ loadingFiles: 'Loading files',
+ terminalFocus: 'Focus terminal view',
+ terminalSplit: 'Return to split view',
+ addToChat: 'Add to chat'
+ },
+
+ preview: {
+ tab: 'Preview',
+ closeTab: label => `Close ${label}`,
+ closePane: 'Close preview pane',
+ loading: 'Loading preview',
+ unavailable: 'Preview unavailable',
+ opening: 'Opening...',
+ hide: 'Hide',
+ openPreview: 'Open preview',
+ sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
+ source: 'SOURCE',
+ renderedPreview: 'PREVIEW',
+ unknownSize: 'unknown size',
+ binaryTitle: 'This looks like a binary file',
+ binaryBody: label => `Previewing ${label} may show unreadable text.`,
+ largeTitle: 'This file is large',
+ largeBody: (label, size) => `${label} is ${size}. Hermes will only show the first 512 KB.`,
+ previewAnyway: 'Preview anyway',
+ truncated: 'Showing first 512 KB.',
+ noInlineTitle: 'No inline preview',
+ noInlineBody: mimeType => `${mimeType || 'This file type'} can still be attached as context.`,
+ console: {
+ deselect: 'Deselect entry',
+ select: 'Select entry',
+ copyFailed: 'Could not copy console output',
+ copyEntry: 'Copy this entry',
+ sendEntry: 'Send this entry to chat',
+ messages: count => `${count} console messages`,
+ resize: 'Resize preview console',
+ title: 'Preview Console',
+ selected: count => `${count} selected`,
+ sendToChat: 'Send to chat',
+ copySelected: 'Copy selected to clipboard',
+ copyAll: 'Copy all to clipboard',
+ copy: 'Copy',
+ clear: 'Clear',
+ empty: 'No console messages yet.',
+ promptHeader: 'Preview console:',
+ sentTitle: 'Sent to chat',
+ sentMessage: count => `${count} log entr${count === 1 ? 'y' : 'ies'} added to composer`
+ },
+ web: {
+ appFailedToBoot: 'Preview app failed to boot',
+ serverNotFound: 'Server not found',
+ failedToLoad: 'Preview failed to load',
+ tryAgain: 'Try again',
+ restarting: 'Hermes is restarting...',
+ askRestart: 'Ask Hermes to restart the server',
+ lookingRestart: taskId => `Hermes is looking for a preview server to restart (${taskId})`,
+ restartingTitle: 'Restarting preview server',
+ restartingMessage: 'Hermes is working in the background. Watch the preview console for progress.',
+ startRestartFailed: message => `Could not start server restart: ${message}`,
+ restartFailed: 'Server restart failed',
+ hideConsole: 'Hide preview console',
+ showConsole: 'Show preview console',
+ hideDevTools: 'Hide preview DevTools',
+ openDevTools: 'Open preview DevTools',
+ finishedRestarting: message =>
+ `Hermes finished restarting the preview server${message ? `: ${message}` : ''}`,
+ failedRestarting: message => `Server restart failed: ${message}`,
+ unknownError: 'unknown error',
+ restartedTitle: 'Preview server restarted',
+ reloadingNow: 'Reloading the preview now.',
+ restartFailedTitle: 'Preview restart failed',
+ restartFailedMessage: 'Hermes could not restart the server.',
+ stillWorking:
+ 'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.',
+ workspaceReloading: 'Workspace changed, reloading preview',
+ fileChanged: url => `File changed, reloading preview: ${url}`,
+ filesChanged: (count, url) => `${count} file changes, reloading preview: ${url}`,
+ watchFailed: message => `Could not watch preview file: ${message}`,
+ moduleMimeDescription:
+ 'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
+ loadFailedConsole: (code, message) => `Load failed${code ? ` (${code})` : ''}: ${message}`,
+ unreachableDescription: 'The preview page could not be reached.',
+ openTarget: url => `Open ${url}`,
+ fallbackTitle: 'Preview'
+ }
+ },
+
+ assistant: {
+ thread: {
+ loadingSession: 'Loading session',
+ loadingResponse: 'Hermes is loading a response',
+ thinking: 'Thinking',
+ today: time => `Today, ${time}`,
+ yesterday: time => `Yesterday, ${time}`,
+ copy: 'Copy',
+ refresh: 'Refresh',
+ moreActions: 'More actions',
+ branchNewChat: 'Branch in new chat',
+ readAloudFailed: 'Read aloud failed',
+ preparingAudio: 'Preparing audio...',
+ stopReading: 'Stop reading',
+ readAloud: 'Read aloud',
+ editMessage: 'Edit message',
+ stop: 'Stop',
+ editableCheckpoint: 'Editable checkpoint',
+ restorePrevious: 'Restore previous checkpoint',
+ restoreCheckpoint: 'Restore checkpoint',
+ restoreNext: 'Restore next checkpoint',
+ goForward: 'Go forward',
+ sendEdited: 'Send edited message'
+ },
+ approval: {
+ gatewayDisconnected: 'Hermes gateway is not connected',
+ sendFailed: 'Could not send approval response',
+ run: 'Run',
+ moreOptions: 'More approval options',
+ allowSession: 'Allow this session',
+ alwaysAllowMenu: 'Always allow…',
+ reject: 'Reject',
+ alwaysTitle: 'Always allow this command?',
+ alwaysDescription: pattern =>
+ `This adds the “${pattern}” pattern to your permanent allowlist (~/.hermes/config.yaml). Hermes won’t ask again for commands like this — in this session or any future one.`,
+ alwaysAllow: 'Always allow'
+ },
+ clarify: {
+ notReady: 'Clarify request is not ready yet',
+ gatewayDisconnected: 'Hermes gateway is not connected',
+ sendFailed: 'Could not send clarify response',
+ loadingQuestion: 'Loading question…',
+ other: 'Other (type your answer)',
+ placeholder: 'Type your answer…',
+ shortcut: '⌘/Ctrl + Enter to send',
+ back: 'Back',
+ skip: 'Skip',
+ send: 'Send'
+ },
+ tool: {
+ code: 'Code',
+ copyCode: 'Copy code',
+ renderingImage: 'Rendering image',
+ copyOutput: 'Copy output',
+ copyCommand: 'Copy command',
+ copyContent: 'Copy content',
+ copyUrl: 'Copy URL',
+ copyResults: 'Copy results',
+ copyQuery: 'Copy query',
+ copyFile: 'Copy file',
+ copyPath: 'Copy path',
+ outputAlt: 'Tool output',
+ rawResponse: 'Raw response',
+ copyActivity: 'Copy activity',
+ recoveredOne: 'Recovered after 1 failed step',
+ recoveredMany: count => `Recovered after ${count} failed steps`,
+ failedOne: '1 step failed',
+ failedMany: count => `${count} steps failed`
+ }
+ },
+
+ prompts: {
+ gatewayDisconnected: 'Hermes gateway is not connected',
+ sudoSendFailed: 'Could not send sudo password',
+ secretSendFailed: 'Could not send secret',
+ sudoTitle: 'Administrator password',
+ sudoDesc: 'Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.',
+ sudoPlaceholder: 'sudo password',
+ secretTitle: 'Secret required',
+ secretDesc: 'Hermes needs a credential to continue.',
+ secretPlaceholder: 'secret value'
+ },
+
+ desktop: {
+ audioReadFailed: 'Could not read recorded audio',
+ sessionUnavailable: 'Session unavailable',
+ createSessionFailed: 'Could not create a new session',
+ promptFailed: 'Prompt failed',
+ providerCredentialRequired: 'Add a provider credential before sending your first message.',
+ emptySlashCommand: 'empty slash command',
+ desktopCommands: 'Desktop commands',
+ skillCommandsAvailable: count => `${count} skill commands available.`,
+ warningLine: message => `warning: ${message}`,
+ yoloArmed: 'YOLO armed for this chat',
+ yoloOff: 'YOLO off',
+ yoloSystem: active => `YOLO ${active ? 'on' : 'off'} for this session`,
+ yoloTitle: 'YOLO',
+ yoloToggleFailed: 'Could not toggle YOLO',
+ profileStatus: current =>
+ `Profile: ${current}. Use /profile or the "New session" picker to start a chat in another profile.`,
+ unknownProfile: 'Unknown profile',
+ noProfileNamed: (target, available) => `No profile named "${target}". Available: ${available}`,
+ newChatsProfile: name => `New chats will use profile ${name}.`,
+ setProfileFailed: 'Failed to set profile',
+ sttDisabled: 'Speech-to-text is disabled in settings.',
+ stopFailed: 'Stop failed',
+ regenerateFailed: 'Regenerate failed',
+ editFailed: 'Edit failed',
+ resumeFailed: 'Resume failed',
+ nothingToBranch: 'Nothing to branch',
+ branchNeedsChat: 'Start or resume a chat before branching.',
+ sessionBusy: 'Session busy',
+ branchStopCurrent: 'Stop the current turn before branching this chat.',
+ branchNoText: 'This message has no text to branch from.',
+ branchTitle: 'Branch',
+ branchFailed: 'Branch failed',
+ deleteFailed: 'Delete failed',
+ archived: 'Archived',
+ archiveFailed: 'Archive failed',
+ cwdChangeFailed: 'Working directory change failed',
+ cwdStagedTitle: 'Working directory staged',
+ cwdStagedMessage: 'Restart the desktop backend to apply cwd changes to this active session.',
+ modelSwitchFailed: 'Model switch failed',
+ sessionExported: 'Session exported',
+ sessionExportFailed: 'Could not export session',
+ imageSaved: 'Image saved',
+ downloadStarted: 'Download started',
+ restartToUseSaveImage: 'Restart Hermes Desktop to use Save Image.',
+ restartToSaveImages: 'Restart Hermes Desktop to save images',
+ imageDownloadFailed: 'Image download failed',
+ imagePreviewFailed: 'Image preview failed',
+ imageAttach: 'Image attach',
+ imageWriteFailed: 'Failed to write image to disk.',
+ imageAttachFailed: 'Image attach failed',
+ attachImages: 'Attach images',
+ clipboard: 'Clipboard',
+ noClipboardImage: 'No image found in clipboard',
+ clipboardPasteFailed: 'Clipboard paste failed',
+ dropFiles: 'Drop files'
+ },
+
+ errors: {
+ genericFailure: 'Something went wrong',
+ boundaryTitle: 'Something broke in the interface',
+ boundaryDesc: 'The view hit an unexpected error. Your chats and settings are safe.',
+ reloadWindow: 'Reload window',
+ openLogs: 'Open logs'
+ },
+
+ ui: {
+ search: {
+ clear: 'Clear search'
+ },
+ pagination: {
+ label: 'pagination',
+ previous: 'Prev',
+ previousAria: 'Go to previous page',
+ next: 'Next',
+ nextAria: 'Go to next page'
+ },
+ sidebar: {
+ title: 'Sidebar',
+ description: 'Displays the mobile sidebar.',
+ toggle: 'Toggle Sidebar'
+ }
}
}
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index b5a3708b19..3c93dd3955 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1,3 +1,5 @@
+import { defineFieldCopy } from '@/app/settings/field-copy'
+
import { defineLocale } from './define-locale'
export const ja = defineLocale({
@@ -80,102 +82,174 @@ export const ja = defineLocale({
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
},
- fieldLabels: {
+ fieldLabels: defineFieldCopy({
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': '環境変数の引き継ぎ',
+ display: {
+ personality: '人格',
+ show_reasoning: '推論ブロック'
+ },
+ agent: {
+ max_turns: '最大エージェントステップ',
+ image_input_mode: '画像添付',
+ api_max_retries: 'API 再試行回数',
+ service_tier: 'サービス階層',
+ tool_use_enforcement: 'ツール使用の強制'
+ },
+ terminal: {
+ cwd: '作業ディレクトリ',
+ backend: '実行バックエンド',
+ timeout: 'コマンドタイムアウト',
+ persistent_shell: '永続シェル',
+ 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 再読み込みの確認',
+ tool_output: {
+ max_bytes: 'ターミナル出力上限',
+ max_lines: 'ファイルページ上限',
+ max_line_length: '行長上限'
+ },
+ code_execution: {
+ mode: 'コード実行モード'
+ },
+ approvals: {
+ mode: '承認モード',
+ timeout: '承認タイムアウト',
+ 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: {
+ security: {
+ redact_secrets: 'シークレットを伏せる',
+ allow_private_urls: 'プライベート URL を許可'
+ },
+ browser: {
+ allow_private_urls: 'ブラウザーのプライベート URL',
+ auto_local_for_private_urls: 'プライベート URL にはローカルブラウザーを使用'
+ },
+ checkpoints: {
+ enabled: 'ファイルチェックポイント',
+ max_snapshots: 'チェックポイント上限'
+ },
+ voice: {
+ record_key: '音声ショートカット',
+ max_recording_seconds: '最大録音時間',
+ auto_tts: '応答を読み上げる'
+ },
+ stt: {
+ enabled: '音声認識',
+ provider: '音声認識プロバイダー',
+ local: {
+ model: 'ローカル文字起こしモデル',
+ language: '文字起こし言語'
+ },
+ elevenlabs: {
+ model_id: 'ElevenLabs STT モデル',
+ language_code: 'ElevenLabs 言語',
+ tag_audio_events: '音声イベントをタグ付け',
+ diarize: '話者分離'
+ }
+ },
+ tts: {
+ provider: '音声合成プロバイダー',
+ edge: {
+ voice: 'Edge 音声'
+ },
+ openai: {
+ model: 'OpenAI TTS モデル',
+ voice: 'OpenAI 音声'
+ },
+ elevenlabs: {
+ voice_id: 'ElevenLabs 音声',
+ model_id: 'ElevenLabs モデル'
+ }
+ },
+ memory: {
+ memory_enabled: '永続メモリ',
+ user_profile_enabled: 'ユーザープロファイル',
+ memory_char_limit: 'メモリ予算',
+ user_char_limit: 'プロファイル予算',
+ provider: 'メモリプロバイダー'
+ },
+ context: {
+ engine: 'コンテキストエンジン'
+ },
+ compression: {
+ enabled: '自動圧縮',
+ threshold: '圧縮しきい値',
+ target_ratio: '圧縮目標',
+ protect_last_n: '保護する直近メッセージ'
+ },
+ delegation: {
+ model: 'サブエージェントモデル',
+ provider: 'サブエージェントプロバイダー',
+ max_iterations: 'サブエージェントターン上限',
+ max_concurrent_children: '並列サブエージェント',
+ child_timeout_seconds: 'サブエージェントタイムアウト',
+ reasoning_effort: 'サブエージェント推論強度'
+ },
+ updates: {
+ non_interactive_local_changes: 'アプリ内更新時のローカル変更'
+ }
+ }),
+ fieldDescriptions: defineFieldCopy({
model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
- 'display.personality': '新しいセッションのデフォルトのアシスタントスタイルです。',
+ display: {
+ personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
+ show_reasoning: 'バックエンドが推論内容を提供したときに表示します。'
+ },
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
- 'display.show_reasoning': 'バックエンドが推論内容を提供したときに表示します。',
- 'agent.image_input_mode': '画像添付をモデルへ送る方法を制御します。',
- 'terminal.cwd': 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
- 'code_execution.mode': 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。',
- 'terminal.persistent_shell': 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
- 'terminal.env_passthrough': 'ツール実行へ渡す環境変数です。',
+ agent: {
+ image_input_mode: '画像添付をモデルへ送る方法を制御します。',
+ max_turns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
+ },
+ terminal: {
+ cwd: 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
+ persistent_shell: 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
+ env_passthrough: 'ツール実行へ渡す環境変数です。'
+ },
+ code_execution: {
+ mode: 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。'
+ },
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 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
- },
+ approvals: {
+ mode: '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
+ timeout: '承認プロンプトがタイムアウトするまで待つ時間です。'
+ },
+ security: {
+ redact_secrets: '検出したシークレットを、可能な限りモデルから見える内容から隠します。'
+ },
+ checkpoints: {
+ enabled: 'ファイル編集前にロールバック用スナップショットを作成します。'
+ },
+ memory: {
+ memory_enabled: '将来のセッションに役立つ永続メモリを保存します。',
+ user_profile_enabled: 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。'
+ },
+ context: {
+ engine: '長い会話がコンテキスト上限に近づいたときの管理戦略です。'
+ },
+ compression: {
+ enabled: '会話が大きくなったとき、古いコンテキストを要約します。'
+ },
+ voice: {
+ auto_tts: 'アシスタントの応答を自動で読み上げます。'
+ },
+ stt: {
+ enabled: 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
+ elevenlabs: {
+ language_code: '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。'
+ }
+ },
+ updates: {
+ non_interactive_local_changes:
+ 'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
+ }
+ }),
about: {
heading: 'Hermes Desktop',
version: value => `バージョン ${value}`,
diff --git a/apps/desktop/src/i18n/runtime.test.ts b/apps/desktop/src/i18n/runtime.test.ts
index 5d8eee38f0..62ca8610ef 100644
--- a/apps/desktop/src/i18n/runtime.test.ts
+++ b/apps/desktop/src/i18n/runtime.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { setRuntimeI18nLocale, translateNow } from './runtime'
+import { zh } from './zh'
describe('desktop i18n runtime translator', () => {
beforeEach(() => {
@@ -39,6 +40,13 @@ describe('desktop i18n runtime translator', () => {
expect(translateNow('settings.nav.providerApiKeys')).toBe('API 金鑰')
})
+ it('keeps translated settings field copy addressable by schema keys', () => {
+ const field = ['display', 'personality'].join('.')
+
+ expect(zh.settings.fieldLabels[field]).toBe('人格')
+ expect(zh.settings.fieldDescriptions[field]).toBe('新会话的默认助手风格。')
+ })
+
it('falls back to English for untranslated desktop-only keys in partial locales', () => {
setRuntimeI18nLocale('ja')
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index a52c9d0063..6e7da1cbdd 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -12,16 +12,45 @@ interface ModeOptionCopy {
description: string
}
+interface AuxTaskCopy {
+ label: string
+ hint: string
+}
+
export interface Translations {
common: {
+ apply: string
+ back: string
save: string
saving: string
cancel: string
+ change: string
+ choose: string
+ clear: string
close: string
+ collapse: string
confirm: string
+ connect: string
+ connecting: string
+ continue: string
+ copied: string
+ copy: string
delete: string
+ docs: string
+ done: string
+ error: string
+ free: string
+ loading: string
+ notSet: string
refresh: string
+ remove: string
+ replace: string
retry: string
+ run: string
+ send: string
+ set: string
+ skip: string
+ update: string
on: string
off: string
}
@@ -182,6 +211,210 @@ export interface Translations {
hoursAgo: (count: number) => string
daysAgo: (count: number) => string
}
+ config: {
+ none: string
+ noneParen: string
+ notSet: string
+ commaSeparated: string
+ loading: string
+ emptyTitle: string
+ emptyDesc: string
+ failedLoad: string
+ autosaveFailed: string
+ imported: string
+ invalidJson: string
+ }
+ credentials: {
+ pasteKey: string
+ pasteLabelKey: (label: string) => string
+ optional: string
+ enterValueFirst: string
+ couldNotSave: string
+ remove: string
+ or: string
+ escToCancel: string
+ getKey: string
+ saving: string
+ }
+ envActions: {
+ actionsFor: (label: string) => string
+ credentialActions: string
+ docs: string
+ hideValue: string
+ revealValue: string
+ replace: string
+ set: string
+ clear: string
+ }
+ gateway: {
+ loading: string
+ unavailableTitle: string
+ unavailableDesc: string
+ title: string
+ envOverride: string
+ intro: string
+ appliesTo: string
+ allProfiles: string
+ defaultConnection: string
+ profileConnection: (profile: string) => string
+ envOverrideTitle: string
+ envOverrideDesc: string
+ localTitle: string
+ localDesc: string
+ remoteTitle: string
+ remoteDesc: string
+ remoteUrlTitle: string
+ remoteUrlDesc: string
+ probing: string
+ probeError: string
+ signedIn: string
+ signIn: string
+ signOut: string
+ signInWith: (provider: string) => string
+ authTitle: string
+ authSignedInPassword: string
+ authSignedInOauth: string
+ authNeedsPassword: string
+ authNeedsOauth: (provider: string) => string
+ tokenTitle: string
+ tokenDesc: string
+ existingToken: (value: string) => string
+ savedToken: string
+ pasteSessionToken: string
+ testRemote: string
+ saveForRestart: string
+ saveAndReconnect: string
+ diagnostics: string
+ diagnosticsDesc: string
+ openLogs: string
+ incompleteTitle: string
+ incompleteSignIn: string
+ incompleteToken: string
+ incompleteSignInTest: string
+ incompleteTokenTest: string
+ enterUrlFirst: string
+ restartingTitle: string
+ savedTitle: string
+ restartingMessage: string
+ savedMessage: string
+ connectedTo: (baseUrl: string, version?: string) => string
+ reachableTitle: string
+ signedOutTitle: string
+ signedOutMessage: string
+ failedLoad: string
+ signInFailed: string
+ signOutFailed: string
+ testFailed: string
+ applyFailed: string
+ saveFailed: string
+ }
+ keys: {
+ loading: string
+ failedLoad: string
+ empty: string
+ }
+ mcp: {
+ loading: string
+ failedLoad: string
+ nameRequiredTitle: string
+ nameRequiredMessage: string
+ objectRequired: string
+ invalidJson: string
+ saveFailed: string
+ removeFailed: string
+ gatewayUnavailableTitle: string
+ gatewayUnavailableMessage: string
+ reloadedTitle: string
+ reloadedMessage: string
+ reloadFailed: string
+ savedTitle: string
+ savedMessage: (name: string) => string
+ newServer: string
+ reload: string
+ reloading: string
+ emptyTitle: string
+ emptyDesc: string
+ disabled: string
+ editServer: string
+ name: string
+ serverJson: string
+ remove: string
+ saveServer: string
+ }
+ model: {
+ loading: string
+ appliesDesc: string
+ provider: string
+ model: string
+ applying: string
+ auxiliaryTitle: string
+ resetAllToMain: string
+ auxiliaryDesc: string
+ setToMain: string
+ change: string
+ autoUseMain: string
+ providerDefault: string
+ tasks: Record
+ }
+ providers: {
+ connectAccount: string
+ haveApiKey: string
+ intro: string
+ connected: string
+ collapse: string
+ connectAnother: string
+ otherProviders: string
+ noProviderKeys: string
+ loading: string
+ }
+ sessions: {
+ loading: string
+ archivedTitle: string
+ archivedIntro: string
+ emptyArchivedTitle: string
+ emptyArchivedDesc: string
+ unarchive: string
+ deletePermanently: string
+ messages: (count: number) => string
+ restored: string
+ deleteConfirm: (title: string) => string
+ defaultDirTitle: string
+ defaultDirDesc: string
+ defaultDirUpdated: string
+ defaultsTo: (label: string) => string
+ change: string
+ choose: string
+ clear: string
+ notSet: string
+ failedLoad: string
+ unarchiveFailed: string
+ deleteFailed: string
+ updateDirFailed: string
+ clearDirFailed: string
+ }
+ toolsets: {
+ loadingConfig: string
+ savedTitle: string
+ savedMessage: (key: string) => string
+ removedTitle: string
+ removedMessage: (key: string) => string
+ failedSave: (key: string) => string
+ failedRemove: (key: string) => string
+ failedReveal: (key: string) => string
+ removeConfirm: (key: string) => string
+ set: string
+ notSet: string
+ selectedTitle: string
+ selectedMessage: (provider: string) => string
+ failedSelect: (provider: string) => string
+ failedLoad: string
+ noProviderOptions: string
+ noProviders: string
+ ready: string
+ nousIncluded: string
+ noApiKeyRequired: string
+ postSetup: (step: string) => string
+ }
}
skills: {
@@ -246,7 +479,18 @@ export interface Translations {
commandCenter: {
close: string
+ paletteTitle: string
+ back: string
searchPlaceholder: string
+ goTo: string
+ commandCenter: string
+ appearance: string
+ settings: string
+ changeTheme: string
+ changeColorMode: string
+ settingsFields: string
+ mcpServers: string
+ archivedChats: string
sections: Record<'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
@@ -342,6 +586,15 @@ export interface Translations {
count: (count: number) => string
loading: string
newProfile: string
+ allProfiles: string
+ showAllProfiles: string
+ switchToProfile: (name: string) => string
+ manageProfiles: string
+ actionsFor: (name: string) => string
+ color: string
+ colorFor: (name: string) => string
+ setColor: (color: string) => string
+ autoColor: string
noProfiles: string
selectPrompt: string
refresh: string
@@ -357,6 +610,10 @@ export interface Translations {
skillsLabel: string
notSet: string
soulDesc: string
+ soulOptional: string
+ soulPlaceholder: (mode: string) => string
+ soulPlaceholderCloned: string
+ soulPlaceholderEmpty: string
unsavedChanges: string
loadingSoul: string
emptySoul: string
@@ -547,6 +804,7 @@ export interface Translations {
composer: {
message: string
+ wakingProfile: (profile: string) => string
placeholderStarting: string
placeholderReconnecting: string
placeholderFollowUp: string
@@ -586,6 +844,7 @@ export interface Translations {
emptyTurn: string
attachments: (count: number) => string
editingInComposer: string
+ editingQueuedInComposer: string
editQueued: string
sendQueuedNext: string
sendQueuedNow: string
@@ -614,5 +873,496 @@ export interface Translations {
snippetsTitle: string
snippetsDesc: string
snippets: Record
+ dropFiles: string
+ dropSession: string
+ }
+
+ updates: {
+ stages: Record
+ checking: string
+ checkFailedTitle: string
+ tryAgain: string
+ notAvailableTitle: string
+ unsupportedMessage: string
+ connectionRetry: string
+ latestBody: string
+ allSetTitle: string
+ availableTitle: string
+ availableBody: string
+ updateNow: string
+ maybeLater: string
+ moreChanges: (count: number) => string
+ manualTitle: string
+ manualBody: string
+ manualPickedUp: string
+ copy: string
+ copied: string
+ done: string
+ applyingBody: string
+ applyingClose: string
+ errorTitle: string
+ errorBody: string
+ notNow: string
+ }
+
+ install: {
+ stageStates: Record
+ oneTimeTitle: string
+ unsupportedDesc: (platform: string) => string
+ installCommand: string
+ copyCommand: string
+ viewDocs: string
+ installTo: string
+ retryAfterRun: string
+ failedTitle: string
+ settingUpTitle: string
+ finishingTitle: string
+ failedDesc: string
+ activeDesc: string
+ progress: (completed: number, total: number) => string
+ currentStage: (stage: string) => string
+ fetchingManifest: string
+ error: string
+ hideOutput: string
+ showOutput: string
+ lines: (count: number) => string
+ noOutput: string
+ cancelling: string
+ cancelInstall: string
+ transcriptSaved: string
+ copiedOutput: string
+ copyOutput: string
+ reloadRetry: string
+ }
+
+ onboarding: {
+ headerTitle: string
+ headerDesc: string
+ preparingInstall: string
+ starting: string
+ lookingUpProviders: string
+ collapse: string
+ otherProviders: string
+ haveApiKey: string
+ chooseLater: string
+ recommended: string
+ connected: string
+ featuredPitch: string
+ openRouterPitch: string
+ apiKeyOptions: Record
+ backToSignIn: string
+ getKey: string
+ replaceCurrent: string
+ pasteApiKey: string
+ couldNotSave: string
+ connecting: string
+ update: string
+ flowSubtitles: Record
+ startingSignIn: (provider: string) => string
+ verifyingCode: (provider: string) => string
+ connectedProvider: (provider: string) => string
+ connectedPicking: (provider: string) => string
+ signInFailed: string
+ pickDifferentProvider: string
+ signInWith: (provider: string) => string
+ openedBrowser: (provider: string) => string
+ authorizeThere: string
+ copyAuthCode: string
+ pasteAuthCode: string
+ reopenAuthPage: string
+ autoBrowser: (provider: string) => string
+ reopenSignInPage: string
+ waitingAuthorize: string
+ externalPending: (provider: string) => string
+ signedIn: string
+ deviceCodeOpened: (provider: string) => string
+ reopenVerification: string
+ copy: string
+ defaultModel: string
+ freeTier: string
+ pro: string
+ free: string
+ price: (input: string, output: string) => string
+ change: string
+ startChatting: string
+ docs: (provider: string) => string
+ }
+
+ modelPicker: {
+ title: string
+ current: string
+ unknown: string
+ search: string
+ noModels: string
+ persistGlobalSession: string
+ persistGlobal: string
+ addProvider: string
+ loadFailed: string
+ noAuthenticatedProviders: string
+ pro: string
+ proNeedsSubscription: string
+ free: string
+ freeTier: string
+ priceTitle: string
+ }
+
+ modelVisibility: {
+ title: string
+ search: string
+ noAuthenticatedProviders: string
+ addProvider: string
+ }
+
+ shell: {
+ windowControls: string
+ paneControls: string
+ appControls: string
+ modelMenu: {
+ search: string
+ noModels: string
+ editModels: string
+ fast: string
+ medium: string
+ }
+ modelOptions: {
+ noOptions: string
+ options: string
+ thinking: string
+ fast: string
+ effort: string
+ minimal: string
+ low: string
+ medium: string
+ high: string
+ max: string
+ updateFailed: string
+ fastFailed: string
+ }
+ gatewayMenu: {
+ gateway: string
+ connected: string
+ connecting: string
+ offline: string
+ inferenceReady: string
+ inferenceNotReady: string
+ checkingInference: string
+ disconnected: string
+ openSystem: string
+ connection: (label: string) => string
+ recentActivity: string
+ viewAllLogs: string
+ messagingPlatforms: string
+ }
+ statusbar: {
+ unknown: string
+ restart: string
+ update: string
+ updateInProgress: string
+ commitsBehind: (count: number, branch: string) => string
+ desktopVersion: (version: string) => string
+ commit: (sha: string) => string
+ branch: (branch: string) => string
+ closeCommandCenter: string
+ openCommandCenter: string
+ gateway: string
+ gatewayReady: string
+ gatewayNeedsSetup: string
+ gatewayChecking: string
+ gatewayConnecting: string
+ gatewayOffline: string
+ gatewayTitle: string
+ agents: string
+ closeAgents: string
+ openAgents: string
+ subagents: (count: number) => string
+ failed: (count: number) => string
+ running: (count: number) => string
+ cron: string
+ openCron: string
+ turnRunning: string
+ currentTurnElapsed: string
+ contextUsage: string
+ session: string
+ runtimeSessionElapsed: string
+ yoloOn: string
+ yoloOff: string
+ modelNone: string
+ noModel: string
+ switchModel: string
+ openModelPicker: string
+ modelTitle: (provider: string, model: string) => string
+ providerModelTitle: (provider: string, model: string) => string
+ }
+ }
+
+ rightSidebar: {
+ aria: string
+ panelsAria: string
+ files: string
+ terminal: string
+ noFolderSelected: string
+ changeCwdTitle: string
+ folderTip: (cwd: string) => string
+ openFolder: string
+ refreshTree: string
+ collapseAll: string
+ previewUnavailable: string
+ couldNotPreview: (path: string) => string
+ noProjectTitle: string
+ noProjectBody: string
+ unreadableTitle: string
+ unreadableBody: (error: string) => string
+ emptyTitle: string
+ emptyBody: string
+ treeErrorTitle: string
+ treeErrorBody: string
+ tryAgain: string
+ loadingTree: string
+ loadingFiles: string
+ terminalFocus: string
+ terminalSplit: string
+ addToChat: string
+ }
+
+ preview: {
+ tab: string
+ closeTab: (label: string) => string
+ closePane: string
+ loading: string
+ unavailable: string
+ opening: string
+ hide: string
+ openPreview: string
+ sourceLineTitle: string
+ source: string
+ renderedPreview: string
+ unknownSize: string
+ binaryTitle: string
+ binaryBody: (label: string) => string
+ largeTitle: string
+ largeBody: (label: string, size: string) => string
+ previewAnyway: string
+ truncated: string
+ noInlineTitle: string
+ noInlineBody: (mimeType: string) => string
+ console: {
+ deselect: string
+ select: string
+ copyFailed: string
+ copyEntry: string
+ sendEntry: string
+ messages: (count: number) => string
+ resize: string
+ title: string
+ selected: (count: number) => string
+ sendToChat: string
+ copySelected: string
+ copyAll: string
+ copy: string
+ clear: string
+ empty: string
+ promptHeader: string
+ sentTitle: string
+ sentMessage: (count: number) => string
+ }
+ web: {
+ appFailedToBoot: string
+ serverNotFound: string
+ failedToLoad: string
+ tryAgain: string
+ restarting: string
+ askRestart: string
+ lookingRestart: (taskId: string) => string
+ restartingTitle: string
+ restartingMessage: string
+ startRestartFailed: (message: string) => string
+ restartFailed: string
+ hideConsole: string
+ showConsole: string
+ hideDevTools: string
+ openDevTools: string
+ finishedRestarting: (message?: string) => string
+ failedRestarting: (message: string) => string
+ unknownError: string
+ restartedTitle: string
+ reloadingNow: string
+ restartFailedTitle: string
+ restartFailedMessage: string
+ stillWorking: string
+ workspaceReloading: string
+ fileChanged: (url: string) => string
+ filesChanged: (count: number, url: string) => string
+ watchFailed: (message: string) => string
+ moduleMimeDescription: string
+ loadFailedConsole: (code: number | undefined, message: string) => string
+ unreachableDescription: string
+ openTarget: (url: string) => string
+ fallbackTitle: string
+ }
+ }
+
+ assistant: {
+ thread: {
+ loadingSession: string
+ loadingResponse: string
+ thinking: string
+ today: (time: string) => string
+ yesterday: (time: string) => string
+ copy: string
+ refresh: string
+ moreActions: string
+ branchNewChat: string
+ readAloudFailed: string
+ preparingAudio: string
+ stopReading: string
+ readAloud: string
+ editMessage: string
+ stop: string
+ editableCheckpoint: string
+ restorePrevious: string
+ restoreCheckpoint: string
+ restoreNext: string
+ goForward: string
+ sendEdited: string
+ }
+ approval: {
+ gatewayDisconnected: string
+ sendFailed: string
+ run: string
+ moreOptions: string
+ allowSession: string
+ alwaysAllowMenu: string
+ reject: string
+ alwaysTitle: string
+ alwaysDescription: (pattern: string) => string
+ alwaysAllow: string
+ }
+ clarify: {
+ notReady: string
+ gatewayDisconnected: string
+ sendFailed: string
+ loadingQuestion: string
+ other: string
+ placeholder: string
+ shortcut: string
+ back: string
+ skip: string
+ send: string
+ }
+ tool: {
+ code: string
+ copyCode: string
+ renderingImage: string
+ copyOutput: string
+ copyCommand: string
+ copyContent: string
+ copyUrl: string
+ copyResults: string
+ copyQuery: string
+ copyFile: string
+ copyPath: string
+ outputAlt: string
+ rawResponse: string
+ copyActivity: string
+ recoveredOne: string
+ recoveredMany: (count: number) => string
+ failedOne: string
+ failedMany: (count: number) => string
+ }
+ }
+
+ prompts: {
+ gatewayDisconnected: string
+ sudoSendFailed: string
+ secretSendFailed: string
+ sudoTitle: string
+ sudoDesc: string
+ sudoPlaceholder: string
+ secretTitle: string
+ secretDesc: string
+ secretPlaceholder: string
+ }
+
+ desktop: {
+ audioReadFailed: string
+ sessionUnavailable: string
+ createSessionFailed: string
+ promptFailed: string
+ providerCredentialRequired: string
+ emptySlashCommand: string
+ desktopCommands: string
+ skillCommandsAvailable: (count: number) => string
+ warningLine: (message: string) => string
+ yoloArmed: string
+ yoloOff: string
+ yoloSystem: (active: boolean) => string
+ yoloTitle: string
+ yoloToggleFailed: string
+ profileStatus: (current: string) => string
+ unknownProfile: string
+ noProfileNamed: (target: string, available: string) => string
+ newChatsProfile: (name: string) => string
+ setProfileFailed: string
+ sttDisabled: string
+ stopFailed: string
+ regenerateFailed: string
+ editFailed: string
+ resumeFailed: string
+ nothingToBranch: string
+ branchNeedsChat: string
+ sessionBusy: string
+ branchStopCurrent: string
+ branchNoText: string
+ branchTitle: string
+ branchFailed: string
+ deleteFailed: string
+ archived: string
+ archiveFailed: string
+ cwdChangeFailed: string
+ cwdStagedTitle: string
+ cwdStagedMessage: string
+ modelSwitchFailed: string
+ sessionExported: string
+ sessionExportFailed: string
+ imageSaved: string
+ downloadStarted: string
+ restartToUseSaveImage: string
+ restartToSaveImages: string
+ imageDownloadFailed: string
+ imagePreviewFailed: string
+ imageAttach: string
+ imageWriteFailed: string
+ imageAttachFailed: string
+ attachImages: string
+ clipboard: string
+ noClipboardImage: string
+ clipboardPasteFailed: string
+ dropFiles: string
+ }
+
+ errors: {
+ genericFailure: string
+ boundaryTitle: string
+ boundaryDesc: string
+ reloadWindow: string
+ openLogs: string
+ }
+
+ ui: {
+ search: {
+ clear: string
+ }
+ pagination: {
+ label: string
+ previous: string
+ previousAria: string
+ next: string
+ nextAria: string
+ }
+ sidebar: {
+ title: string
+ description: string
+ toggle: string
+ }
}
}
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index ae62ab94be..fbfc012431 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1,3 +1,5 @@
+import { defineFieldCopy } from '@/app/settings/field-copy'
+
import { defineLocale } from './define-locale'
export const zhHant = defineLocale({
@@ -79,102 +81,174 @@ export const zhHant = defineLocale({
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
},
- fieldLabels: {
+ fieldLabels: defineFieldCopy({
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': '環境變數傳遞',
+ display: {
+ personality: '人格',
+ show_reasoning: '推理區塊'
+ },
+ agent: {
+ max_turns: '最大代理步數',
+ image_input_mode: '圖片附件',
+ api_max_retries: 'API 重試次數',
+ service_tier: '服務層級',
+ tool_use_enforcement: '工具使用強制'
+ },
+ terminal: {
+ cwd: '工作目錄',
+ backend: '執行後端',
+ timeout: '指令逾時',
+ persistent_shell: '持久化 Shell',
+ 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 重新載入',
+ tool_output: {
+ max_bytes: '終端機輸出上限',
+ max_lines: '檔案頁面上限',
+ max_line_length: '行長上限'
+ },
+ code_execution: {
+ mode: '程式碼執行模式'
+ },
+ approvals: {
+ mode: '批准模式',
+ timeout: '批准逾時',
+ 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: {
+ security: {
+ redact_secrets: '遮蔽密鑰',
+ allow_private_urls: '允許私有 URL'
+ },
+ browser: {
+ allow_private_urls: '瀏覽器私有 URL',
+ auto_local_for_private_urls: '私有 URL 使用本機瀏覽器'
+ },
+ checkpoints: {
+ enabled: '檔案檢查點',
+ max_snapshots: '檢查點上限'
+ },
+ voice: {
+ record_key: '語音快捷鍵',
+ max_recording_seconds: '最長錄音時間',
+ auto_tts: '朗讀回覆'
+ },
+ stt: {
+ enabled: '語音轉文字',
+ provider: '語音轉文字提供方',
+ local: {
+ model: '本機轉寫模型',
+ language: '轉寫語言'
+ },
+ elevenlabs: {
+ model_id: 'ElevenLabs STT 模型',
+ language_code: 'ElevenLabs 語言',
+ tag_audio_events: '標記音訊事件',
+ diarize: '說話者分離'
+ }
+ },
+ tts: {
+ provider: '文字轉語音提供方',
+ edge: {
+ voice: 'Edge 語音'
+ },
+ openai: {
+ model: 'OpenAI TTS 模型',
+ voice: 'OpenAI 語音'
+ },
+ elevenlabs: {
+ voice_id: 'ElevenLabs 語音',
+ model_id: 'ElevenLabs 模型'
+ }
+ },
+ memory: {
+ memory_enabled: '持久記憶',
+ user_profile_enabled: '使用者設定檔',
+ memory_char_limit: '記憶預算',
+ user_char_limit: '設定檔預算',
+ provider: '記憶提供方'
+ },
+ context: {
+ engine: '上下文引擎'
+ },
+ compression: {
+ enabled: '自動壓縮',
+ threshold: '壓縮閾值',
+ target_ratio: '壓縮目標',
+ protect_last_n: '保護最近訊息'
+ },
+ delegation: {
+ model: '子代理模型',
+ provider: '子代理提供方',
+ max_iterations: '子代理輪次上限',
+ max_concurrent_children: '平行子代理',
+ child_timeout_seconds: '子代理逾時',
+ reasoning_effort: '子代理推理強度'
+ },
+ updates: {
+ non_interactive_local_changes: '應用程式內更新的本機變更'
+ }
+ }),
+ fieldDescriptions: defineFieldCopy({
model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
- 'display.personality': '新工作階段的預設助手風格。',
+ display: {
+ personality: '新工作階段的預設助手風格。',
+ show_reasoning: '後端提供推理內容時顯示該區塊。'
+ },
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
- 'display.show_reasoning': '後端提供推理內容時顯示該區塊。',
- 'agent.image_input_mode': '控制圖片附件如何傳送給模型。',
- 'terminal.cwd': '工具與終端機操作的預設專案資料夾。',
- 'code_execution.mode': '程式碼執行被限制在目前專案中的嚴格程度。',
- 'terminal.persistent_shell': '後端支援時,在指令之間保留 Shell 狀態。',
- 'terminal.env_passthrough': '傳入工具執行的環境變數。',
+ agent: {
+ image_input_mode: '控制圖片附件如何傳送給模型。',
+ max_turns: 'Hermes 停止一次執行前的工具呼叫輪次上限。'
+ },
+ terminal: {
+ cwd: '工具與終端機操作的預設專案資料夾。',
+ persistent_shell: '後端支援時,在指令之間保留 Shell 狀態。',
+ env_passthrough: '傳入工具執行的環境變數。'
+ },
+ code_execution: {
+ mode: '程式碼執行被限制在目前專案中的嚴格程度。'
+ },
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)。終端機更新一律會詢問。'
- },
+ approvals: {
+ mode: 'Hermes 如何處理需要明確批准的指令。',
+ timeout: '批准提示逾時前等待的時間。'
+ },
+ security: {
+ redact_secrets: '盡可能從模型可見內容中隱藏偵測到的密鑰。'
+ },
+ checkpoints: {
+ enabled: '在檔案編輯前建立可回復的快照。'
+ },
+ memory: {
+ memory_enabled: '儲存有助於未來工作階段的持久記憶。',
+ user_profile_enabled: '維護一份精簡的使用者偏好設定檔。'
+ },
+ context: {
+ engine: '長對話接近上下文上限時的管理策略。'
+ },
+ compression: {
+ enabled: '對話變大時摘要較早的上下文。'
+ },
+ voice: {
+ auto_tts: '自動朗讀助手回覆。'
+ },
+ stt: {
+ enabled: '啟用本機或提供方支援的語音轉寫。',
+ elevenlabs: {
+ language_code: '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。'
+ }
+ },
+ updates: {
+ non_interactive_local_changes:
+ 'Hermes 從應用程式內更新自身時,保留本機原始碼變更(stash)或丟棄(discard)。終端機更新一律會詢問。'
+ }
+ }),
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 887ce38682..3cdc6a0187 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1,15 +1,41 @@
+import { defineFieldCopy } from '@/app/settings/field-copy'
+
import type { Translations } from './types'
export const zh: Translations = {
common: {
+ apply: '应用',
+ back: '返回',
save: '保存',
saving: '保存中…',
cancel: '取消',
+ change: '更改',
+ choose: '选择',
+ clear: '清除',
close: '关闭',
+ collapse: '收起',
confirm: '确认',
+ connect: '连接',
+ connecting: '连接中',
+ continue: '继续',
+ copied: '已复制',
+ copy: '复制',
delete: '删除',
+ docs: '文档',
+ done: '完成',
+ error: '错误',
+ free: '免费',
+ loading: '加载中…',
+ notSet: '未设置',
refresh: '刷新',
+ remove: '移除',
+ replace: '替换',
retry: '重试',
+ run: '运行',
+ send: '发送',
+ set: '设置',
+ skip: '跳过',
+ update: '更新',
on: '开',
off: '关'
},
@@ -67,7 +93,7 @@ export const zh: Translations = {
copyDetail: '复制详情',
copyDetailFailed: '无法复制通知详情',
backendOutOfDateTitle: '后端版本过旧',
- backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
+ backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
@@ -75,7 +101,7 @@ export const zh: Translations = {
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
- methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
+ methodNotAllowed: '桌面后端拒绝了该请求 (405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
@@ -162,99 +188,167 @@ export const zh: Translations = {
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
- fieldLabels: {
+ fieldLabels: defineFieldCopy({
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': '环境变量透传',
+ display: {
+ personality: '人格',
+ show_reasoning: '推理过程块'
+ },
+ agent: {
+ max_turns: '最大智能体步数',
+ image_input_mode: '图片附件',
+ api_max_retries: 'API 重试次数',
+ service_tier: '服务等级',
+ tool_use_enforcement: '工具调用强制'
+ },
+ terminal: {
+ cwd: '工作目录',
+ backend: '执行后端',
+ timeout: '命令超时',
+ persistent_shell: '持久化 Shell',
+ 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 重载',
+ tool_output: {
+ max_bytes: '终端输出上限',
+ max_lines: '文件分页上限',
+ max_line_length: '行长度上限'
+ },
+ code_execution: {
+ mode: '代码执行模式'
+ },
+ approvals: {
+ mode: '审批模式',
+ timeout: '审批超时',
+ 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': '子智能体推理强度'
- },
- fieldDescriptions: {
+ security: {
+ redact_secrets: '隐去密钥',
+ allow_private_urls: '允许私有 URL'
+ },
+ browser: {
+ allow_private_urls: '浏览器私有 URL',
+ auto_local_for_private_urls: '私有 URL 使用本地浏览器'
+ },
+ checkpoints: {
+ enabled: '文件检查点',
+ max_snapshots: '检查点上限'
+ },
+ voice: {
+ record_key: '语音快捷键',
+ max_recording_seconds: '最长录音时长',
+ auto_tts: '朗读回复'
+ },
+ stt: {
+ enabled: '语音转文字',
+ provider: '语音转文字提供方',
+ local: {
+ model: '本地转写模型',
+ language: '转写语言'
+ },
+ elevenlabs: {
+ model_id: 'ElevenLabs STT 模型',
+ language_code: 'ElevenLabs 语言',
+ tag_audio_events: '标记音频事件',
+ diarize: '说话人区分'
+ }
+ },
+ tts: {
+ provider: '文字转语音提供方',
+ edge: {
+ voice: 'Edge 语音'
+ },
+ openai: {
+ model: 'OpenAI TTS 模型',
+ voice: 'OpenAI 语音'
+ },
+ elevenlabs: {
+ voice_id: 'ElevenLabs 语音',
+ model_id: 'ElevenLabs 模型'
+ }
+ },
+ memory: {
+ memory_enabled: '持久记忆',
+ user_profile_enabled: '用户画像',
+ memory_char_limit: '记忆预算',
+ user_char_limit: '画像预算',
+ provider: '记忆提供方'
+ },
+ context: {
+ engine: '上下文引擎'
+ },
+ compression: {
+ enabled: '自动压缩',
+ threshold: '压缩阈值',
+ target_ratio: '压缩目标',
+ protect_last_n: '保护最近消息'
+ },
+ delegation: {
+ model: '子智能体模型',
+ provider: '子智能体提供方',
+ max_iterations: '子智能体轮次上限',
+ max_concurrent_children: '并行子智能体',
+ child_timeout_seconds: '子智能体超时',
+ reasoning_effort: '子智能体推理强度'
+ }
+ }),
+ fieldDescriptions: defineFieldCopy({
model: '用于新对话,除非你在输入框中选择其他模型。',
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
- 'display.personality': '新会话的默认助手风格。',
+ display: {
+ personality: '新会话的默认助手风格。',
+ show_reasoning: '当后端提供推理内容时予以显示。'
+ },
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
- 'display.show_reasoning': '当后端提供推理内容时予以显示。',
- 'agent.image_input_mode': '控制图片附件如何发送给模型。',
- 'terminal.cwd': '工具与终端操作的默认项目目录。',
- 'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
- 'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
- 'terminal.env_passthrough': '传入工具执行的环境变量。',
+ agent: {
+ image_input_mode: '控制图片附件如何发送给模型。',
+ max_turns: 'Hermes 停止一次运行前工具调用轮次的上限。'
+ },
+ terminal: {
+ cwd: '工具与终端操作的默认项目目录。',
+ persistent_shell: '当后端支持时,在命令之间保留 Shell 状态。',
+ env_passthrough: '传入工具执行的环境变量。'
+ },
+ code_execution: {
+ mode: '代码执行被限定到当前项目的严格程度。'
+ },
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 停止一次运行前工具调用轮次的上限。'
- },
+ approvals: {
+ mode: 'Hermes 如何处理需要显式审批的命令。',
+ timeout: '审批提示在超时前等待的时长。'
+ },
+ security: {
+ redact_secrets: '尽可能从模型可见内容中隐藏检测到的密钥。'
+ },
+ checkpoints: {
+ enabled: '在文件编辑前创建可回滚的快照。'
+ },
+ memory: {
+ memory_enabled: '保存有助于未来会话的持久记忆。',
+ user_profile_enabled: '维护一份精简的用户偏好画像。'
+ },
+ context: {
+ engine: '在接近上下文上限时管理长对话的策略。'
+ },
+ compression: {
+ enabled: '当对话变大时对较早的上下文进行摘要。'
+ },
+ voice: {
+ auto_tts: '自动朗读助手回复。'
+ },
+ stt: {
+ enabled: '启用本地或提供方支持的语音转写。',
+ elevenlabs: {
+ language_code: '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。'
+ }
+ }
+ }),
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
@@ -269,17 +363,232 @@ export const zh: Translations = {
cantUpdate: '此版本无法在应用内自我更新。',
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
- updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
+ updateReady: count => `已准备好新更新 (包含 ${count} 项更改)。`,
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
- automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
+ automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
hoursAgo: count => `${count} 小时前`,
daysAgo: count => `${count} 天前`
+ },
+ config: {
+ none: '无',
+ noneParen: '(无)',
+ notSet: '未设置',
+ commaSeparated: '逗号分隔的值',
+ loading: '正在加载 Hermes 配置...',
+ emptyTitle: '无可配置项',
+ emptyDesc: '此分区没有可调整的设置。',
+ failedLoad: '设置加载失败',
+ autosaveFailed: '自动保存失败',
+ imported: '配置已导入',
+ invalidJson: '配置 JSON 无效'
+ },
+ credentials: {
+ pasteKey: '粘贴密钥',
+ pasteLabelKey: label => `粘贴 ${label} 密钥`,
+ optional: '可选',
+ enterValueFirst: '请先输入一个值。',
+ couldNotSave: '无法保存凭据。',
+ remove: '移除',
+ or: '或',
+ escToCancel: '按 esc 取消',
+ getKey: '获取密钥',
+ saving: '保存中'
+ },
+ envActions: {
+ actionsFor: label => `${label} 的操作`,
+ credentialActions: '凭据操作',
+ docs: '文档',
+ hideValue: '隐藏值',
+ revealValue: '显示值',
+ replace: '替换',
+ set: '设置',
+ clear: '清除'
+ },
+ gateway: {
+ loading: '正在加载网关设置...',
+ unavailableTitle: '网关设置不可用',
+ unavailableDesc: '桌面 IPC 桥未暴露网关设置。',
+ title: '网关连接',
+ envOverride: '环境变量覆盖',
+ intro:
+ 'Hermes Desktop 默认会启动自己的本地网关。当你希望此应用控制另一台机器上或可信代理后的现有 Hermes 后端时,可以使用远程网关。下面可按 profile 指定各自的远程主机。',
+ appliesTo: '应用于',
+ allProfiles: '所有 profile',
+ defaultConnection: '默认连接会用于所有没有自定义覆盖的 profile。',
+ profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`,
+ envOverrideTitle: '环境变量正在控制此桌面会话。',
+ envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。',
+ localTitle: '本地网关',
+ localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。',
+ remoteTitle: '远程网关',
+ remoteDesc:
+ '将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
+ remoteUrlTitle: '远程 URL',
+ remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。',
+ probing: '正在检查此网关的认证方式…',
+ probeError: '暂时无法访问此网关。请检查 URL;网关响应后会显示认证方式。',
+ signedIn: '已登录',
+ signIn: '登录',
+ signOut: '退出登录',
+ signInWith: provider => `使用 ${provider} 登录`,
+ authTitle: '认证',
+ authSignedInPassword: '此网关使用用户名和密码。你已登录,会话会自动刷新。',
+ authSignedInOauth: '此网关使用 OAuth。你已登录,会话会自动刷新。',
+ authNeedsPassword: '此网关使用用户名和密码。请登录以授权此桌面应用。',
+ authNeedsOauth: provider => `此网关使用 OAuth。请使用 ${provider} 登录以授权此桌面应用。`,
+ tokenTitle: '会话 token',
+ tokenDesc: '用于 REST 和 WebSocket 访问的 dashboard 会话 token。留空则保留已保存的 token。',
+ existingToken: value => `现有 token ${value}`,
+ savedToken: '已保存',
+ pasteSessionToken: '粘贴会话 token',
+ testRemote: '测试远程',
+ saveForRestart: '保存到下次重启',
+ saveAndReconnect: '保存并重连',
+ diagnostics: '诊断',
+ diagnosticsDesc: '在文件管理器中显示 desktop.log,网关启动失败时很有用。',
+ openLogs: '打开日志',
+ incompleteTitle: '远程网关配置不完整',
+ incompleteSignIn: '切换到远程前,请输入远程 URL 并完成登录。',
+ incompleteToken: '切换到远程前,请输入远程 URL 和会话 token。',
+ incompleteSignInTest: '测试前,请输入远程 URL 并完成登录。',
+ incompleteTokenTest: '测试前,请输入远程 URL 和会话 token。',
+ enterUrlFirst: '请先输入远程 URL。',
+ restartingTitle: '网关连接正在重启',
+ savedTitle: '网关设置已保存',
+ restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接。',
+ savedMessage: '已保存,下一次重启生效。',
+ connectedTo: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` · Hermes ${version}` : ''}`,
+ reachableTitle: '远程网关可访问',
+ signedOutTitle: '已退出登录',
+ signedOutMessage: '已清除远程网关会话。',
+ failedLoad: '网关设置加载失败',
+ signInFailed: '登录失败',
+ signOutFailed: '退出登录失败',
+ testFailed: '远程网关测试失败',
+ applyFailed: '无法应用网关设置',
+ saveFailed: '无法保存网关设置'
+ },
+ keys: {
+ loading: '正在加载 API 密钥和凭据...',
+ failedLoad: 'API 密钥加载失败',
+ empty: '此类别暂时没有配置项。'
+ },
+ mcp: {
+ loading: '正在加载 MCP 服务器...',
+ failedLoad: 'MCP 配置加载失败',
+ nameRequiredTitle: '需要名称',
+ nameRequiredMessage: '请为此 MCP 服务器提供配置键。',
+ objectRequired: '服务器配置必须是 JSON 对象',
+ invalidJson: 'MCP JSON 无效',
+ saveFailed: '保存失败',
+ removeFailed: '移除失败',
+ gatewayUnavailableTitle: '网关不可用',
+ gatewayUnavailableMessage: '重新加载 MCP 前请先重连网关。',
+ reloadedTitle: 'MCP 工具已重新加载',
+ reloadedMessage: '新的工具 schema 将应用到后续回合。',
+ reloadFailed: 'MCP 重新加载失败',
+ savedTitle: 'MCP 服务器已保存',
+ savedMessage: name => `${name} 会在 MCP 重新加载后生效。`,
+ newServer: '新服务器',
+ reload: '重新加载 MCP',
+ reloading: '重新加载中...',
+ emptyTitle: '没有 MCP 服务器',
+ emptyDesc: '添加 stdio 或 HTTP 服务器以暴露 MCP 工具。',
+ disabled: '已禁用',
+ editServer: '编辑服务器',
+ name: '名称',
+ serverJson: '服务器 JSON',
+ remove: '移除',
+ saveServer: '保存服务器'
+ },
+ model: {
+ loading: '正在加载模型配置...',
+ appliesDesc: '应用于新会话。可在输入框的模型选择器中临时切换当前对话。',
+ provider: '提供方',
+ model: '模型',
+ applying: '应用中...',
+ auxiliaryTitle: '辅助模型',
+ resetAllToMain: '全部重置为主模型',
+ auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
+ setToMain: '设为主模型',
+ change: '更改',
+ autoUseMain: '自动 · 使用主模型',
+ providerDefault: '(提供方默认)',
+ tasks: {
+ vision: { label: '视觉', hint: '图片分析' },
+ web_extract: { label: '网页提取', hint: '页面总结' },
+ compression: { label: '压缩', hint: '上下文压缩' },
+ skills_hub: { label: '技能中心', hint: '技能搜索' },
+ approval: { label: '审批', hint: '智能自动批准' },
+ mcp: { label: 'MCP', hint: 'MCP 工具路由' },
+ title_generation: { label: '标题生成', hint: '会话标题' },
+ curator: { label: '维护器', hint: '技能使用审查' }
+ }
+ },
+ providers: {
+ connectAccount: '连接账号',
+ haveApiKey: '改用 API 密钥?',
+ intro: '使用订阅登录,无需复制 API 密钥。Hermes 会在应用中为你完成浏览器登录。',
+ connected: '已连接',
+ collapse: '收起',
+ connectAnother: '连接其他提供方',
+ otherProviders: '其他提供方',
+ noProviderKeys: '没有可用的提供方 API 密钥。',
+ loading: '正在加载提供方...'
+ },
+ sessions: {
+ loading: '正在加载已归档会话…',
+ archivedTitle: '已归档会话',
+ archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
+ emptyArchivedTitle: '暂无归档',
+ emptyArchivedDesc: '归档一个对话后会显示在这里。',
+ unarchive: '取消归档',
+ deletePermanently: '永久删除',
+ messages: count => `${count} 条消息`,
+ restored: '已恢复',
+ deleteConfirm: title => `永久删除“${title}”?此操作无法撤销。`,
+ defaultDirTitle: '默认项目目录',
+ defaultDirDesc: '新会话默认从此文件夹开始,除非你选择其他目录。留空则使用你的 home 目录。',
+ defaultDirUpdated: '默认项目目录已更新',
+ defaultsTo: label => `默认使用 ${label}。`,
+ change: '更改',
+ choose: '选择',
+ clear: '清除',
+ notSet: '未设置',
+ failedLoad: '无法加载已归档会话',
+ unarchiveFailed: '取消归档失败',
+ deleteFailed: '删除失败',
+ updateDirFailed: '无法更新默认目录',
+ clearDirFailed: '无法清除默认目录'
+ },
+ toolsets: {
+ loadingConfig: '正在加载配置',
+ savedTitle: '凭据已保存',
+ savedMessage: key => `${key} 已更新。`,
+ removedTitle: '凭据已移除',
+ removedMessage: key => `${key} 已移除。`,
+ failedSave: key => `保存 ${key} 失败`,
+ failedRemove: key => `移除 ${key} 失败`,
+ failedReveal: key => `显示 ${key} 失败`,
+ removeConfirm: key => `从 .env 中移除 ${key}?`,
+ set: '已设置',
+ notSet: '未设置',
+ selectedTitle: '已选择提供方',
+ selectedMessage: provider => `${provider} 现在处于活动状态。`,
+ failedSelect: provider => `选择 ${provider} 失败`,
+ failedLoad: '工具配置加载失败',
+ noProviderOptions: '此工具集没有提供方选项;启用后即可使用当前配置。',
+ noProviders: '此工具集当前没有可用提供方。',
+ ready: '就绪',
+ nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。',
+ noApiKeyRequired: '不需要 API 密钥。',
+ postSetup: step => `此提供方需要额外设置步骤 (${step})。暂时请在 CLI 中运行 hermes tools。`
}
},
@@ -317,7 +626,7 @@ export const zh: Translations = {
title: '派生树',
subtitle: '当前回合的子代理实时活动。',
emptyTitle: '暂无活跃子代理',
- emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
+ emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
running: '运行中',
failed: '失败',
done: '完成',
@@ -345,7 +654,18 @@ export const zh: Translations = {
commandCenter: {
close: '关闭命令中心',
+ paletteTitle: '命令面板',
+ back: '返回',
searchPlaceholder: '搜索会话、视图与操作',
+ goTo: '前往',
+ commandCenter: '命令中心',
+ appearance: '外观',
+ settings: '设置',
+ changeTheme: '更改主题...',
+ changeColorMode: '更改颜色模式...',
+ settingsFields: '设置字段',
+ mcpServers: 'MCP 服务器',
+ archivedChats: '已归档对话',
sections: { sessions: '会话', system: '系统', usage: '用量' },
sectionDescriptions: {
sessions: '搜索与管理会话',
@@ -382,7 +702,7 @@ export const zh: Translations = {
actionRunning: '运行中',
actionDone: '完成',
actionFailed: '失败',
- actionStartedWaiting: '操作已启动,等待状态…',
+ actionStartedWaiting: '操作已启动,等待状态…',
loadingStatus: '正在加载状态…',
recentLogs: '最近日志',
noLogs: '尚未加载日志。',
@@ -432,7 +752,7 @@ export const zh: Translations = {
required: '必填',
recommended: '推荐',
advanced: count => `高级 (${count})`,
- noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
+ noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
enabled: '已启用',
disabled: '已禁用',
unsavedChanges: '有未保存的更改',
@@ -455,54 +775,82 @@ export const zh: Translations = {
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
fieldCopy: {
- TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
+ TELEGRAM_BOT_TOKEN: {
+ label: 'Bot 令牌',
+ help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。',
+ placeholder: '粘贴 Telegram bot 令牌'
+ },
TELEGRAM_ALLOWED_USERS: {
label: '允许的 Telegram 用户 ID',
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
},
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
- DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
+ DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
- SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
- SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
+ DISCORD_ALLOW_ALL_USERS: {
+ label: '允许所有 Discord 用户',
+ help: '仅用于开发。为 true 时,任何人都可以私信 bot,不需要允许列表。'
+ },
+ DISCORD_HOME_CHANNEL: { label: '主页频道 ID', help: 'bot 主动发送消息的频道(cron 输出、提醒等)。' },
+ DISCORD_HOME_CHANNEL_NAME: { label: '主页频道名称', help: '日志和状态输出中显示的主页频道名称。' },
+ BLUEBUBBLES_ALLOW_ALL_USERS: { label: '允许所有 iMessage 用户', help: '为 true 时跳过 BlueBubbles 允许列表。' },
+ MATTERMOST_ALLOW_ALL_USERS: { label: '允许所有 Mattermost 用户' },
+ MATTERMOST_HOME_CHANNEL: { label: '主页频道' },
+ QQ_ALLOW_ALL_USERS: { label: '允许所有 QQ 用户' },
+ QQBOT_HOME_CHANNEL: { label: 'QQ 主页频道', help: 'cron 投递的默认频道或群组。' },
+ QQBOT_HOME_CHANNEL_NAME: { label: 'QQ 主页频道名称' },
+ SLACK_BOT_TOKEN: {
+ label: 'Slack bot 令牌',
+ help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。',
+ placeholder: '粘贴 Slack bot 令牌'
+ },
+ SLACK_APP_TOKEN: {
+ label: 'Slack app 令牌',
+ help: 'Socket Mode 需要 app 级令牌。',
+ placeholder: '粘贴 Slack app 令牌'
+ },
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
- MATTERMOST_URL: { label: '服务器 URL' },
+ MATTERMOST_URL: { label: '服务器 URL', placeholder: 'https://mattermost.example.com' },
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
- MATRIX_HOMESERVER: { label: 'Homeserver URL' },
+ MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' },
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
- MATRIX_USER_ID: { label: 'Bot 用户 ID' },
+ MATRIX_USER_ID: { label: 'Bot 用户 ID', placeholder: '@hermes:example.org' },
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
- SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
+ SIGNAL_HTTP_URL: {
+ label: 'Signal 桥接 URL',
+ placeholder: 'http://127.0.0.1:8080',
+ help: '运行中的 signal-cli REST 桥接的 URL。'
+ },
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
- WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
+ WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
WHATSAPP_MODE: { label: '桥接模式' },
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
},
platformIntro: {
telegram:
- '在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
- discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
- slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
- mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
- matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
- signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
- whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
- bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
+ '在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
+ discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
+ slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
+ mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
+ matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
+ signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
+ whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
+ bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
- sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
- dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
- feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
- wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
- wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
- weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
- qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
+ sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
+ dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key) 和 Client Secret。',
+ feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
+ wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信 (应用) 选项。',
+ 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。',
- webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
+ '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
+ webhook: '运行一个 HTTP 服务器,供其他工具 (GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},
@@ -513,6 +861,15 @@ export const zh: Translations = {
count: count => `${count} 个配置档案`,
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
+ allProfiles: '全部配置档案',
+ showAllProfiles: '显示全部配置档案',
+ switchToProfile: name => `切换到 ${name}`,
+ manageProfiles: '管理配置档案...',
+ actionsFor: name => `${name} 的操作`,
+ color: '颜色...',
+ colorFor: name => `${name} 的颜色`,
+ setColor: color => `设置颜色 ${color}`,
+ autoColor: '自动',
noProfiles: '暂无配置档案。',
selectPrompt: '选择一个配置档案以查看其详情。',
refresh: '刷新配置档案',
@@ -528,6 +885,10 @@ export const zh: Translations = {
skillsLabel: '技能',
notSet: '未设置',
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
+ soulOptional: '可选',
+ soulPlaceholder: mode => `此配置档案的系统提示词 / 人格说明。\n留空则保留${mode}默认值。`,
+ soulPlaceholderCloned: '克隆的',
+ soulPlaceholderEmpty: '空的',
unsavedChanges: '有未保存的更改',
loadingSoul: '正在加载 SOUL.md…',
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
@@ -538,7 +899,7 @@ export const zh: Translations = {
deleteDescMid: ' 并移除其 ',
deleteDescSuffix: ' 目录。此操作无法撤销。',
deleting: '删除中…',
- createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
+ createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
@@ -625,12 +986,12 @@ export const zh: Translations = {
active: (enabled, total) => `${enabled}/${total} 个启用`,
newCron: '新建定时任务',
createFirst: '创建第一个定时任务',
- emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
+ emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
emptyDescSearch: '尝试更宽泛的搜索词。',
emptyTitleNew: '暂无排程任务',
emptyTitleSearch: '无匹配项',
- last: '上次:',
- next: '下次:',
+ last: '上次:',
+ next: '下次:',
actionsFor: title => `${title} 的操作`,
actionsTitle: '定时任务操作',
resume: '恢复定时任务',
@@ -661,12 +1022,12 @@ export const zh: Translations = {
nameLabel: '名称',
namePlaceholder: '晨间简报',
promptLabel: '提示词',
- promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
+ promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
frequencyLabel: '频率',
deliverLabel: '投递至',
customScheduleLabel: '自定义排程',
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
- customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
+ customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
optional: '可选',
promptScheduleRequired: '提示词和排程为必填项。',
saveChanges: '保存更改',
@@ -683,7 +1044,7 @@ export const zh: Translations = {
tabFiles: '文件',
tabLinks: '链接',
noArtifactsTitle: '未找到产物',
- noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
+ noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
failedLoad: '产物加载失败',
openFailed: '打开失败',
itemsImage: '张图片',
@@ -727,7 +1088,7 @@ export const zh: Translations = {
groupTitleGrouped: '取消分组',
groupTitleUngrouped: '按工作区分组',
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
- shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
+ shiftClickHint: 'Shift+ 单击对话以置顶 · 拖动以重新排序',
noWorkspace: '无工作区',
newSessionIn: label => `在 ${label} 中新建会话`,
reorderWorkspace: label => `重新排序工作区 ${label}`,
@@ -762,6 +1123,7 @@ export const zh: Translations = {
composer: {
message: '消息',
+ wakingProfile: profile => `正在唤醒 ${profile}…`,
placeholderStarting: '正在启动 Hermes…',
placeholderReconnecting: '正在重新连接 Hermes…',
placeholderFollowUp: '发送后续消息',
@@ -816,7 +1178,7 @@ export const zh: Translations = {
hotkeyDescs: {
'@': '引用文件、文件夹、URL、git',
'/': '斜杠命令面板',
- '?': '此快速帮助(删除以关闭)',
+ '?': '此快速帮助 (删除以关闭)',
Enter: '发送 · Shift+Enter 换行',
'Cmd/Ctrl+K': '发送下一条排队的回合',
'Cmd/Ctrl+L': '重绘',
@@ -826,13 +1188,14 @@ export const zh: Translations = {
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
urlPlaceholder: 'https://example.com/post',
- urlHintPre: '请包含完整 URL,例如 ',
+ urlHintPre: '请包含完整 URL,例如 ',
attach: '附加',
queued: count => `${count} 条排队`,
attachmentOnly: '仅附件回合',
emptyTurn: '空回合',
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
+ editingQueuedInComposer: '正在输入框中编辑排队回合',
editQueued: '编辑排队回合',
sendQueuedNext: '下一个发送排队回合',
sendQueuedNow: '立即发送排队回合',
@@ -856,10 +1219,12 @@ export const zh: Translations = {
pasteImage: '粘贴图片',
url: 'URL…',
promptSnippets: '提示词片段…',
- tipPre: '提示:输入 ',
+ tipPre: '提示:输入 ',
tipPost: ' 以内联引用文件。',
snippetsTitle: '提示词片段',
snippetsDesc: '选择一个起始提示词放入输入框。',
+ dropFiles: '拖放文件以附加',
+ dropSession: '拖放以链接此对话',
snippets: {
codeReview: {
label: '代码审查',
@@ -868,14 +1233,536 @@ export const zh: Translations = {
},
implementationPlan: {
label: '实现计划',
- description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
+ description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
text: '请在修改代码前制定一个简洁的实现计划。'
},
explainThis: {
label: '解释这段',
- description: '讲解所选代码的工作方式,并链接到关键文件。',
- text: '请解释这是如何工作的,并指给我关键文件。'
+ description: '讲解所选代码的工作方式,并链接到关键文件。',
+ text: '请解释这是如何工作的,并指给我关键文件。'
}
}
+ },
+
+ updates: {
+ stages: {
+ idle: '准备中…',
+ prepare: '准备中…',
+ fetch: '下载中…',
+ pull: '马上完成…',
+ pydeps: '收尾中…',
+ restart: '正在重启 Hermes…',
+ manual: '从终端更新',
+ error: '更新已暂停'
+ },
+ checking: '正在检查更新…',
+ checkFailedTitle: '无法检查更新',
+ tryAgain: '重试',
+ notAvailableTitle: '更新不可用',
+ unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
+ connectionRetry: '请检查网络连接后重试。',
+ latestBody: '你正在运行最新版本。',
+ allSetTitle: '已是最新',
+ availableTitle: '有可用更新',
+ availableBody: '新版 Hermes 已可安装。',
+ updateNow: '立即更新',
+ maybeLater: '稍后再说',
+ moreChanges: count => `另有 ${count} 项更改。`,
+ manualTitle: '从终端更新',
+ manualBody: '你是从命令行安装的 Hermes,因此更新也需要在那里运行。请将此命令粘贴到终端:',
+ manualPickedUp: '下次启动 Hermes 时会使用新版本。',
+ copy: '复制',
+ copied: '已复制',
+ done: '完成',
+ applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。',
+ applyingClose: 'Hermes 将关闭以应用更新。',
+ errorTitle: '更新未完成',
+ errorBody: '没有数据丢失。你可以现在重试。',
+ notNow: '暂不'
+ },
+
+ install: {
+ stageStates: {
+ pending: '等待中',
+ running: '安装中',
+ succeeded: '完成',
+ skipped: '已跳过',
+ failed: '失败'
+ },
+ oneTimeTitle: 'Hermes 需要一次性安装',
+ unsupportedDesc: platform =>
+ `${platform} 暂不支持自动首次启动安装。请打开终端并运行下面的命令,然后重新启动此应用。之后启动会跳过此步骤。`,
+ installCommand: '安装命令',
+ copyCommand: '复制命令',
+ viewDocs: '查看安装文档',
+ installTo: '将安装到',
+ retryAfterRun: '我已运行 -- 重试',
+ failedTitle: '安装失败',
+ settingUpTitle: '正在设置 Hermes Agent',
+ finishingTitle: '正在收尾',
+ failedDesc:
+ '某个安装步骤失败。在 Windows 上,如果另一个 Hermes CLI 或桌面实例正在运行,可能会出现这种情况。请停止正在运行的 Hermes 实例后重试。可查看下面的详情或 desktop 日志中的完整记录。',
+ activeDesc: '这是一次性设置。Hermes 安装器正在下载依赖并配置你的机器。之后启动会跳过此步骤。',
+ progress: (completed, total) => `${completed}/${total} 个步骤已完成`,
+ currentStage: stage => ` -- 当前: ${stage}`,
+ fetchingManifest: '正在获取安装器 manifest...',
+ error: '错误',
+ hideOutput: '隐藏安装器输出',
+ showOutput: '显示安装器输出',
+ lines: count => `${count} 行`,
+ noOutput: '暂无输出。',
+ cancelling: '取消中...',
+ cancelInstall: '取消安装',
+ transcriptSaved: '完整记录已保存到',
+ copiedOutput: '已复制!',
+ copyOutput: '复制输出',
+ reloadRetry: '重新加载并重试'
+ },
+
+ onboarding: {
+ headerTitle: '开始设置 Hermes Agent',
+ headerDesc: '连接模型提供方即可开始对话。大多数选项只需一次点击。',
+ preparingInstall: 'Hermes 正在完成安装。首次运行通常不到一分钟。',
+ starting: '正在启动 Hermes…',
+ lookingUpProviders: '正在查找提供方...',
+ collapse: '收起',
+ otherProviders: '其他提供方',
+ haveApiKey: '我有 API 密钥',
+ chooseLater: '稍后再选择提供方',
+ recommended: '推荐',
+ connected: '已连接',
+ featuredPitch: '一个订阅,300+ 前沿模型 — 运行 Hermes 的推荐方式',
+ openRouterPitch: '一个密钥,数百个模型 — 稳妥的默认选择',
+ apiKeyOptions: {
+ openrouter: { short: '一个密钥,多个模型', description: '用一个密钥访问数百个模型。适合新安装的默认选择。' },
+ openai: { short: 'GPT 级模型', description: '直接访问 OpenAI 模型。' },
+ gemini: { short: 'Gemini 模型', description: '直接访问 Google Gemini 模型。' },
+ xai: { short: 'Grok 模型', description: '直接访问 xAI Grok 模型。' },
+ local: {
+ short: '自托管',
+ description: '将 Hermes 指向本地或自托管的 OpenAI 兼容端点(vLLM、llama.cpp、Ollama 等)。'
+ }
+ },
+ backToSignIn: '返回登录',
+ getKey: '获取密钥',
+ replaceCurrent: '替换当前值',
+ pasteApiKey: '粘贴 API 密钥',
+ couldNotSave: '无法保存凭据。',
+ connecting: '连接中',
+ update: '更新',
+ flowSubtitles: {
+ pkce: '打开浏览器登录,然后回到这里继续',
+ device_code: '在浏览器中打开验证页面 — Hermes 会自动连接',
+ loopback: '打开浏览器登录 — Hermes 会自动连接',
+ external: '先在终端登录一次,然后回来继续对话'
+ },
+ startingSignIn: provider => `正在为 ${provider} 启动登录...`,
+ verifyingCode: provider => `正在通过 ${provider} 验证你的代码...`,
+ connectedProvider: provider => `${provider} 已连接。`,
+ connectedPicking: provider => `${provider} 已连接。正在选择默认模型...`,
+ signInFailed: '登录失败,请重试。',
+ pickDifferentProvider: '选择其他提供方',
+ signInWith: provider => `使用 ${provider} 登录`,
+ openedBrowser: provider => `已在浏览器中打开 ${provider}。`,
+ authorizeThere: '请在那里授权 Hermes。',
+ copyAuthCode: '复制授权码并粘贴到下面。',
+ pasteAuthCode: '粘贴授权码',
+ reopenAuthPage: '重新打开授权页面',
+ autoBrowser: provider => `已在浏览器中打开 ${provider}。请在那里授权 Hermes,连接会自动完成,无需复制或粘贴。`,
+ reopenSignInPage: '重新打开登录页面',
+ waitingAuthorize: '等待你授权...',
+ externalPending: provider => `${provider} 通过自己的 CLI 登录。请在终端运行此命令,然后回来选择“我已登录”:`,
+ signedIn: '我已登录',
+ deviceCodeOpened: provider => `已在浏览器中打开 ${provider}。请在那里输入此代码:`,
+ reopenVerification: '重新打开验证页面',
+ copy: '复制',
+ defaultModel: '默认模型',
+ freeTier: '免费层',
+ pro: 'Pro',
+ free: '免费',
+ price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
+ change: '更改',
+ startChatting: '开始对话',
+ docs: provider => `${provider} 文档`
+ },
+
+ modelPicker: {
+ title: '切换模型',
+ current: '当前:',
+ unknown: '(未知)',
+ search: '筛选提供方和模型...',
+ noModels: '未找到模型。',
+ persistGlobalSession: '全局保存(否则仅当前会话)',
+ persistGlobal: '全局保存',
+ addProvider: '添加提供方',
+ loadFailed: '无法加载模型',
+ noAuthenticatedProviders: '没有已认证的提供方。',
+ pro: 'Pro',
+ proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
+ free: '免费',
+ freeTier: '免费层',
+ priceTitle: '每百万 token 的输入/输出价格'
+ },
+
+ modelVisibility: {
+ title: '模型',
+ search: '搜索模型',
+ noAuthenticatedProviders: '没有已认证的提供方。',
+ addProvider: '添加提供方…'
+ },
+
+ shell: {
+ windowControls: '窗口控件',
+ paneControls: '面板控件',
+ appControls: '应用控件',
+ modelMenu: {
+ search: '搜索模型',
+ noModels: '未找到模型',
+ editModels: '编辑模型…',
+ fast: '快速',
+ medium: '中'
+ },
+ modelOptions: {
+ noOptions: '此模型没有可用选项',
+ options: '选项',
+ thinking: '思考',
+ fast: '快速',
+ effort: '推理强度',
+ minimal: '最小',
+ low: '低',
+ medium: '中',
+ high: '高',
+ max: '最高',
+ updateFailed: '模型选项更新失败',
+ fastFailed: '快速模式更新失败'
+ },
+ gatewayMenu: {
+ gateway: '网关',
+ connected: '已连接',
+ connecting: '连接中',
+ offline: '离线',
+ inferenceReady: '推理已就绪',
+ inferenceNotReady: '推理未就绪',
+ checkingInference: '正在检查推理',
+ disconnected: '已断开',
+ openSystem: '打开系统面板',
+ connection: label => `连接: ${label}`,
+ recentActivity: '最近活动',
+ viewAllLogs: '查看全部日志 →',
+ messagingPlatforms: '消息平台'
+ },
+ statusbar: {
+ unknown: '未知',
+ restart: '重启',
+ update: '更新',
+ updateInProgress: '正在更新',
+ commitsBehind: (count, branch) => `落后 ${branch} ${count} 个提交`,
+ desktopVersion: version => `Hermes Desktop v${version}`,
+ commit: sha => `提交 ${sha}`,
+ branch: branch => `分支 ${branch}`,
+ closeCommandCenter: '关闭命令中心',
+ openCommandCenter: '打开命令中心',
+ gateway: '网关',
+ gatewayReady: '就绪',
+ gatewayNeedsSetup: '需要设置',
+ gatewayChecking: '检查中',
+ gatewayConnecting: '连接中',
+ gatewayOffline: '离线',
+ gatewayTitle: 'Hermes 推理网关状态',
+ agents: '代理',
+ closeAgents: '关闭代理',
+ openAgents: '打开代理',
+ subagents: count => `${count} 个子代理`,
+ failed: count => `${count} 个失败`,
+ running: count => `${count} 个运行中`,
+ cron: '排程',
+ openCron: '打开排程任务',
+ turnRunning: '运行中',
+ currentTurnElapsed: '当前回合已用时间',
+ contextUsage: '上下文用量',
+ session: '会话',
+ runtimeSessionElapsed: '运行时会话已用时间',
+ yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。',
+ yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。',
+ modelNone: '无',
+ noModel: '无模型',
+ switchModel: '切换模型',
+ openModelPicker: '打开模型选择器',
+ modelTitle: (provider, model) => `模型 · ${provider}: ${model}`,
+ providerModelTitle: (provider, model) => `${provider} · ${model}`
+ }
+ },
+
+ rightSidebar: {
+ aria: '右侧边栏',
+ panelsAria: '右侧边栏面板',
+ files: '文件系统',
+ terminal: '终端',
+ noFolderSelected: '未选择文件夹',
+ changeCwdTitle: '更改工作目录',
+ folderTip: cwd => `${cwd} — 点击更改文件夹`,
+ openFolder: '打开文件夹',
+ refreshTree: '刷新文件树',
+ collapseAll: '折叠所有文件夹',
+ previewUnavailable: '预览不可用',
+ couldNotPreview: path => `无法预览 ${path}`,
+ noProjectTitle: '没有项目',
+ noProjectBody: '从状态栏设置工作目录后即可浏览文件。',
+ unreadableTitle: '无法读取',
+ unreadableBody: error => `无法读取此文件夹(${error})。`,
+ emptyTitle: '空文件夹',
+ emptyBody: '此文件夹为空。',
+ treeErrorTitle: '文件树错误',
+ treeErrorBody: '文件树渲染此文件夹时出错。',
+ tryAgain: '重试',
+ loadingTree: '正在加载文件树',
+ loadingFiles: '正在加载文件',
+ terminalFocus: '聚焦终端视图',
+ terminalSplit: '返回分栏视图',
+ addToChat: '添加到对话'
+ },
+
+ preview: {
+ tab: '预览',
+ closeTab: label => `关闭 ${label}`,
+ closePane: '关闭预览面板',
+ loading: '正在加载预览',
+ unavailable: '预览不可用',
+ opening: '正在打开...',
+ hide: '隐藏',
+ openPreview: '打开预览',
+ sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
+ source: '源码',
+ renderedPreview: '预览',
+ unknownSize: '大小未知',
+ binaryTitle: '这看起来像二进制文件',
+ binaryBody: label => `预览 ${label} 可能会显示不可读文本。`,
+ largeTitle: '此文件较大',
+ largeBody: (label, size) => `${label} 大小为 ${size}。Hermes 只会显示前 512 KB。`,
+ previewAnyway: '仍然预览',
+ truncated: '显示前 512 KB。',
+ noInlineTitle: '没有内联预览',
+ noInlineBody: mimeType => `${mimeType || '此文件类型'} 仍可作为上下文附件。`,
+ console: {
+ deselect: '取消选择条目',
+ select: '选择条目',
+ copyFailed: '无法复制控制台输出',
+ copyEntry: '复制此条目',
+ sendEntry: '将此条目发送到对话',
+ messages: count => `${count} 条控制台消息`,
+ resize: '调整预览控制台大小',
+ title: '预览控制台',
+ selected: count => `已选择 ${count} 条`,
+ sendToChat: '发送到对话',
+ copySelected: '复制所选到剪贴板',
+ copyAll: '全部复制到剪贴板',
+ copy: '复制',
+ clear: '清除',
+ empty: '暂无控制台消息。',
+ promptHeader: '预览控制台:',
+ sentTitle: '已发送到对话',
+ sentMessage: count => `已将 ${count} 条日志添加到输入框`
+ },
+ web: {
+ appFailedToBoot: '预览应用启动失败',
+ serverNotFound: '未找到服务器',
+ failedToLoad: '预览加载失败',
+ tryAgain: '重试',
+ restarting: 'Hermes 正在重启...',
+ askRestart: '让 Hermes 重启服务器',
+ lookingRestart: taskId => `Hermes 正在查找要重启的预览服务器 (${taskId})`,
+ restartingTitle: '正在重启预览服务器',
+ restartingMessage: 'Hermes 正在后台工作。可在预览控制台查看进度。',
+ startRestartFailed: message => `无法启动服务器重启: ${message}`,
+ restartFailed: '服务器重启失败',
+ hideConsole: '隐藏预览控制台',
+ showConsole: '显示预览控制台',
+ hideDevTools: '隐藏预览 DevTools',
+ openDevTools: '打开预览 DevTools',
+ finishedRestarting: message => `Hermes 已完成预览服务器重启${message ? `: ${message}` : ''}`,
+ failedRestarting: message => `服务器重启失败: ${message}`,
+ unknownError: '未知错误',
+ restartedTitle: '预览服务器已重启',
+ reloadingNow: '正在重新加载预览。',
+ restartFailedTitle: '预览重启失败',
+ restartFailedMessage: 'Hermes 无法重启服务器。',
+ stillWorking: 'Hermes 仍在工作,但还没有收到重启结果。服务器命令可能正在前台运行。',
+ workspaceReloading: '工作区已变更,正在重新加载预览',
+ fileChanged: url => `文件已变更,正在重新加载预览: ${url}`,
+ filesChanged: (count, url) => `${count} 个文件变更,正在重新加载预览: ${url}`,
+ watchFailed: message => `无法监听预览文件: ${message}`,
+ moduleMimeDescription:
+ '模块脚本使用了错误的 MIME 类型。这通常表示静态文件服务器正在服务 Vite/React 应用,而不是项目开发服务器。',
+ loadFailedConsole: (code, message) => `加载失败${code ? ` (${code})` : ''}: ${message}`,
+ unreachableDescription: '无法访问预览页面。',
+ openTarget: url => `打开 ${url}`,
+ fallbackTitle: '预览'
+ }
+ },
+
+ assistant: {
+ thread: {
+ loadingSession: '正在加载会话',
+ loadingResponse: 'Hermes 正在加载回复',
+ thinking: '思考中',
+ today: time => `今天,${time}`,
+ yesterday: time => `昨天,${time}`,
+ copy: '复制',
+ refresh: '刷新',
+ moreActions: '更多操作',
+ branchNewChat: '在新对话中分支',
+ readAloudFailed: '朗读失败',
+ preparingAudio: '正在准备音频...',
+ stopReading: '停止朗读',
+ readAloud: '朗读',
+ editMessage: '编辑消息',
+ stop: '停止',
+ editableCheckpoint: '可编辑检查点',
+ restorePrevious: '恢复上一个检查点',
+ restoreCheckpoint: '恢复检查点',
+ restoreNext: '恢复下一个检查点',
+ goForward: '前进',
+ sendEdited: '发送编辑后的消息'
+ },
+ approval: {
+ gatewayDisconnected: 'Hermes 网关未连接',
+ sendFailed: '无法发送审批响应',
+ run: '运行',
+ moreOptions: '更多审批选项',
+ allowSession: '允许本会话',
+ alwaysAllowMenu: '始终允许…',
+ reject: '拒绝',
+ alwaysTitle: '始终允许此命令?',
+ alwaysDescription: pattern =>
+ `这会将 “${pattern}” 模式加入永久允许列表(~/.hermes/config.yaml)。Hermes 对类似命令将不再询问,包括当前会话和未来会话。`,
+ alwaysAllow: '始终允许'
+ },
+ clarify: {
+ notReady: '澄清请求尚未就绪',
+ gatewayDisconnected: 'Hermes 网关未连接',
+ sendFailed: '无法发送澄清响应',
+ loadingQuestion: '正在加载问题…',
+ other: '其他(输入你的答案)',
+ placeholder: '输入你的答案…',
+ shortcut: '⌘/Ctrl + Enter 发送',
+ back: '返回',
+ skip: '跳过',
+ send: '发送'
+ },
+ tool: {
+ code: '代码',
+ copyCode: '复制代码',
+ renderingImage: '正在渲染图片',
+ copyOutput: '复制输出',
+ copyCommand: '复制命令',
+ copyContent: '复制内容',
+ copyUrl: '复制 URL',
+ copyResults: '复制结果',
+ copyQuery: '复制查询',
+ copyFile: '复制文件',
+ copyPath: '复制路径',
+ outputAlt: '工具输出',
+ rawResponse: '原始响应',
+ copyActivity: '复制活动',
+ recoveredOne: '在 1 个失败步骤后已恢复',
+ recoveredMany: count => `在 ${count} 个失败步骤后已恢复`,
+ failedOne: '1 个步骤失败',
+ failedMany: count => `${count} 个步骤失败`
+ }
+ },
+
+ prompts: {
+ gatewayDisconnected: 'Hermes 网关未连接',
+ sudoSendFailed: '无法发送 sudo 密码',
+ secretSendFailed: '无法发送密钥',
+ sudoTitle: '管理员密码',
+ sudoDesc: 'Hermes 需要你的 sudo 密码来运行特权命令。它只会发送给你的本地 agent。',
+ sudoPlaceholder: 'sudo 密码',
+ secretTitle: '需要密钥',
+ secretDesc: 'Hermes 需要一个凭据才能继续。',
+ secretPlaceholder: '密钥值'
+ },
+
+ desktop: {
+ audioReadFailed: '无法读取录制的音频',
+ sessionUnavailable: '会话不可用',
+ createSessionFailed: '无法创建新会话',
+ promptFailed: '提示词发送失败',
+ providerCredentialRequired: '发送第一条消息前请先添加提供方凭据。',
+ emptySlashCommand: '空 slash 命令',
+ desktopCommands: '桌面端命令',
+ skillCommandsAvailable: count => `${count} 个技能命令可用。`,
+ warningLine: message => `警告: ${message}`,
+ yoloArmed: '此对话已启用 YOLO',
+ yoloOff: 'YOLO 已关闭',
+ yoloSystem: active => `此会话 YOLO ${active ? '已开启' : '已关闭'}`,
+ yoloTitle: 'YOLO',
+ yoloToggleFailed: '无法切换 YOLO',
+ profileStatus: current => `Profile: ${current}。使用 /profile 或“新建会话”选择器在其他 profile 中开始对话。`,
+ unknownProfile: '未知 profile',
+ noProfileNamed: (target, available) => `没有名为“${target}”的 profile。可用: ${available}`,
+ newChatsProfile: name => `新对话将使用 profile ${name}。`,
+ setProfileFailed: '设置 profile 失败',
+ sttDisabled: '设置中已禁用语音转文字。',
+ stopFailed: '停止失败',
+ regenerateFailed: '重新生成失败',
+ editFailed: '编辑失败',
+ resumeFailed: '恢复失败',
+ nothingToBranch: '没有可分支的内容',
+ branchNeedsChat: '分支前请先开始或恢复一个对话。',
+ sessionBusy: '会话忙碌中',
+ branchStopCurrent: '分支此对话前请先停止当前回合。',
+ branchNoText: '此消息没有可用于分支的文本。',
+ branchTitle: '分支',
+ branchFailed: '分支失败',
+ deleteFailed: '删除失败',
+ archived: '已归档',
+ archiveFailed: '归档失败',
+ cwdChangeFailed: '工作目录更改失败',
+ cwdStagedTitle: '工作目录已暂存',
+ cwdStagedMessage: '重启桌面后端后,工作目录更改才会应用到当前活跃会话。',
+ modelSwitchFailed: '模型切换失败',
+ sessionExported: '会话已导出',
+ sessionExportFailed: '无法导出会话',
+ imageSaved: '图片已保存',
+ downloadStarted: '下载已开始',
+ restartToUseSaveImage: '重启 Hermes Desktop 后可使用保存图片。',
+ restartToSaveImages: '重启 Hermes Desktop 以保存图片',
+ imageDownloadFailed: '图片下载失败',
+ imagePreviewFailed: '图片预览失败',
+ imageAttach: '附加图片',
+ imageWriteFailed: '无法将图片写入磁盘。',
+ imageAttachFailed: '附加图片失败',
+ attachImages: '附加图片',
+ clipboard: '剪贴板',
+ noClipboardImage: '剪贴板中没有图片',
+ clipboardPasteFailed: '粘贴剪贴板失败',
+ dropFiles: '拖放文件'
+ },
+
+ errors: {
+ genericFailure: '发生错误',
+ boundaryTitle: '界面出错了',
+ boundaryDesc: '此视图遇到意外错误。你的对话和设置是安全的。',
+ reloadWindow: '重新加载窗口',
+ openLogs: '打开日志'
+ },
+
+ ui: {
+ search: {
+ clear: '清除搜索'
+ },
+ pagination: {
+ label: '分页',
+ previous: '上一页',
+ previousAria: '前往上一页',
+ next: '下一页',
+ nextAria: '前往下一页'
+ },
+ sidebar: {
+ title: '侧边栏',
+ description: '显示移动端侧边栏。',
+ toggle: '切换侧边栏'
+ }
}
}
diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts
index 677ed783d1..b32a705b7e 100644
--- a/apps/desktop/src/lib/session-export.ts
+++ b/apps/desktop/src/lib/session-export.ts
@@ -1,5 +1,6 @@
import type { SessionInfo } from '@/hermes'
import { getSessionMessages } from '@/hermes'
+import { translateNow } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
@@ -49,8 +50,8 @@ export async function exportSession(sessionId: string, params: Omit