From e6de6dd559b482b9ac90dc9417c77c1f3f54b89e Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sat, 6 Jun 2026 07:40:36 -0700
Subject: [PATCH 1/9] fix(dashboard): tighten skill detail dialog spacing
(#40419)
The skill detail dialog (Skills hub browser) had several awkward
spacing/placement issues:
- description and identifier crammed together with no breathing room
(-mt-1 pulled the description tight to the header)
- the identifier line touched the action-row border
- Install was stranded far right with a large empty void in the middle
of the action row
- the SKILL.md
opened with a leading blank line
Fixes:
- group description + identifier in a spaced flex-col block (mt-1, gap-1)
- give the action row mt-3 + py-2.5 so it separates from the meta block
- move the repo link into the right-side group with Install (ml-auto,
gap-3) so the row reads left=tabs / right=repo+install, no middle void
- mt-3 on the body for consistent vertical rhythm
- trim() the SKILL.md content so it starts at the first real line
---
web/src/pages/SkillsPage.tsx | 42 +++++++++++++++++++-----------------
1 file changed, 22 insertions(+), 20 deletions(-)
diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx
index 5131cc3e877..c21a3e481d0 100644
--- a/web/src/pages/SkillsPage.tsx
+++ b/web/src/pages/SkillsPage.tsx
@@ -1187,13 +1187,15 @@ function SkillDetailDialog({
- {result.description}
-
- {result.identifier}
-
+
+
{result.description}
+
+ {result.identifier}
+
+
{/* Action row */}
-
+
{scan ? "Re-scan" : "Security scan"}
- {result.repo && (
-
-
- {result.repo}
-
- )}
-
+
+ {result.repo && (
+
+
+ {result.repo}
+
+ )}
{installed ? (
}>
Installed
@@ -1246,14 +1248,14 @@ function SkillDetailDialog({
{/* Body */}
-
+
{tab === "readme" ? (
previewLoading ? (
) : preview ? (
-
+
{preview.tags.length > 0 && (
{preview.tags.map((tag) => (
@@ -1275,7 +1277,7 @@ function SkillDetailDialog({
)}
- {preview.skill_md || "(SKILL.md is empty)"}
+ {(preview.skill_md || "").trim() || "(SKILL.md is empty)"}
) : (
From 2bf0a6e76083f1e46a766ac75b75c42431e18528 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sat, 6 Jun 2026 07:45:36 -0700
Subject: [PATCH 2/9] feat(dashboard): full tool backend configuration in the
GUI (#40418)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replicate the `hermes tools` configurator in the dashboard Skills →
Toolsets view. Each toolset now opens a config drawer that covers the
full lifecycle the CLI offers: enable/disable, pick a provider/backend,
enter and save API keys, and run a provider's post-setup install hook
with a live log tail.
The toolset view was previously read+toggle only — the provider matrix
and key-status endpoints existed but the page never called them, and
there was no way to save a key or run a backend install (npm/pip/binary)
from the browser.
Backend:
- New CLI subcommand `hermes tools post-setup
` — non-interactive,
scriptable target that runs a provider's install hook (agent_browser,
camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse,
xai_grok). Validated against valid_post_setup_keys() so an arbitrary
key can't drive _run_post_setup.
- PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env
via save_env_value (same store the CLI writes), validated against the
toolset category's env-var allowlist; blank values skipped.
- POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs
`hermes tools post-setup `; frontend tails the log via the
existing /api/actions/tools-post-setup/status. Registered in
_ACTION_LOG_FILES.
Frontend:
- New ToolsetConfigDrawer component (provider radios, password key
inputs with saved-state, get-a-key links, Run-setup + live install
log). Toolset cards get a Configure button + the drawer also exposes
the enable toggle.
- api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider,
saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/
EnvResult types.
Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI
parity + allowlist reject + blank-skip, post-setup spawn validation,
auth gate); 232 web_server tests pass; web npm run build + eslint clean;
HTTP E2E exercises save-key (CLI reads it back) and spawn+poll
post-setup to exit 0.
---
hermes_cli/main.py | 24 +
hermes_cli/tools_config.py | 62 +++
hermes_cli/web_server.py | 101 ++++
.../test_dashboard_admin_endpoints.py | 129 +++++
web/src/components/ToolsetConfigDrawer.tsx | 448 ++++++++++++++++++
web/src/lib/api.ts | 73 +++
web/src/pages/SkillsPage.tsx | 29 ++
7 files changed, 866 insertions(+)
create mode 100644 web/src/components/ToolsetConfigDrawer.tsx
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 4316bc1f535..4c0c733449b 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -14740,12 +14740,36 @@ Examples:
help="Platform to apply to (default: cli)",
)
+ # hermes tools post-setup
+ tools_postsetup_p = tools_sub.add_parser(
+ "post-setup",
+ help="Run a provider's post-setup install hook (npm/pip/binary)",
+ description=(
+ "Run the install/bootstrap hook a tool backend declares — the\n"
+ "same step `hermes tools` runs after you pick a provider that\n"
+ "needs extra dependencies (browser Chromium, Camofox, cua-driver,\n"
+ "KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI). Stable,\n"
+ "non-interactive target the dashboard spawns to drive backend\n"
+ "setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n"
+ "piper, ddgs, spotify, langfuse, xai_grok."
+ ),
+ )
+ tools_postsetup_p.add_argument(
+ "post_setup_key",
+ metavar="KEY",
+ help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)",
+ )
+
def cmd_tools(args):
action = getattr(args, "tools_action", None)
if action in {"list", "disable", "enable"}:
from hermes_cli.tools_config import tools_disable_enable_command
tools_disable_enable_command(args)
+ elif action == "post-setup":
+ from hermes_cli.tools_config import run_post_setup_command
+
+ sys.exit(run_post_setup_command(args))
else:
_require_tty("tools")
from hermes_cli.tools_config import tools_command
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 50f1f9f8607..72fb824a560 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -1168,6 +1168,68 @@ def _run_post_setup(post_setup_key: str):
_print_info(" xAI will remain inactive until credentials are configured.")
+def valid_post_setup_keys() -> Set[str]:
+ """Return the set of post-setup keys declared by any visible provider.
+
+ Collected from ``TOOL_CATEGORIES`` plus the plugin-registered web /
+ image-gen / video-gen / browser providers (which can also carry a
+ ``post_setup``). This is the allowlist the ``hermes tools post-setup``
+ command and the dashboard post-setup endpoint validate against, so a
+ caller can't drive ``_run_post_setup`` with an arbitrary key.
+ """
+ keys: Set[str] = set()
+ for cat in TOOL_CATEGORIES.values():
+ for prov in cat.get("providers", []):
+ ps = prov.get("post_setup")
+ if ps:
+ keys.add(ps)
+ # Plugin-registered providers can declare their own post_setup hooks.
+ for builder in (
+ _plugin_web_search_providers,
+ _plugin_image_gen_providers,
+ _plugin_video_gen_providers,
+ _plugin_browser_providers,
+ ):
+ try:
+ for prov in builder():
+ ps = prov.get("post_setup")
+ if ps:
+ keys.add(ps)
+ except Exception: # pragma: no cover — defensive; plugins optional
+ continue
+ return keys
+
+
+def run_post_setup_command(args) -> int:
+ """``hermes tools post-setup `` — non-interactive post-setup runner.
+
+ Runs the install/bootstrap hook a provider declares (npm install for
+ browser/Camofox, pip install for kittentts/piper/ddgs, cua-driver fetch,
+ etc.). This is the stable, scriptable target the dashboard spawns so the
+ GUI can drive backend setup without re-implementing the install logic.
+ Returns a process exit code (0 ok, 2 unknown key).
+ """
+ key = getattr(args, "post_setup_key", None)
+ if not key:
+ _print_error("Usage: hermes tools post-setup ")
+ return 2
+ valid = valid_post_setup_keys()
+ if key not in valid:
+ _print_error(
+ f"Unknown post-setup key: {key!r}. "
+ f"Valid keys: {', '.join(sorted(valid)) or '(none)'}"
+ )
+ return 2
+ _print_info(f"Running post-setup hook: {key}")
+ try:
+ _run_post_setup(key)
+ except Exception as exc: # pragma: no cover — defensive
+ _print_error(f"Post-setup failed: {exc}")
+ return 1
+ _print_success(f"Post-setup '{key}' complete")
+ return 0
+
+
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
def _get_enabled_platforms() -> List[str]:
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 6a3ed07344b..3373a571bbd 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -1145,6 +1145,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
"prompt-size": "action-prompt-size.log",
"dump": "action-dump.log",
"config-migrate": "action-config-migrate.log",
+ "tools-post-setup": "action-tools-post-setup.log",
}
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
@@ -7684,6 +7685,106 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
return {"ok": True, "name": name, "provider": body.provider}
+class ToolsetEnvUpdate(BaseModel):
+ env: Dict[str, str]
+
+
+@app.put("/api/tools/toolsets/{name}/env")
+async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
+ """Persist API keys for a toolset's provider env vars.
+
+ Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
+ the same store ``hermes tools`` writes when it prompts for keys. Keys are
+ validated against the env-var allowlist for the toolset's category (the
+ union of every visible provider's ``env_vars``), so the GUI can't write an
+ arbitrary env var through this endpoint. A blank value is treated as
+ "leave unchanged" and skipped. Returns the saved/skipped key lists and the
+ refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
+ """
+ from hermes_cli.tools_config import (
+ TOOL_CATEGORIES,
+ _get_effective_configurable_toolsets,
+ _visible_providers,
+ )
+ from hermes_cli.config import get_env_value, save_env_value
+
+ valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
+ if name not in valid_ts:
+ raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
+
+ config = load_config()
+ cat = TOOL_CATEGORIES.get(name)
+ allowed: set[str] = set()
+ if cat:
+ for prov in _visible_providers(cat, config, force_fresh=True):
+ for e in prov.get("env_vars", []):
+ allowed.add(e["key"])
+
+ unknown = [k for k in body.env if k not in allowed]
+ if unknown:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}",
+ )
+
+ saved: List[str] = []
+ skipped: List[str] = []
+ for key, value in body.env.items():
+ if value and value.strip():
+ try:
+ save_env_value(key, value.strip())
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ saved.append(key)
+ else:
+ skipped.append(key)
+
+ status = {k: bool(get_env_value(k)) for k in allowed}
+ return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
+
+
+class ToolsetPostSetup(BaseModel):
+ key: str
+
+
+@app.post("/api/tools/toolsets/{name}/post-setup")
+async def run_toolset_post_setup(name: str, body: ToolsetPostSetup):
+ """Spawn a provider's post-setup install hook as a background action.
+
+ Post-setup hooks (npm install for browser/Camofox, pip install for
+ KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
+ text-output, so this follows the spawn-action pattern: it launches
+ ``hermes tools post-setup `` and the frontend tails the log via
+ ``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
+ against the declared post-setup allowlist before spawning. Returns 400
+ for unknown toolset or post-setup key.
+ """
+ from hermes_cli.tools_config import (
+ _get_effective_configurable_toolsets,
+ valid_post_setup_keys,
+ )
+
+ valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
+ if name not in valid_ts:
+ raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
+
+ if body.key not in valid_post_setup_keys():
+ raise HTTPException(
+ status_code=400, detail=f"Unknown post-setup key: {body.key}"
+ )
+
+ try:
+ proc = _spawn_hermes_action(
+ ["tools", "post-setup", body.key], "tools-post-setup"
+ )
+ except Exception as exc:
+ _log.exception("Failed to spawn tools post-setup")
+ raise HTTPException(
+ status_code=500, detail=f"Failed to run post-setup: {exc}"
+ )
+ return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
+
+
# ---------------------------------------------------------------------------
# Raw YAML config endpoint
# ---------------------------------------------------------------------------
diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py
index 04ccc7eb299..df21d2fd56c 100644
--- a/tests/hermes_cli/test_dashboard_admin_endpoints.py
+++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py
@@ -794,3 +794,132 @@ class TestDebugShareEndpoint:
)
assert r.status_code == 401
+
+class TestToolsConfigEndpoints:
+ """Provider selection, API-key save, and post-setup spawn for toolsets —
+ the dashboard surface that replicates the `hermes tools` configurator."""
+
+ @pytest.fixture(autouse=True)
+ def _setup(self, _isolate_hermes_home):
+ self.client, self.header = _client()
+
+ def test_list_toolsets_shape(self):
+ r = self.client.get("/api/tools/toolsets")
+ assert r.status_code == 200
+ rows = r.json()
+ assert isinstance(rows, list) and rows
+ row = rows[0]
+ for k in ("name", "label", "enabled", "configured", "tools"):
+ assert k in row
+
+ def test_toolset_config_provider_matrix(self):
+ # `web` has a TOOL_CATEGORIES entry → providers list populated.
+ r = self.client.get("/api/tools/toolsets/web/config")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["has_category"] is True
+ assert isinstance(body["providers"], list)
+
+ def test_unknown_toolset_config_400(self):
+ r = self.client.get("/api/tools/toolsets/not_a_toolset/config")
+ assert r.status_code == 400
+
+ def test_save_env_writes_key_and_validates_allowlist(self):
+ from hermes_cli.config import get_env_value
+
+ cfg = self.client.get("/api/tools/toolsets/web/config").json()
+ # Find a real env-var key from the visible provider matrix.
+ key = None
+ for prov in cfg["providers"]:
+ for e in prov.get("env_vars", []):
+ key = e["key"]
+ break
+ if key:
+ break
+ if not key:
+ pytest.skip("no env-var-bearing web provider in this build")
+
+ r = self.client.put(
+ "/api/tools/toolsets/web/env", json={"env": {key: "test-secret-123"}}
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert key in body["saved"]
+ assert body["is_set"][key] is True
+ # CLI-config parity: the key landed in the .env store the CLI reads.
+ assert get_env_value(key) == "test-secret-123"
+
+ def test_save_env_rejects_unknown_key(self):
+ r = self.client.put(
+ "/api/tools/toolsets/web/env",
+ json={"env": {"TOTALLY_BOGUS_KEY": "x"}},
+ )
+ assert r.status_code == 400
+
+ def test_save_env_blank_value_skipped(self):
+ cfg = self.client.get("/api/tools/toolsets/web/config").json()
+ key = None
+ for prov in cfg["providers"]:
+ for e in prov.get("env_vars", []):
+ key = e["key"]
+ break
+ if key:
+ break
+ if not key:
+ pytest.skip("no env-var-bearing web provider in this build")
+ r = self.client.put(
+ "/api/tools/toolsets/web/env", json={"env": {key: " "}}
+ )
+ assert r.status_code == 200
+ assert key in r.json()["skipped"]
+
+ def test_post_setup_unknown_key_400(self):
+ r = self.client.post(
+ "/api/tools/toolsets/browser/post-setup", json={"key": "bogus"}
+ )
+ assert r.status_code == 400
+
+ def test_post_setup_unknown_toolset_400(self):
+ r = self.client.post(
+ "/api/tools/toolsets/not_a_toolset/post-setup",
+ json={"key": "agent_browser"},
+ )
+ assert r.status_code == 400
+
+ def test_post_setup_spawns_action(self, monkeypatch):
+ import hermes_cli.web_server as ws
+
+ spawned = {}
+
+ class _FakeProc:
+ pid = 4321
+
+ def _fake_spawn(subcommand, name):
+ spawned["subcommand"] = subcommand
+ spawned["name"] = name
+ return _FakeProc()
+
+ monkeypatch.setattr(ws, "_spawn_hermes_action", _fake_spawn)
+ r = self.client.post(
+ "/api/tools/toolsets/browser/post-setup",
+ json={"key": "agent_browser"},
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["name"] == "tools-post-setup"
+ assert body["pid"] == 4321
+ assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
+
+ def test_endpoints_require_session_token(self):
+ for method, path, payload in [
+ ("get", "/api/tools/toolsets/web/config", None),
+ ("put", "/api/tools/toolsets/web/env", {"env": {}}),
+ ("post", "/api/tools/toolsets/web/post-setup", {"key": "ddgs"}),
+ ]:
+ fn = getattr(self.client, method)
+ kwargs = {"headers": {self.header: "wrong-token"}}
+ if payload is not None:
+ kwargs["json"] = payload
+ r = fn(path, **kwargs)
+ assert r.status_code == 401, f"{method} {path} not gated"
+
diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx
new file mode 100644
index 00000000000..42e58d589f5
--- /dev/null
+++ b/web/src/components/ToolsetConfigDrawer.tsx
@@ -0,0 +1,448 @@
+import { useCallback, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { Check, ExternalLink, Loader2, Terminal, X } from "lucide-react";
+import { api } from "@/lib/api";
+import type {
+ ToolsetConfig,
+ ToolsetInfo,
+ ToolsetProvider,
+} from "@/lib/api";
+import { useToast } from "@nous-research/ui/hooks/use-toast";
+import { Button } from "@nous-research/ui/ui/components/button";
+import { Input } from "@nous-research/ui/ui/components/input";
+import { Label } from "@nous-research/ui/ui/components/label";
+import { Badge } from "@nous-research/ui/ui/components/badge";
+import { Switch } from "@nous-research/ui/ui/components/switch";
+import { Spinner } from "@nous-research/ui/ui/components/spinner";
+import { Toast } from "@nous-research/ui/ui/components/toast";
+import { cn, themedBody } from "@/lib/utils";
+
+interface Props {
+ /** The toolset whose backends are being configured. */
+ toolset: ToolsetInfo;
+ onClose: () => void;
+ /** Called after a toggle/provider/key change so the parent grid refreshes. */
+ onChanged: () => void;
+}
+
+/**
+ * Full configuration surface for a single toolset's backends — the dashboard
+ * equivalent of selecting a toolset in the `hermes tools` curses UI: toggle
+ * the toolset on/off, pick a provider, enter API keys, and run a provider's
+ * post-setup install hook (npm/pip/binary) with a live log tail.
+ */
+export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
+ const { toast, showToast } = useToast();
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [enabled, setEnabled] = useState(toolset.enabled);
+ const [toggling, setToggling] = useState(false);
+ const [selecting, setSelecting] = useState(null);
+ const [activeProvider, setActiveProvider] = useState(null);
+ // Per-env-var draft input values, keyed by env var name.
+ const [drafts, setDrafts] = useState>({});
+ const [savingProvider, setSavingProvider] = useState(null);
+ const [isSet, setIsSet] = useState>({});
+
+ // Post-setup install log tail state.
+ const [postSetupRunning, setPostSetupRunning] = useState(false);
+ const [postSetupLog, setPostSetupLog] = useState([]);
+ const [postSetupKey, setPostSetupKey] = useState(null);
+ // Bumped each time a post-setup is kicked off, to (re)trigger the poll
+ // effect below. Mirrors the SkillsPage HubBrowser action-poll pattern so
+ // the recursive timer lives inside the effect (lint-clean — no ref
+ // mutation, no self-referencing memo).
+ const [postSetupTrigger, setPostSetupTrigger] = useState(0);
+
+ const loadConfig = useCallback(() => {
+ // Promise-chain shape (not async/await with a leading synchronous
+ // setLoading) so callers in a useEffect don't trip
+ // react-hooks/set-state-in-effect — setState only fires inside the
+ // async .then/.catch/.finally callbacks.
+ return api
+ .getToolsetConfig(toolset.name)
+ .then((cfg) => {
+ setConfig(cfg);
+ setActiveProvider(cfg.active_provider);
+ const seed: Record = {};
+ for (const p of cfg.providers) {
+ for (const e of p.env_vars) seed[e.key] = e.is_set;
+ }
+ setIsSet(seed);
+ })
+ .catch(() => showToast("Failed to load toolset config", "error"))
+ .finally(() => setLoading(false));
+ }, [toolset.name, showToast]);
+
+ useEffect(() => {
+ void loadConfig();
+ }, [loadConfig]);
+
+ // Poll the post-setup action's log until it exits. Driven by
+ // postSetupTrigger; the recursive timer + cleanup live entirely inside the
+ // effect (matches the SkillsPage HubBrowser pattern — lint-clean).
+ useEffect(() => {
+ if (postSetupTrigger === 0) return;
+ let cancelled = false;
+ let timer: ReturnType | null = null;
+ const poll = async () => {
+ try {
+ const st = await api.getActionStatus("tools-post-setup", 300);
+ if (cancelled) return;
+ setPostSetupLog(st.lines);
+ if (st.running) {
+ timer = setTimeout(() => void poll(), 1200);
+ } else {
+ setPostSetupRunning(false);
+ const ok = st.exit_code === 0;
+ showToast(
+ ok ? "Post-setup complete" : "Post-setup finished with errors",
+ ok ? "success" : "error",
+ );
+ // Refresh — a backend may now report itself configured/available.
+ void loadConfig();
+ onChanged();
+ }
+ } catch {
+ if (!cancelled) {
+ setPostSetupRunning(false);
+ showToast("Lost track of the post-setup process", "error");
+ }
+ }
+ };
+ // Small delay so the spawned action has a log file to read.
+ timer = setTimeout(() => void poll(), 800);
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [postSetupTrigger, showToast, loadConfig, onChanged]);
+
+ const handleToggle = async (next: boolean) => {
+ setToggling(true);
+ try {
+ await api.toggleToolset(toolset.name, next);
+ setEnabled(next);
+ showToast(
+ `${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
+ "success",
+ );
+ onChanged();
+ } catch {
+ showToast("Failed to toggle toolset", "error");
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ const handleSelectProvider = async (provider: ToolsetProvider) => {
+ setSelecting(provider.name);
+ try {
+ await api.selectToolsetProvider(toolset.name, provider.name);
+ setActiveProvider(provider.name);
+ showToast(`Provider set to ${provider.name}`, "success");
+ onChanged();
+ } catch (e) {
+ showToast(
+ e instanceof Error ? e.message : "Failed to select provider",
+ "error",
+ );
+ } finally {
+ setSelecting(null);
+ }
+ };
+
+ const handleSaveKeys = async (provider: ToolsetProvider) => {
+ const env: Record = {};
+ for (const e of provider.env_vars) {
+ const v = drafts[e.key];
+ if (v && v.trim()) env[e.key] = v.trim();
+ }
+ if (Object.keys(env).length === 0) {
+ showToast("Enter at least one value to save", "error");
+ return;
+ }
+ setSavingProvider(provider.name);
+ try {
+ const res = await api.saveToolsetEnv(toolset.name, env);
+ setIsSet((prev) => ({ ...prev, ...res.is_set }));
+ // Clear saved drafts so the inputs reset to the "saved" placeholder.
+ setDrafts((prev) => {
+ const next = { ...prev };
+ for (const k of res.saved) delete next[k];
+ return next;
+ });
+ showToast(
+ res.saved.length
+ ? `Saved ${res.saved.length} key${res.saved.length > 1 ? "s" : ""}`
+ : "Nothing to save",
+ "success",
+ );
+ onChanged();
+ } catch (e) {
+ showToast(
+ e instanceof Error ? e.message : "Failed to save keys",
+ "error",
+ );
+ } finally {
+ setSavingProvider(null);
+ }
+ };
+
+ const handleRunPostSetup = async (provider: ToolsetProvider) => {
+ if (!provider.post_setup) return;
+ setPostSetupRunning(true);
+ setPostSetupLog([]);
+ setPostSetupKey(provider.post_setup);
+ try {
+ await api.runToolsetPostSetup(toolset.name, provider.post_setup);
+ // Bump the trigger so the poll effect (re)starts tailing the log.
+ setPostSetupTrigger((n) => n + 1);
+ } catch (e) {
+ setPostSetupRunning(false);
+ showToast(
+ e instanceof Error ? e.message : "Failed to start post-setup",
+ "error",
+ );
+ }
+ };
+
+ const labelText = toolset.label?.trim() || toolset.name;
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+
+
+
+
+ {/* Header — toolset identity + enable toggle */}
+
+
+
+ {labelText}
+
+
+ {enabled ? "Active" : "Inactive"}
+
+
+
+ {toolset.description}
+
+
+ void handleToggle(v)}
+ disabled={toggling}
+ aria-label="Enable toolset"
+ />
+
+ {enabled ? "Enabled for the agent" : "Disabled"}
+
+
+
+
+ {/* Body — provider matrix */}
+
+ {loading ? (
+
+
+
+ ) : !config?.has_category ? (
+
+ This toolset has no configurable backends — toggle it on or off
+ above. It works with no provider selection or API keys.
+
+ ) : config.providers.length === 0 ? (
+
+ No providers are available for this toolset in this install.
+
+ ) : (
+ config.providers.map((provider) => {
+ const isActive = provider.name === activeProvider;
+ return (
+
+
+
+
+ {provider.name}
+
+ {provider.badge && (
+
+ {provider.badge}
+
+ )}
+ {provider.requires_nous_auth && (
+
+ Nous Portal
+
+ )}
+
+ {isActive ? (
+
+ Selected
+
+ ) : (
+
void handleSelectProvider(provider)}
+ disabled={selecting !== null}
+ >
+ {selecting === provider.name ? (
+
+ ) : (
+ "Select"
+ )}
+
+ )}
+
+ {provider.tag && (
+
+ {provider.tag}
+
+ )}
+
+ {/* API key inputs */}
+ {provider.env_vars.length > 0 && (
+
+ {provider.env_vars.map((ev) => (
+
+
+
+ {ev.key}
+
+ {isSet[ev.key] && (
+
+ Saved
+
+ )}
+
+
+ setDrafts((prev) => ({
+ ...prev,
+ [ev.key]: e.target.value,
+ }))
+ }
+ />
+ {ev.url && (
+
+ Get a key
+
+ )}
+
+ ))}
+
void handleSaveKeys(provider)}
+ disabled={savingProvider !== null}
+ >
+ {savingProvider === provider.name ? (
+
+ ) : (
+ "Save keys"
+ )}
+
+
+ )}
+
+ {/* Post-setup install hook */}
+ {provider.post_setup && (
+
+
+ This backend needs a one-time install
+ {" "}
+
+ ({provider.post_setup})
+
+ . Runs on this host — may take a few minutes.
+
+
void handleRunPostSetup(provider)}
+ disabled={postSetupRunning}
+ >
+ {postSetupRunning &&
+ postSetupKey === provider.post_setup ? (
+ <>
+
+ Installing…
+ >
+ ) : (
+ <>
+ Run setup
+ >
+ )}
+
+
+ )}
+
+ );
+ })
+ )}
+
+ {/* Post-setup live log */}
+ {(postSetupRunning || postSetupLog.length > 0) && (
+
+
+
+
+ post-setup: {postSetupKey}
+
+ {postSetupRunning && (
+
+ )}
+
+
+ {postSetupLog.length ? postSetupLog.join("\n") : "Starting…"}
+
+
+ )}
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 0452ee46103..cbcb7fe1440 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -513,6 +513,46 @@ export const api = {
body: JSON.stringify({ name, enabled }),
}),
getToolsets: () => fetchJSON("/api/tools/toolsets"),
+ toggleToolset: (name: string, enabled: boolean) =>
+ fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
+ `/api/tools/toolsets/${encodeURIComponent(name)}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ },
+ ),
+ getToolsetConfig: (name: string) =>
+ fetchJSON(
+ `/api/tools/toolsets/${encodeURIComponent(name)}/config`,
+ ),
+ selectToolsetProvider: (name: string, provider: string) =>
+ fetchJSON<{ ok: boolean; name: string; provider: string }>(
+ `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ provider }),
+ },
+ ),
+ saveToolsetEnv: (name: string, env: Record) =>
+ fetchJSON(
+ `/api/tools/toolsets/${encodeURIComponent(name)}/env`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ env }),
+ },
+ ),
+ runToolsetPostSetup: (name: string, key: string) =>
+ fetchJSON(
+ `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ key }),
+ },
+ ),
// Session search (FTS5)
searchSessions: (q: string) =>
@@ -1619,6 +1659,39 @@ export interface ToolsetInfo {
tools: string[];
}
+export interface ToolsetProviderEnvVar {
+ key: string;
+ prompt: string;
+ url: string | null;
+ default: string | null;
+ is_set: boolean;
+}
+
+export interface ToolsetProvider {
+ name: string;
+ badge: string;
+ tag: string;
+ env_vars: ToolsetProviderEnvVar[];
+ post_setup: string | null;
+ requires_nous_auth: boolean;
+ is_active: boolean;
+}
+
+export interface ToolsetConfig {
+ name: string;
+ has_category: boolean;
+ providers: ToolsetProvider[];
+ active_provider: string | null;
+}
+
+export interface ToolsetEnvResult {
+ ok: boolean;
+ name: string;
+ saved: string[];
+ skipped: string[];
+ is_set: Record;
+}
+
export interface SessionSearchResult {
session_id: string;
snippet: string;
diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx
index c21a3e481d0..4ece3105f76 100644
--- a/web/src/pages/SkillsPage.tsx
+++ b/web/src/pages/SkillsPage.tsx
@@ -36,6 +36,7 @@ import type {
SkillHubPreview,
SkillHubScan,
} from "@/lib/api";
+import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@@ -127,6 +128,7 @@ export default function SkillsPage() {
const [view, setView] = useState<"skills" | "toolsets" | "hub">("skills");
const [activeCategory, setActiveCategory] = useState(null);
const [togglingSkills, setTogglingSkills] = useState>(new Set());
+ const [configToolset, setConfigToolset] = useState(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
@@ -166,6 +168,16 @@ export default function SkillsPage() {
}
};
+ /* ---- Refresh toolsets after a config change ---- */
+ const refreshToolsets = async () => {
+ try {
+ const tsets = await api.getToolsets();
+ setToolsets(tsets);
+ } catch {
+ /* non-fatal: the drawer already toasted on the failing write */
+ }
+ };
+
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
@@ -508,6 +520,16 @@ export default function SkillsPage() {
: t.skills.disabledForCli}
)}
+
+ setConfigToolset(ts)}
+ >
+
+ Configure
+
+
@@ -522,6 +544,13 @@ export default function SkillsPage() {
)}
+ {configToolset && (
+
setConfigToolset(null)}
+ onChanged={() => void refreshToolsets()}
+ />
+ )}
);
From f18a9dbefc597f77741c370f203b3747193bef6d 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 15:40:18 -0500
Subject: [PATCH 3/9] feat: Add desktop language switching for Japanese and
Traditional Chinese
---
.../src/app/settings/appearance-settings.tsx | 60 +----
apps/desktop/src/app/settings/index.tsx | 10 +-
.../src/components/language-switcher.test.tsx | 39 +++
.../src/components/language-switcher.tsx | 145 +++++++++++
apps/desktop/src/i18n/catalog.ts | 6 +-
apps/desktop/src/i18n/context.test.tsx | 66 ++++-
apps/desktop/src/i18n/define-locale.ts | 41 +++
apps/desktop/src/i18n/en.ts | 11 +-
apps/desktop/src/i18n/ja.ts | 237 ++++++++++++++++++
apps/desktop/src/i18n/languages.test.ts | 23 +-
apps/desktop/src/i18n/languages.ts | 27 +-
apps/desktop/src/i18n/runtime.test.ts | 24 ++
apps/desktop/src/i18n/types.ts | 14 +-
apps/desktop/src/i18n/zh-hant.ts | 236 +++++++++++++++++
apps/desktop/src/i18n/zh.ts | 14 +-
15 files changed, 871 insertions(+), 82 deletions(-)
create mode 100644 apps/desktop/src/components/language-switcher.test.tsx
create mode 100644 apps/desktop/src/components/language-switcher.tsx
create mode 100644 apps/desktop/src/i18n/define-locale.ts
create mode 100644 apps/desktop/src/i18n/ja.ts
create mode 100644 apps/desktop/src/i18n/zh-hant.ts
diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx
index 6fa5aefadba..7acf47cd618 100644
--- a/apps/desktop/src/app/settings/appearance-settings.tsx
+++ b/apps/desktop/src/app/settings/appearance-settings.tsx
@@ -1,10 +1,10 @@
import { useStore } from '@nanostores/react'
-import { type Locale, LOCALE_META, useI18n } from '@/i18n'
+import { LanguageSwitcher } from '@/components/language-switcher'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { notifyError } from '@/store/notifications'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
@@ -53,27 +53,11 @@ function ThemePreview({ name }: { name: string }) {
}
export function AppearanceSettings() {
- const { t, isSavingLocale, locale, setLocale } = useI18n()
+ const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
- const locales = Object.keys(LOCALE_META) as Locale[]
-
- const selectLocale = async (code: Locale) => {
- if (code === locale || isSavingLocale) {
- return
- }
-
- triggerHaptic('selection')
-
- try {
- await setLocale(code)
- triggerHaptic('success')
- } catch (error) {
- notifyError(error, t.language.saveError)
- }
- }
return (
@@ -86,45 +70,13 @@ export function AppearanceSettings() {
-
-
+
+
{t.language.label}
{t.language.description}
{isSavingLocale &&
{t.language.saving}
}
-
{LOCALE_META[locale].name}
-
-
- {locales.map(code => {
- const active = locale === code
-
- return (
-
void selectLocale(code)}
- type="button"
- >
-
-
- {LOCALE_META[code].name}
-
- {active && (
-
-
-
- )}
-
-
- {code}
-
-
- )
- })}
+
diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx
index b35290c6db5..93ab0c8ecca 100644
--- a/apps/desktop/src/app/settings/index.tsx
+++ b/apps/desktop/src/app/settings/index.tsx
@@ -105,7 +105,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
setActiveView('providers')}
/>
{activeView === 'providers' && (
@@ -113,14 +113,14 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
openProviderView('accounts')}
/>
openProviderView('keys')}
/>
@@ -143,14 +143,14 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
openKeysView('tools')}
/>
openKeysView('settings')}
/>
diff --git a/apps/desktop/src/components/language-switcher.test.tsx b/apps/desktop/src/components/language-switcher.test.tsx
new file mode 100644
index 00000000000..77614af22e5
--- /dev/null
+++ b/apps/desktop/src/components/language-switcher.test.tsx
@@ -0,0 +1,39 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { HermesConfigRecord } from '@/hermes'
+import { type I18nConfigClient, I18nProvider } from '@/i18n'
+
+import { LanguageSwitcher } from './language-switcher'
+
+describe('LanguageSwitcher', () => {
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('persists language changes through display.language config', async () => {
+ const saveConfig = vi.fn().mockResolvedValue({ ok: true })
+ const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } }
+ const configClient: I18nConfigClient = {
+ getConfig: vi.fn().mockResolvedValue(latestConfig),
+ saveConfig
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Switch language' }).hasAttribute('disabled')).toBe(false)
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Switch language' }))
+ fireEvent.click(screen.getByRole('option', { name: /日本語/i }))
+
+ await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
+ expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ja', skin: 'slate' } })
+ })
+})
diff --git a/apps/desktop/src/components/language-switcher.tsx b/apps/desktop/src/components/language-switcher.tsx
new file mode 100644
index 00000000000..59edb622ef1
--- /dev/null
+++ b/apps/desktop/src/components/language-switcher.tsx
@@ -0,0 +1,145 @@
+import { useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
+import { useIsMobile } from '@/hooks/use-mobile'
+import { type Locale, LOCALE_META, useI18n } from '@/i18n'
+import { triggerHaptic } from '@/lib/haptics'
+import { Check, ChevronDown, Globe } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { notifyError } from '@/store/notifications'
+
+export interface LanguageSwitcherProps {
+ className?: string
+ collapsed?: boolean
+ dropUp?: boolean
+}
+
+interface LanguageSwitcherOptionsProps {
+ allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
+ disabled?: boolean
+ label: string
+ locale: Locale
+ onSelect: (code: Locale) => void
+}
+
+export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
+ const { isSavingLocale, locale, setLocale, t } = useI18n()
+ const [open, setOpen] = useState(false)
+ const isMobile = useIsMobile()
+ const useMobileSheet = Boolean(dropUp && isMobile)
+ const current = LOCALE_META[locale]
+ const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>
+ const title = t.language.switchTo
+
+ const selectLocale = async (code: Locale) => {
+ if (code === locale || isSavingLocale) {
+ setOpen(false)
+ return
+ }
+
+ triggerHaptic('selection')
+
+ try {
+ await setLocale(code)
+ setOpen(false)
+ triggerHaptic('success')
+ } catch (error) {
+ notifyError(error, t.language.saveError)
+ }
+ }
+
+ const trigger = (
+
+
+
+ {!collapsed && {locale === 'en' ? 'EN' : current.name} }
+
+ {!collapsed && }
+
+ )
+
+ if (useMobileSheet) {
+ return (
+
+ {trigger}
+
+
+ {title}
+ {t.language.description}
+
+
+ void selectLocale(code)}
+ />
+
+
+
+ )
+ }
+
+ return (
+
+ {trigger}
+
+
+ void selectLocale(code)}
+ />
+
+
+
+ )
+}
+
+function LanguageSwitcherOptions({ allLocales, disabled, label, locale, onSelect }: LanguageSwitcherOptionsProps) {
+ return (
+
+ {allLocales.map(([code, meta]) => {
+ const selected = code === locale
+
+ return (
+ onSelect(code)}
+ role="option"
+ type="button"
+ >
+ {meta.name}
+ {code}
+ {selected && }
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/desktop/src/i18n/catalog.ts b/apps/desktop/src/i18n/catalog.ts
index 5919aa47763..19556cb45c4 100644
--- a/apps/desktop/src/i18n/catalog.ts
+++ b/apps/desktop/src/i18n/catalog.ts
@@ -1,8 +1,12 @@
import { en } from './en'
+import { ja } from './ja'
import type { Locale, Translations } from './types'
import { zh } from './zh'
+import { zhHant } from './zh-hant'
export const TRANSLATIONS: Record = {
en,
- zh
+ zh,
+ 'zh-hant': zhHant,
+ ja
}
diff --git a/apps/desktop/src/i18n/context.test.tsx b/apps/desktop/src/i18n/context.test.tsx
index ff4f5580941..3028c86ffa0 100644
--- a/apps/desktop/src/i18n/context.test.tsx
+++ b/apps/desktop/src/i18n/context.test.tsx
@@ -13,6 +13,7 @@ function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
{locale}
{t.language.label}
+
{t.common.save}
{String(isLoadingConfig)}
{String(isSavingLocale)}
{saveError?.message ?? ''}
@@ -94,9 +95,47 @@ describe('I18nProvider', () => {
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
+ it('loads zh-hant from display.language config', async () => {
+ const configClient: I18nConfigClient = {
+ getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-TW' } }),
+ saveConfig: vi.fn()
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
+
+ expect(screen.getByTestId('locale').textContent).toBe('zh-hant')
+ expect(screen.getByTestId('save').textContent).toBe('儲存')
+ expect(configClient.saveConfig).not.toHaveBeenCalled()
+ })
+
+ it('loads ja from display.language config', async () => {
+ const configClient: I18nConfigClient = {
+ getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja-JP' } }),
+ saveConfig: vi.fn()
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
+
+ expect(screen.getByTestId('locale').textContent).toBe('ja')
+ expect(screen.getByTestId('save').textContent).toBe('保存')
+ expect(configClient.saveConfig).not.toHaveBeenCalled()
+ })
+
it('does not overwrite unsupported configured languages', async () => {
const configClient: I18nConfigClient = {
- getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
+ getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }),
saveConfig: vi.fn()
}
@@ -145,6 +184,31 @@ describe('I18nProvider', () => {
})
})
+ it('saves newly supported locales to display.language', async () => {
+ const saveConfig = vi.fn().mockResolvedValue({ ok: true })
+
+ const configClient: I18nConfigClient = {
+ getConfig: vi
+ .fn()
+ .mockResolvedValueOnce({ display: { language: 'en' } })
+ .mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' } }),
+ saveConfig
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
+ fireEvent.click(screen.getByRole('button', { name: 'switch' }))
+
+ await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
+ expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ja', skin: 'mono' } })
+ expect(screen.getByTestId('locale').textContent).toBe('ja')
+ })
+
it('rolls back the visible locale when saving fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
diff --git a/apps/desktop/src/i18n/define-locale.ts b/apps/desktop/src/i18n/define-locale.ts
new file mode 100644
index 00000000000..bb6f29f6fbb
--- /dev/null
+++ b/apps/desktop/src/i18n/define-locale.ts
@@ -0,0 +1,41 @@
+import { en } from './en'
+import type { Translations } from './types'
+
+type TranslationOverride
= T extends (...args: never[]) => string
+ ? T
+ : T extends readonly unknown[]
+ ? T
+ : T extends string
+ ? string
+ : T extends object
+ ? { [K in keyof T]?: TranslationOverride }
+ : T
+
+export type TranslationOverrides = TranslationOverride
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function mergeTranslations(base: T, overrides: TranslationOverride | undefined): T {
+ if (!isRecord(base) || !isRecord(overrides)) {
+ return (overrides ?? base) as T
+ }
+
+ const result: Record = { ...base }
+
+ for (const [key, value] of Object.entries(overrides)) {
+ if (value === undefined) {
+ continue
+ }
+
+ const baseValue = result[key]
+ result[key] = isRecord(baseValue) && isRecord(value) ? mergeTranslations(baseValue, value) : value
+ }
+
+ return result as T
+}
+
+export function defineLocale(overrides: TranslationOverrides): Translations {
+ return mergeTranslations(en, overrides)
+}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 19709a8e3da..22f6b6b5a90 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -107,7 +107,8 @@ export const en: Translations = {
label: 'Language',
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
- saveError: 'Language update failed'
+ saveError: 'Language update failed',
+ switchTo: 'Switch language'
},
settings: {
@@ -119,8 +120,13 @@ export const en: Translations = {
exportFailed: 'Export failed',
resetFailed: 'Reset failed',
nav: {
+ providers: 'Providers',
+ providerAccounts: 'Accounts',
+ providerApiKeys: 'API keys',
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
+ keysTools: 'Tools',
+ keysSettings: 'Settings',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
@@ -521,8 +527,7 @@ export const en: Translations = {
editTitle: 'Edit cron job',
createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
- createDesc:
- 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
+ createDesc: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name',
namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
new file mode 100644
index 00000000000..b5a3708b190
--- /dev/null
+++ b/apps/desktop/src/i18n/ja.ts
@@ -0,0 +1,237 @@
+import { defineLocale } from './define-locale'
+
+export const ja = defineLocale({
+ common: {
+ save: '保存',
+ saving: '保存中…',
+ cancel: 'キャンセル',
+ close: '閉じる',
+ confirm: '確認',
+ delete: '削除',
+ refresh: '更新',
+ retry: '再試行',
+ on: 'オン',
+ off: 'オフ'
+ },
+
+ language: {
+ label: '言語',
+ description: 'デスクトップインターフェイスの言語を選択します。',
+ saving: '言語を保存中…',
+ saveError: '言語の更新に失敗しました',
+ switchTo: '言語を切り替え'
+ },
+
+ settings: {
+ closeSettings: '設定を閉じる',
+ exportConfig: '設定を書き出す',
+ importConfig: '設定を読み込む',
+ resetToDefaults: 'デフォルトに戻す',
+ resetConfirm: 'すべての設定を Hermes のデフォルトに戻しますか?',
+ exportFailed: '書き出しに失敗しました',
+ resetFailed: 'リセットに失敗しました',
+ nav: {
+ providers: 'プロバイダー',
+ providerAccounts: 'アカウント',
+ providerApiKeys: 'API キー',
+ gateway: 'ゲートウェイ',
+ apiKeys: 'ツールとキー',
+ keysTools: 'ツール',
+ keysSettings: '設定',
+ mcp: 'MCP',
+ archivedChats: 'アーカイブ済みチャット',
+ about: '情報'
+ },
+ sections: {
+ model: 'モデル',
+ chat: 'チャット',
+ appearance: '外観',
+ workspace: 'ワークスペース',
+ safety: '安全性',
+ memory: 'メモリとコンテキスト',
+ voice: '音声',
+ advanced: '詳細'
+ },
+ searchPlaceholder: {
+ about: 'Hermes Desktop について',
+ config: '設定を検索…',
+ gateway: 'ゲートウェイ接続…',
+ keys: 'API キーを検索…',
+ mcp: 'MCP サーバーを検索…',
+ sessions: 'アーカイブ済みセッションを検索…'
+ },
+ modeOptions: {
+ light: { label: 'ライト', description: '明るいデスクトップ表示' },
+ dark: { label: 'ダーク', description: 'まぶしさを抑えたワークスペース' },
+ system: { label: 'システム', description: 'OS の外観に合わせる' }
+ },
+ appearance: {
+ title: '外観',
+ intro:
+ 'デスクトップ専用の表示設定です。モードは明るさ、テーマはアクセントカラーとチャット面のスタイルを制御します。',
+ colorMode: 'カラーモード',
+ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
+ toolViewTitle: 'ツール呼び出しの表示',
+ toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
+ product: 'プロダクト',
+ productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
+ technical: 'テクニカル',
+ technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
+ themeTitle: 'テーマ',
+ themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
+ },
+ fieldLabels: {
+ model: 'デフォルトモデル',
+ model_context_length: 'コンテキストウィンドウ',
+ fallback_providers: 'フォールバックモデル',
+ toolsets: '有効なツールセット',
+ timezone: 'タイムゾーン',
+ 'display.personality': '人格',
+ 'display.show_reasoning': '推論ブロック',
+ 'agent.max_turns': '最大エージェントステップ',
+ 'agent.image_input_mode': '画像添付',
+ 'terminal.cwd': '作業ディレクトリ',
+ 'terminal.backend': '実行バックエンド',
+ 'terminal.timeout': 'コマンドタイムアウト',
+ 'terminal.persistent_shell': '永続シェル',
+ 'terminal.env_passthrough': '環境変数の引き継ぎ',
+ file_read_max_chars: 'ファイル読み取り上限',
+ 'tool_output.max_bytes': 'ターミナル出力上限',
+ 'tool_output.max_lines': 'ファイルページ上限',
+ 'tool_output.max_line_length': '行長上限',
+ 'code_execution.mode': 'コード実行モード',
+ 'approvals.mode': '承認モード',
+ 'approvals.timeout': '承認タイムアウト',
+ 'approvals.mcp_reload_confirm': 'MCP 再読み込みの確認',
+ command_allowlist: 'コマンド許可リスト',
+ 'security.redact_secrets': 'シークレットを伏せる',
+ 'security.allow_private_urls': 'プライベート URL を許可',
+ 'browser.allow_private_urls': 'ブラウザーのプライベート URL',
+ 'browser.auto_local_for_private_urls': 'プライベート URL にはローカルブラウザーを使用',
+ 'checkpoints.enabled': 'ファイルチェックポイント',
+ 'checkpoints.max_snapshots': 'チェックポイント上限',
+ 'voice.record_key': '音声ショートカット',
+ 'voice.max_recording_seconds': '最大録音時間',
+ 'voice.auto_tts': '応答を読み上げる',
+ 'stt.enabled': '音声認識',
+ 'stt.provider': '音声認識プロバイダー',
+ 'stt.local.model': 'ローカル文字起こしモデル',
+ 'stt.local.language': '文字起こし言語',
+ 'stt.elevenlabs.model_id': 'ElevenLabs STT モデル',
+ 'stt.elevenlabs.language_code': 'ElevenLabs 言語',
+ 'stt.elevenlabs.tag_audio_events': '音声イベントをタグ付け',
+ 'stt.elevenlabs.diarize': '話者分離',
+ 'tts.provider': '音声合成プロバイダー',
+ 'tts.edge.voice': 'Edge 音声',
+ 'tts.openai.model': 'OpenAI TTS モデル',
+ 'tts.openai.voice': 'OpenAI 音声',
+ 'tts.elevenlabs.voice_id': 'ElevenLabs 音声',
+ 'tts.elevenlabs.model_id': 'ElevenLabs モデル',
+ 'memory.memory_enabled': '永続メモリ',
+ 'memory.user_profile_enabled': 'ユーザープロファイル',
+ 'memory.memory_char_limit': 'メモリ予算',
+ 'memory.user_char_limit': 'プロファイル予算',
+ 'memory.provider': 'メモリプロバイダー',
+ 'context.engine': 'コンテキストエンジン',
+ 'compression.enabled': '自動圧縮',
+ 'compression.threshold': '圧縮しきい値',
+ 'compression.target_ratio': '圧縮目標',
+ 'compression.protect_last_n': '保護する直近メッセージ',
+ 'agent.api_max_retries': 'API 再試行回数',
+ 'agent.service_tier': 'サービス階層',
+ 'agent.tool_use_enforcement': 'ツール使用の強制',
+ 'delegation.model': 'サブエージェントモデル',
+ 'delegation.provider': 'サブエージェントプロバイダー',
+ 'delegation.max_iterations': 'サブエージェントターン上限',
+ 'delegation.max_concurrent_children': '並列サブエージェント',
+ 'delegation.child_timeout_seconds': 'サブエージェントタイムアウト',
+ 'delegation.reasoning_effort': 'サブエージェント推論強度',
+ 'updates.non_interactive_local_changes': 'アプリ内更新時のローカル変更'
+ },
+ fieldDescriptions: {
+ model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
+ model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
+ fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
+ 'display.personality': '新しいセッションのデフォルトのアシスタントスタイルです。',
+ timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
+ 'display.show_reasoning': 'バックエンドが推論内容を提供したときに表示します。',
+ 'agent.image_input_mode': '画像添付をモデルへ送る方法を制御します。',
+ 'terminal.cwd': 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
+ 'code_execution.mode': 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。',
+ 'terminal.persistent_shell': 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
+ 'terminal.env_passthrough': 'ツール実行へ渡す環境変数です。',
+ file_read_max_chars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
+ 'approvals.mode': '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
+ 'approvals.timeout': '承認プロンプトがタイムアウトするまで待つ時間です。',
+ 'security.redact_secrets': '検出したシークレットを、可能な限りモデルから見える内容から隠します。',
+ 'checkpoints.enabled': 'ファイル編集前にロールバック用スナップショットを作成します。',
+ 'memory.memory_enabled': '将来のセッションに役立つ永続メモリを保存します。',
+ 'memory.user_profile_enabled': 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。',
+ 'context.engine': '長い会話がコンテキスト上限に近づいたときの管理戦略です。',
+ 'compression.enabled': '会話が大きくなったとき、古いコンテキストを要約します。',
+ 'voice.auto_tts': 'アシスタントの応答を自動で読み上げます。',
+ 'stt.enabled': 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
+ 'stt.elevenlabs.language_code': '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。',
+ 'agent.max_turns': 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。',
+ 'updates.non_interactive_local_changes':
+ 'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
+ },
+ about: {
+ heading: 'Hermes Desktop',
+ version: value => `バージョン ${value}`,
+ versionUnavailable: 'バージョンを取得できません',
+ updates: '更新',
+ checkNow: '今すぐ確認',
+ checking: '確認中…',
+ seeWhatsNew: '新機能を見る',
+ releaseNotes: 'リリースノート',
+ onLatest: '最新バージョンです。',
+ installing: '更新をインストール中です。',
+ cantUpdate: 'このビルドはアプリ内から更新できません。',
+ cantReach: '更新サーバーに接続できませんでした。',
+ tapCheck: '更新を探すには「今すぐ確認」を押してください。',
+ updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`,
+ lastChecked: age => `前回確認: ${age}`,
+ justNowSuffix: ' · たった今',
+ automaticUpdates: '自動更新',
+ automaticUpdatesDesc: 'Hermes はバックグラウンドで自動的に更新を確認し、利用可能になったら通知します。',
+ branchCommit: (branch, commit) => `ブランチ ${branch} · コミット ${commit}`,
+ never: '未確認',
+ justNow: 'たった今',
+ minAgo: count => `${count} 分前`,
+ hoursAgo: count => `${count} 時間前`,
+ daysAgo: count => `${count} 日前`
+ }
+ },
+
+ skills: {
+ all: 'すべて',
+ noDescription: '説明はありません。'
+ },
+
+ profiles: {
+ newProfile: '新しいプロファイル',
+ noProfiles: 'プロファイルが見つかりません。',
+ skills: count => `${count} スキル`,
+ defaultBadge: 'デフォルト',
+ rename: '名前を変更',
+ saveSoul: 'SOUL を保存',
+ cloneFromDefault: 'デフォルトプロファイルから設定を複製',
+ invalidName: hint => `無効なプロファイル名。${hint}`,
+ nameRequired: '名前は必須です',
+ created: '作成しました',
+ renamed: '名前を変更しました',
+ deleted: '削除しました',
+ soulSaved: 'SOUL.md を保存しました'
+ },
+
+ cron: {
+ last: '前回',
+ next: '次回',
+ resume: '再開',
+ pause: '一時停止',
+ triggerNow: '今すぐ実行',
+ namePlaceholder: '例: 日次サマリー',
+ promptPlaceholder: '実行ごとにエージェントが行う内容は?'
+ }
+})
diff --git a/apps/desktop/src/i18n/languages.test.ts b/apps/desktop/src/i18n/languages.test.ts
index c5f8ebc6258..792aad5f586 100644
--- a/apps/desktop/src/i18n/languages.test.ts
+++ b/apps/desktop/src/i18n/languages.test.ts
@@ -1,12 +1,6 @@
import { describe, expect, it } from 'vitest'
-import {
- DEFAULT_LOCALE,
- isLocale,
- isSupportedLocaleValue,
- localeConfigValue,
- normalizeLocale
-} from './languages'
+import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, localeConfigValue, normalizeLocale } from './languages'
describe('desktop i18n languages', () => {
it('normalizes supported locale aliases', () => {
@@ -16,23 +10,34 @@ describe('desktop i18n languages', () => {
expect(normalizeLocale('zh-CN')).toBe('zh')
expect(normalizeLocale('zh-Hans')).toBe('zh')
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
+ expect(normalizeLocale('zh-Hant')).toBe('zh-hant')
+ expect(normalizeLocale('zh-TW')).toBe('zh-hant')
+ expect(normalizeLocale('zh_HK')).toBe('zh-hant')
+ expect(normalizeLocale('ja')).toBe('ja')
+ expect(normalizeLocale('ja-JP')).toBe('ja')
})
it('falls back to English for empty or unsupported values', () => {
expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE)
expect(normalizeLocale('')).toBe(DEFAULT_LOCALE)
- expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE)
+ expect(normalizeLocale('de')).toBe(DEFAULT_LOCALE)
})
it('distinguishes exact locale ids from supported config aliases', () => {
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
- expect(isSupportedLocaleValue('ja')).toBe(false)
+ expect(isSupportedLocaleValue('zh-TW')).toBe(true)
+ expect(isSupportedLocaleValue('ja-JP')).toBe(true)
+ expect(isSupportedLocaleValue('de')).toBe(false)
expect(isLocale('zh-CN')).toBe(false)
expect(isLocale('zh')).toBe(true)
+ expect(isLocale('zh-hant')).toBe(true)
+ expect(isLocale('ja')).toBe(true)
})
it('returns the persisted config value for supported locales', () => {
expect(localeConfigValue('en')).toBe('en')
expect(localeConfigValue('zh')).toBe('zh')
+ expect(localeConfigValue('zh-hant')).toBe('zh-hant')
+ expect(localeConfigValue('ja')).toBe('ja')
})
})
diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts
index 4fdcdf9de0e..2694b3ba5c3 100644
--- a/apps/desktop/src/i18n/languages.ts
+++ b/apps/desktop/src/i18n/languages.ts
@@ -12,6 +12,16 @@ export const LOCALE_OPTIONS = [
id: 'zh',
name: '简体中文',
configValue: 'zh'
+ },
+ {
+ id: 'zh-hant',
+ name: '繁體中文',
+ configValue: 'zh-hant'
+ },
+ {
+ id: 'ja',
+ name: '日本語',
+ configValue: 'ja'
}
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
@@ -32,7 +42,22 @@ const LOCALE_ALIASES: Record = {
'zh-hans': 'zh',
zh_hans: 'zh',
'zh-hans-cn': 'zh',
- zh_hans_cn: 'zh'
+ zh_hans_cn: 'zh',
+ 'zh-tw': 'zh-hant',
+ zh_tw: 'zh-hant',
+ 'zh-hk': 'zh-hant',
+ zh_hk: 'zh-hant',
+ 'zh-mo': 'zh-hant',
+ zh_mo: 'zh-hant',
+ 'zh-hant': 'zh-hant',
+ zh_hant: 'zh-hant',
+ 'zh-hant-tw': 'zh-hant',
+ zh_hant_tw: 'zh-hant',
+ 'zh-hant-hk': 'zh-hant',
+ zh_hant_hk: 'zh-hant',
+ ja: 'ja',
+ 'ja-jp': 'ja',
+ ja_jp: 'ja'
}
export function isLocale(value: unknown): value is Locale {
diff --git a/apps/desktop/src/i18n/runtime.test.ts b/apps/desktop/src/i18n/runtime.test.ts
index a18296f3bfa..5d8eee38f0d 100644
--- a/apps/desktop/src/i18n/runtime.test.ts
+++ b/apps/desktop/src/i18n/runtime.test.ts
@@ -21,6 +21,30 @@ describe('desktop i18n runtime translator', () => {
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
})
+ it('translates migrated overlap keys for newly supported locales', () => {
+ setRuntimeI18nLocale('ja')
+ expect(translateNow('common.save')).toBe('保存')
+
+ setRuntimeI18nLocale('zh-hant')
+ expect(translateNow('cron.promptPlaceholder')).toBe('代理每次執行時應做什麼?')
+ })
+
+ it('translates settings copy for newly supported locales', () => {
+ setRuntimeI18nLocale('ja')
+ expect(translateNow('settings.appearance.title')).toBe('外観')
+ expect(translateNow('settings.nav.providers')).toBe('プロバイダー')
+
+ setRuntimeI18nLocale('zh-hant')
+ expect(translateNow('settings.appearance.title')).toBe('外觀')
+ expect(translateNow('settings.nav.providerApiKeys')).toBe('API 金鑰')
+ })
+
+ it('falls back to English for untranslated desktop-only keys in partial locales', () => {
+ setRuntimeI18nLocale('ja')
+
+ expect(translateNow('boot.ready')).toBe('Hermes Desktop is ready')
+ })
+
it('returns the key when no locale can resolve a path', () => {
setRuntimeI18nLocale('zh')
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 8a1c94b52f3..a52c9d0063b 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1,11 +1,11 @@
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
-// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
-// so a missing key is a compile error — that's the completeness guard for
-// "full" coverage as more surfaces are migrated off hardcoded English.
+// surface. Fully translated locale files may satisfy this interface directly;
+// partial locales should use `defineLocale()` so missing desktop-only strings
+// fall back to English while new keys remain type-checked.
-export type Locale = 'en' | 'zh'
+export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja'
interface ModeOptionCopy {
label: string
@@ -114,6 +114,7 @@ export interface Translations {
description: string
saving: string
saveError: string
+ switchTo: string
}
settings: {
@@ -125,8 +126,13 @@ export interface Translations {
exportFailed: string
resetFailed: string
nav: {
+ providers: string
+ providerAccounts: string
+ providerApiKeys: string
gateway: string
apiKeys: string
+ keysTools: string
+ keysSettings: string
mcp: string
archivedChats: string
about: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
new file mode 100644
index 00000000000..ae62ab94be9
--- /dev/null
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -0,0 +1,236 @@
+import { defineLocale } from './define-locale'
+
+export const zhHant = defineLocale({
+ common: {
+ save: '儲存',
+ saving: '儲存中…',
+ cancel: '取消',
+ close: '關閉',
+ confirm: '確認',
+ delete: '刪除',
+ refresh: '重新整理',
+ retry: '重試',
+ on: '開啟',
+ off: '關閉'
+ },
+
+ language: {
+ label: '語言',
+ description: '選擇桌面介面的語言。',
+ saving: '正在儲存語言…',
+ saveError: '語言更新失敗',
+ switchTo: '切換語言'
+ },
+
+ settings: {
+ closeSettings: '關閉設定',
+ exportConfig: '匯出設定',
+ importConfig: '匯入設定',
+ resetToDefaults: '恢復預設值',
+ resetConfirm: '要將所有設定恢復為 Hermes 預設值嗎?',
+ exportFailed: '匯出失敗',
+ resetFailed: '重設失敗',
+ nav: {
+ providers: '提供方',
+ providerAccounts: '帳號',
+ providerApiKeys: 'API 金鑰',
+ gateway: '閘道',
+ apiKeys: '工具與金鑰',
+ keysTools: '工具',
+ keysSettings: '設定',
+ mcp: 'MCP',
+ archivedChats: '已封存聊天',
+ about: '關於'
+ },
+ sections: {
+ model: '模型',
+ chat: '聊天',
+ appearance: '外觀',
+ workspace: '工作區',
+ safety: '安全性',
+ memory: '記憶與上下文',
+ voice: '語音',
+ advanced: '進階'
+ },
+ searchPlaceholder: {
+ about: '關於 Hermes Desktop',
+ config: '搜尋設定…',
+ gateway: '閘道連線…',
+ keys: '搜尋 API 金鑰…',
+ mcp: '搜尋 MCP 伺服器…',
+ sessions: '搜尋已封存工作階段…'
+ },
+ modeOptions: {
+ light: { label: '明亮', description: '明亮的桌面介面' },
+ dark: { label: '深色', description: '降低眩光的工作區' },
+ system: { label: '跟隨系統', description: '跟隨作業系統外觀' }
+ },
+ appearance: {
+ title: '外觀',
+ intro: '這些是僅限桌面端的顯示偏好。模式控制亮度;主題控制強調色與聊天介面樣式。',
+ colorMode: '色彩模式',
+ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
+ toolViewTitle: '工具呼叫顯示',
+ toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
+ product: '產品',
+ productDesc: '易讀的工具活動與精簡摘要。',
+ technical: '技術',
+ technicalDesc: '包含原始工具參數、結果與底層細節。',
+ themeTitle: '主題',
+ themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
+ },
+ fieldLabels: {
+ model: '預設模型',
+ model_context_length: '上下文視窗',
+ fallback_providers: '備用模型',
+ toolsets: '已啟用工具集',
+ timezone: '時區',
+ 'display.personality': '人格',
+ 'display.show_reasoning': '推理區塊',
+ 'agent.max_turns': '最大代理步數',
+ 'agent.image_input_mode': '圖片附件',
+ 'terminal.cwd': '工作目錄',
+ 'terminal.backend': '執行後端',
+ 'terminal.timeout': '指令逾時',
+ 'terminal.persistent_shell': '持久化 Shell',
+ 'terminal.env_passthrough': '環境變數傳遞',
+ file_read_max_chars: '檔案讀取上限',
+ 'tool_output.max_bytes': '終端機輸出上限',
+ 'tool_output.max_lines': '檔案頁面上限',
+ 'tool_output.max_line_length': '行長上限',
+ 'code_execution.mode': '程式碼執行模式',
+ 'approvals.mode': '批准模式',
+ 'approvals.timeout': '批准逾時',
+ 'approvals.mcp_reload_confirm': '確認 MCP 重新載入',
+ command_allowlist: '指令允許清單',
+ 'security.redact_secrets': '遮蔽密鑰',
+ 'security.allow_private_urls': '允許私有 URL',
+ 'browser.allow_private_urls': '瀏覽器私有 URL',
+ 'browser.auto_local_for_private_urls': '私有 URL 使用本機瀏覽器',
+ 'checkpoints.enabled': '檔案檢查點',
+ 'checkpoints.max_snapshots': '檢查點上限',
+ 'voice.record_key': '語音快捷鍵',
+ 'voice.max_recording_seconds': '最長錄音時間',
+ 'voice.auto_tts': '朗讀回覆',
+ 'stt.enabled': '語音轉文字',
+ 'stt.provider': '語音轉文字提供方',
+ 'stt.local.model': '本機轉寫模型',
+ 'stt.local.language': '轉寫語言',
+ 'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
+ 'stt.elevenlabs.language_code': 'ElevenLabs 語言',
+ 'stt.elevenlabs.tag_audio_events': '標記音訊事件',
+ 'stt.elevenlabs.diarize': '說話者分離',
+ 'tts.provider': '文字轉語音提供方',
+ 'tts.edge.voice': 'Edge 語音',
+ 'tts.openai.model': 'OpenAI TTS 模型',
+ 'tts.openai.voice': 'OpenAI 語音',
+ 'tts.elevenlabs.voice_id': 'ElevenLabs 語音',
+ 'tts.elevenlabs.model_id': 'ElevenLabs 模型',
+ 'memory.memory_enabled': '持久記憶',
+ 'memory.user_profile_enabled': '使用者設定檔',
+ 'memory.memory_char_limit': '記憶預算',
+ 'memory.user_char_limit': '設定檔預算',
+ 'memory.provider': '記憶提供方',
+ 'context.engine': '上下文引擎',
+ 'compression.enabled': '自動壓縮',
+ 'compression.threshold': '壓縮閾值',
+ 'compression.target_ratio': '壓縮目標',
+ 'compression.protect_last_n': '保護最近訊息',
+ 'agent.api_max_retries': 'API 重試次數',
+ 'agent.service_tier': '服務層級',
+ 'agent.tool_use_enforcement': '工具使用強制',
+ 'delegation.model': '子代理模型',
+ 'delegation.provider': '子代理提供方',
+ 'delegation.max_iterations': '子代理輪次上限',
+ 'delegation.max_concurrent_children': '平行子代理',
+ 'delegation.child_timeout_seconds': '子代理逾時',
+ 'delegation.reasoning_effort': '子代理推理強度',
+ 'updates.non_interactive_local_changes': '應用程式內更新的本機變更'
+ },
+ fieldDescriptions: {
+ model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
+ model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
+ fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
+ 'display.personality': '新工作階段的預設助手風格。',
+ timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
+ 'display.show_reasoning': '後端提供推理內容時顯示該區塊。',
+ 'agent.image_input_mode': '控制圖片附件如何傳送給模型。',
+ 'terminal.cwd': '工具與終端機操作的預設專案資料夾。',
+ 'code_execution.mode': '程式碼執行被限制在目前專案中的嚴格程度。',
+ 'terminal.persistent_shell': '後端支援時,在指令之間保留 Shell 狀態。',
+ 'terminal.env_passthrough': '傳入工具執行的環境變數。',
+ file_read_max_chars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
+ 'approvals.mode': 'Hermes 如何處理需要明確批准的指令。',
+ 'approvals.timeout': '批准提示逾時前等待的時間。',
+ 'security.redact_secrets': '盡可能從模型可見內容中隱藏偵測到的密鑰。',
+ 'checkpoints.enabled': '在檔案編輯前建立可回復的快照。',
+ 'memory.memory_enabled': '儲存有助於未來工作階段的持久記憶。',
+ 'memory.user_profile_enabled': '維護一份精簡的使用者偏好設定檔。',
+ 'context.engine': '長對話接近上下文上限時的管理策略。',
+ 'compression.enabled': '對話變大時摘要較早的上下文。',
+ 'voice.auto_tts': '自動朗讀助手回覆。',
+ 'stt.enabled': '啟用本機或提供方支援的語音轉寫。',
+ 'stt.elevenlabs.language_code': '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。',
+ 'agent.max_turns': 'Hermes 停止一次執行前的工具呼叫輪次上限。',
+ 'updates.non_interactive_local_changes':
+ 'Hermes 從應用程式內更新自身時,保留本機原始碼變更(stash)或丟棄(discard)。終端機更新一律會詢問。'
+ },
+ about: {
+ heading: 'Hermes Desktop',
+ version: value => `版本 ${value}`,
+ versionUnavailable: '版本不可用',
+ updates: '更新',
+ checkNow: '立即檢查',
+ checking: '檢查中…',
+ seeWhatsNew: '查看新增內容',
+ releaseNotes: '發行說明',
+ onLatest: '你已是最新版本。',
+ installing: '正在安裝更新。',
+ cantUpdate: '此版本無法從應用程式內自行更新。',
+ cantReach: '無法連線到更新伺服器。',
+ tapCheck: '點選「立即檢查」以尋找更新。',
+ updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`,
+ lastChecked: age => `上次檢查:${age}`,
+ justNowSuffix: ' · 剛剛',
+ automaticUpdates: '自動更新',
+ automaticUpdatesDesc: 'Hermes 會在背景自動檢查更新,並在有可用更新時通知你。',
+ branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
+ never: '從未',
+ justNow: '剛剛',
+ minAgo: count => `${count} 分鐘前`,
+ hoursAgo: count => `${count} 小時前`,
+ daysAgo: count => `${count} 天前`
+ }
+ },
+
+ skills: {
+ all: '全部',
+ noDescription: '無可用描述。'
+ },
+
+ profiles: {
+ newProfile: '新增設定檔',
+ noProfiles: '找不到設定檔。',
+ skills: count => `${count} 個技能`,
+ defaultBadge: '預設',
+ rename: '重新命名',
+ saveSoul: '儲存 SOUL',
+ cloneFromDefault: '從預設設定檔複製設定',
+ invalidName: hint => `設定檔名稱無效。${hint}`,
+ nameRequired: '名稱為必填',
+ created: '已建立',
+ renamed: '已重新命名',
+ deleted: '已刪除',
+ soulSaved: 'SOUL.md 已儲存'
+ },
+
+ cron: {
+ last: '上次',
+ next: '下次',
+ resume: '繼續',
+ pause: '暫停',
+ triggerNow: '立即觸發',
+ namePlaceholder: '例如:每日摘要',
+ promptPlaceholder: '代理每次執行時應做什麼?'
+ }
+})
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 3df3efadef4..887ce38682f 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -101,7 +101,8 @@ export const zh: Translations = {
label: '语言',
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
- saveError: '语言更新失败'
+ saveError: '语言更新失败',
+ switchTo: '切换语言'
},
settings: {
@@ -113,8 +114,13 @@ export const zh: Translations = {
exportFailed: '导出失败',
resetFailed: '重置失败',
nav: {
+ providers: '提供方',
+ providerAccounts: '账号',
+ providerApiKeys: 'API 密钥',
gateway: '网关',
apiKeys: '工具与密钥',
+ keysTools: '工具',
+ keysSettings: '设置',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
@@ -478,8 +484,7 @@ export const zh: Translations = {
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
- discord:
- '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
+ discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
@@ -495,7 +500,8 @@ export const zh: Translations = {
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
- api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
+ api_server:
+ '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},
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 4/9] 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 f9d3fc370af..ff01687aacc 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 e19128add30..0cea51661c8 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 e48ff7accff..ebfd4e58b6f 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 70a973322b1..67df7fefc2f 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 86b8acf9c39..e3ebc4f2285 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 0c8a5bb2962..21cfbeb3ced 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 b6825ff6f05..dec0e36f47b 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 7e624d59d81..fbd089a9ffb 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 7fd015efece..236aac244bd 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 261bf24e3d4..2b5c4da4946 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 6fb9ab38c3d..8e429c3884a 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 1fc34725e3d..cd9b3fa0d5f 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 a0406203d6d..2d0e209f680 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 e0bf820266b..3fbd0aaced0 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 6f6da603e9b..6421581ca8c 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 2e025c4cc17..d5707873c3c 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 ab301e59308..e10f34e929c 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 3d1348434a2..1a04b19da76 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 b9668413445..e31a0ce07ef 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 b0a4c7efc1c..ca39d778537 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 8645162b780..0c12d551184 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 99efb342589..6fb8ad9e97d 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 8003b348759..614fdcf34ea 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 f0ea858ad14..5442ae5dd86 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 709d3aee917..31da15a05e7 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 00000000000..9077f6d6852
--- /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 7c6368e97fd..7e9f9be5953 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 ff793e4a00c..e607c165bcd 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 89545acc4f4..3f69c0166b2 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 7ff9a7c5e92..d342428ed65 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 1beeca62ee5..642fba5bf85 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 413ebd2827f..f95ad1977aa 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 060088ba160..e37c9d7896a 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 d766f926756..aeff184e39d 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 9d37cf30ae8..326f66f3f4b 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 a1331bd5150..c700cb51019 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 610d23f9d33..6872cca7f5a 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 d22869487cf..d66761d0b82 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 0a54d51c32f..a939a180519 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 014214b9eb9..5f0c350e108 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 cad01efe6ee..6663bbb5975 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 a38ebce4f34..a815b61f754 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 ad3a1cdbe33..068573131ae 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 c5b4d7a789f..0767e06debe 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 ff0a4652fc0..e120e6f6fda 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 6bb9983e0ea..972c3aaf961 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 cc4c8ef2d48..b85d1b8b057 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 e53fd9da4bd..4993b993bf6 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 bc4882d07d3..e160258cb8e 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 66e086fb1c5..e03f33b0780 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 08f4da29dce..de9cfe560f9 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 e78b70a4a86..87b6b7743c5 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 6678dd5288c..c19443ffabd 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 12230533b29..47757e2874e 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 551bbf047ea..1782a59ffc0 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 e67bbb7686b..ddb4f17950d 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 099bef305db..10bc68bcab0 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 f8be0007820..b635595fc95 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 90d8d7c74bc..88b131fdd09 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 bb935478349..c6c3d5e97a0 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 22f6b6b5a90..17c42e2d365 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 b5a3708b190..3c93dd39555 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 5d8eee38f0d..62ca8610ef7 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 a52c9d0063b..6e7da1cbddc 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 ae62ab94be9..fbfc0124312 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 887ce38682f..3cdc6a01870 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 677ed783d11..b32a705b7eb 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
Date: Fri, 5 Jun 2026 22:12:31 -0500
Subject: [PATCH 5/9] Add searchable language picker
---
.../src/components/language-switcher.test.tsx | 14 ++
.../src/components/language-switcher.tsx | 128 +++++++++++-------
apps/desktop/src/i18n/en.ts | 4 +-
apps/desktop/src/i18n/ja.ts | 4 +-
apps/desktop/src/i18n/languages.ts | 19 ++-
apps/desktop/src/i18n/types.ts | 2 +
apps/desktop/src/i18n/zh-hant.ts | 4 +-
apps/desktop/src/i18n/zh.ts | 4 +-
8 files changed, 119 insertions(+), 60 deletions(-)
diff --git a/apps/desktop/src/components/language-switcher.test.tsx b/apps/desktop/src/components/language-switcher.test.tsx
index 77614af22e5..3792012171a 100644
--- a/apps/desktop/src/components/language-switcher.test.tsx
+++ b/apps/desktop/src/components/language-switcher.test.tsx
@@ -6,6 +6,19 @@ import { type I18nConfigClient, I18nProvider } from '@/i18n'
import { LanguageSwitcher } from './language-switcher'
+// cmdk (the searchable list) wires a ResizeObserver and scrolls the active
+// item into view — neither exists in jsdom. Stub them, matching the polyfill
+// idiom in tool-approval-group.test.tsx.
+class TestResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+vi.stubGlobal('ResizeObserver', TestResizeObserver)
+
+Element.prototype.scrollIntoView = function scrollIntoView() {}
+
describe('LanguageSwitcher', () => {
afterEach(() => {
cleanup()
@@ -15,6 +28,7 @@ describe('LanguageSwitcher', () => {
it('persists language changes through display.language config', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } }
+
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue(latestConfig),
saveConfig
diff --git a/apps/desktop/src/components/language-switcher.tsx b/apps/desktop/src/components/language-switcher.tsx
index 59edb622ef1..a95c361d485 100644
--- a/apps/desktop/src/components/language-switcher.tsx
+++ b/apps/desktop/src/components/language-switcher.tsx
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
+import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { ScrollArea } from '@/components/ui/scroll-area'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { useIsMobile } from '@/hooks/use-mobile'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
@@ -17,12 +17,14 @@ export interface LanguageSwitcherProps {
dropUp?: boolean
}
-interface LanguageSwitcherOptionsProps {
+interface LanguageCommandProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
+ autoFocus?: boolean
disabled?: boolean
- label: string
locale: Locale
+ noResults: string
onSelect: (code: Locale) => void
+ searchPlaceholder: string
}
export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
@@ -37,6 +39,7 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
setOpen(false)
+
return
}
@@ -53,8 +56,8 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
const trigger = (
- {!collapsed && {locale === 'en' ? 'EN' : current.name} }
+ {!collapsed && {current.name} }
{!collapsed && }
@@ -83,15 +86,14 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
{title}
{t.language.description}
-
- void selectLocale(code)}
- />
-
+ void selectLocale(code)}
+ searchPlaceholder={t.language.searchPlaceholder}
+ />
)
@@ -100,46 +102,74 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
return (
{trigger}
-
-
- void selectLocale(code)}
- />
-
+
+ void selectLocale(code)}
+ searchPlaceholder={t.language.searchPlaceholder}
+ />
)
}
-function LanguageSwitcherOptions({ allLocales, disabled, label, locale, onSelect }: LanguageSwitcherOptionsProps) {
- return (
-
- {allLocales.map(([code, meta]) => {
- const selected = code === locale
+function LanguageCommand({
+ allLocales,
+ autoFocus,
+ disabled,
+ locale,
+ noResults,
+ onSelect,
+ searchPlaceholder
+}: LanguageCommandProps) {
+ const [search, setSearch] = useState('')
- return (
- onSelect(code)}
- role="option"
- type="button"
- >
- {meta.name}
- {code}
- {selected && }
-
- )
- })}
-
+ // Own the search term and filter manually. cmdk's built-in shouldFilter
+ // reorders items by its fuzzy-match score (≈alphabetical with an empty
+ // query), which destroys the curated en→zh→zh-hant→ja order. We disable it
+ // and do a plain substring filter that preserves array order — matching
+ // model-picker.tsx. Match against the endonym, the (hidden) English name,
+ // and the locale code so "日本"/"japanese"/"ja" all find Japanese.
+ const q = search.trim().toLowerCase()
+
+ const filtered = allLocales.filter(
+ ([code, meta]) =>
+ !q ||
+ meta.name.toLowerCase().includes(q) ||
+ meta.englishName.toLowerCase().includes(q) ||
+ code.toLowerCase().includes(q)
+ )
+
+ return (
+
+
+
+ {filtered.length === 0 ? (
+ {noResults}
+ ) : (
+ filtered.map(([code, meta]) => {
+ const selected = code === locale
+
+ return (
+ onSelect(code)}
+ value={code}
+ >
+
+ {meta.name}
+ {code}
+
+ )
+ })
+ )}
+
+
)
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 17c42e2d365..8f99e8cdac5 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -132,7 +132,9 @@ export const en: Translations = {
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
saveError: 'Language update failed',
- switchTo: 'Switch language'
+ switchTo: 'Switch language',
+ searchPlaceholder: 'Search languages…',
+ noResults: 'No languages found'
},
settings: {
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 3c93dd39555..93177c380cf 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -21,7 +21,9 @@ export const ja = defineLocale({
description: 'デスクトップインターフェイスの言語を選択します。',
saving: '言語を保存中…',
saveError: '言語の更新に失敗しました',
- switchTo: '言語を切り替え'
+ switchTo: '言語を切り替え',
+ searchPlaceholder: '言語を検索…',
+ noResults: '言語が見つかりません'
},
settings: {
diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts
index 2694b3ba5c3..5b4990f4970 100644
--- a/apps/desktop/src/i18n/languages.ts
+++ b/apps/desktop/src/i18n/languages.ts
@@ -6,31 +6,36 @@ export const LOCALE_OPTIONS = [
{
id: 'en',
name: 'English',
+ englishName: 'English',
configValue: 'en'
},
{
id: 'zh',
name: '简体中文',
+ englishName: 'Simplified Chinese',
configValue: 'zh'
},
{
id: 'zh-hant',
name: '繁體中文',
+ englishName: 'Traditional Chinese',
configValue: 'zh-hant'
},
{
id: 'ja',
name: '日本語',
+ englishName: 'Japanese',
configValue: 'ja'
}
-] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
+] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[]
-// Endonyms (native names) for the language picker so users recognize their
-// language regardless of the current UI language. No country flags:
-// languages are not countries.
-export const LOCALE_META: Record = Object.fromEntries(
- LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
-) as Record
+// `name` is the endonym (native name) shown in the picker so users recognize
+// their language regardless of the current UI language. No country flags:
+// languages are not countries. `englishName` is search-only (not shown) so an
+// English speaker can type "japanese"/"traditional" to filter the list.
+export const LOCALE_META: Record = Object.fromEntries(
+ LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name, englishName: locale.englishName }])
+) as Record
const LOCALE_ALIASES: Record = {
en: 'en',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 6e7da1cbddc..7e794fc900d 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -144,6 +144,8 @@ export interface Translations {
saving: string
saveError: string
switchTo: string
+ searchPlaceholder: string
+ noResults: string
}
settings: {
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index fbfc0124312..d6d1b8c8b12 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -21,7 +21,9 @@ export const zhHant = defineLocale({
description: '選擇桌面介面的語言。',
saving: '正在儲存語言…',
saveError: '語言更新失敗',
- switchTo: '切換語言'
+ switchTo: '切換語言',
+ searchPlaceholder: '搜尋語言…',
+ noResults: '找不到語言'
},
settings: {
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 3cdc6a01870..f9546491c1e 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -128,7 +128,9 @@ export const zh: Translations = {
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
saveError: '语言更新失败',
- switchTo: '切换语言'
+ switchTo: '切换语言',
+ searchPlaceholder: '搜索语言…',
+ noResults: '未找到语言'
},
settings: {
From fbd423b94d50e14de83196be040f63a591f570eb 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 22:43:16 -0500
Subject: [PATCH 6/9] feat(desktop): localize desktop chrome
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
---
.../chat/composer/hooks/use-mic-recorder.ts | 34 +++++++----
.../composer/hooks/use-voice-conversation.ts | 25 ++++----
.../chat/composer/hooks/use-voice-recorder.ts | 15 +++--
.../chat/composer/trigger-popover.test.tsx | 42 +++++++++++++
.../src/app/chat/composer/trigger-popover.tsx | 10 +++-
.../components/assistant-ui/tool-fallback.tsx | 35 ++++++++---
.../src/components/chat/zoomable-image.tsx | 17 ++++--
.../components/desktop-onboarding-overlay.tsx | 3 +-
.../src/components/ui/copy-button.test.tsx | 36 +++++++++++
.../desktop/src/components/ui/copy-button.tsx | 19 +++---
apps/desktop/src/i18n/en.ts | 34 ++++++++++-
apps/desktop/src/i18n/runtime.test.ts | 5 +-
apps/desktop/src/i18n/types.ts | 32 ++++++++++
apps/desktop/src/i18n/zh.ts | 60 ++++++++++++++-----
14 files changed, 297 insertions(+), 70 deletions(-)
create mode 100644 apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
create mode 100644 apps/desktop/src/components/ui/copy-button.test.tsx
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
index df74c7f4a5c..8823084a36e 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
@@ -17,39 +17,49 @@ export interface MicRecording {
heardSpeech: boolean
}
+export interface MicRecorderErrorCopy {
+ microphoneAccessDenied: string
+ microphoneConstraintsUnsupported: string
+ microphoneInUse: string
+ microphonePermissionDenied: string
+ microphoneStartFailed: string
+ microphoneUnsupported: string
+ noMicrophone: string
+}
+
interface MicRecorderHandle {
start: (options?: MicRecorderOptions) => Promise
stop: () => Promise
cancel: () => void
}
-function micError(error: unknown): Error {
+function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
const name = error instanceof DOMException ? error.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
- return new Error('Microphone permission was denied.')
+ return new Error(copy.microphonePermissionDenied)
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
- return new Error('No microphone was found.')
+ return new Error(copy.noMicrophone)
}
if (name === 'NotReadableError' || name === 'TrackStartError') {
- return new Error('Microphone is already in use by another app.')
+ return new Error(copy.microphoneInUse)
}
if (name === 'OverconstrainedError') {
- return new Error('Microphone constraints are not supported by this device.')
+ return new Error(copy.microphoneConstraintsUnsupported)
}
if (error instanceof Error) {
return error
}
- return new Error('Could not start microphone recording.')
+ return new Error(copy.microphoneStartFailed)
}
-export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } {
+export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
@@ -158,13 +168,13 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
- throw new Error('This runtime does not support microphone recording.')
+ throw new Error(copy.microphoneUnsupported)
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
if (permitted === false) {
- throw new Error('Microphone access denied.')
+ throw new Error(copy.microphoneAccessDenied)
}
let stream: MediaStream
@@ -174,7 +184,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
audio: { echoCancellation: true, noiseSuppression: true }
})
} catch (error) {
- throw micError(error)
+ throw micError(error, copy)
}
const mimeType =
@@ -188,7 +198,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
} catch (error) {
stream.getTracks().forEach(track => track.stop())
- throw micError(error)
+ throw micError(error, copy)
}
chunksRef.current = []
@@ -231,7 +241,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
}
recorder.onerror = event => {
- const error = micError((event as Event & { error?: unknown }).error)
+ const error = micError((event as Event & { error?: unknown }).error, copy)
const resolver = stopResolverRef.current
stopResolverRef.current = null
cleanup()
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
index 3261acc3409..e4e8f3201be 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { useI18n } from '@/i18n'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
@@ -32,7 +33,9 @@ export function useVoiceConversation({
pendingResponse,
consumePendingResponse
}: VoiceConversationOptions) {
- const { handle, level } = useMicRecorder()
+ const { t } = useI18n()
+ const voiceCopy = t.notifications.voice
+ const { handle, level } = useMicRecorder(voiceCopy)
const [status, setStatus] = useState('idle')
const [muted, setMuted] = useState(false)
const turnTimeoutRef = useRef(null)
@@ -168,7 +171,7 @@ export function useVoiceConversation({
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
- notifyError(error, 'Voice transcription failed')
+ notifyError(error, voiceCopy.transcriptionFailed)
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
@@ -180,7 +183,7 @@ export function useVoiceConversation({
turnClosingRef.current = false
}
},
- [handle, onSubmit, onTranscribeAudio]
+ [handle, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
const startListening = useCallback(async () => {
@@ -201,7 +204,7 @@ export function useVoiceConversation({
silenceMs: 1_250,
idleSilenceMs: 12_000,
onError: error => {
- notifyError(error, 'Microphone failed')
+ notifyError(error, voiceCopy.microphoneFailed)
pendingStartRef.current = false
onFatalError?.()
},
@@ -210,12 +213,12 @@ export function useVoiceConversation({
setStatus('listening')
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
} catch (error) {
- notifyError(error, 'Could not start voice session')
+ notifyError(error, voiceCopy.couldNotStartSession)
pendingStartRef.current = false
setStatus('idle')
onFatalError?.()
}
- }, [handle, handleTurn, onFatalError])
+ }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(async (text: string) => {
setStatus('speaking')
@@ -223,7 +226,7 @@ export function useVoiceConversation({
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
- notifyError(error, 'Voice playback failed')
+ notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
@@ -232,14 +235,14 @@ export function useVoiceConversation({
setStatus('idle')
}
}
- }, [])
+ }, [voiceCopy.playbackFailed])
const start = useCallback(async () => {
if (!onTranscribeAudio) {
notify({
kind: 'warning',
- title: 'Voice unavailable',
- message: 'Configure speech-to-text to use voice mode.'
+ title: voiceCopy.unavailable,
+ message: voiceCopy.configureSpeechToText
})
onFatalError?.()
@@ -252,7 +255,7 @@ export function useVoiceConversation({
consumePendingResponse()
pendingStartRef.current = true
await startListening()
- }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening])
+ }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
const end = useCallback(async () => {
pendingStartRef.current = false
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts
index cffc2820ca7..937f2d3bc03 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
+import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import type { VoiceActivityState, VoiceStatus } from '../types'
@@ -19,7 +20,9 @@ export function useVoiceRecorder({
focusInput,
onTranscript
}: VoiceRecorderOptions) {
- const { handle, level, recording } = useMicRecorder()
+ const { t } = useI18n()
+ const voiceCopy = t.notifications.voice
+ const { handle, level, recording } = useMicRecorder(voiceCopy)
const [voiceStatus, setVoiceStatus] = useState('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const startedAtRef = useRef(0)
@@ -62,12 +65,12 @@ export function useVoiceRecorder({
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
- notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' })
+ notify({ kind: 'warning', title: voiceCopy.noSpeechDetected, message: voiceCopy.tryRecordingAgain })
} else {
onTranscript(transcript)
}
} catch (error) {
- notifyError(error, 'Voice transcription failed')
+ notifyError(error, voiceCopy.transcriptionFailed)
} finally {
setVoiceStatus('idle')
focusInput()
@@ -76,13 +79,13 @@ export function useVoiceRecorder({
const start = async () => {
if (!onTranscribeAudio) {
- notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' })
+ notify({ kind: 'warning', title: voiceCopy.unavailable, message: voiceCopy.transcriptionUnavailable })
return
}
try {
- await handle.start({ onError: error => notifyError(error, 'Voice recording failed') })
+ await handle.start({ onError: error => notifyError(error, voiceCopy.recordingFailed) })
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
@@ -91,7 +94,7 @@ export function useVoiceRecorder({
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {
setVoiceStatus('idle')
- notifyError(error, 'Voice recording failed')
+ notifyError(error, voiceCopy.recordingFailed)
}
}
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
new file mode 100644
index 00000000000..9acc43f7f19
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
@@ -0,0 +1,42 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { I18nProvider } from '@/i18n'
+
+import { ComposerTriggerPopover } from './trigger-popover'
+
+function renderPopover(kind: '@' | '/', loading = false) {
+ const onHover = vi.fn()
+ const onPick = vi.fn()
+
+ const rendered = render(
+
+
+
+ )
+
+ return { ...rendered, onHover, onPick }
+}
+
+describe('ComposerTriggerPopover i18n', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('renders localized empty lookup copy for @ references', () => {
+ const { container } = renderPopover('@')
+
+ expect(screen.getByText('没有匹配项。')).toBeTruthy()
+ expect(container.textContent).toContain('试试')
+ expect(container.textContent).toContain('@file:')
+ expect(container.textContent).toContain('或')
+ expect(container.textContent).toContain('@folder:')
+ })
+
+ it('renders localized loading copy for slash commands', () => {
+ const { container } = renderPopover('/', true)
+
+ expect(screen.getByText('查找中…')).toBeTruthy()
+ expect(container.textContent).toContain('/help')
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
index 7cc6a3b2237..a09190dd6b3 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
@@ -1,6 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
@@ -60,6 +61,9 @@ export function ComposerTriggerPopover({
onPick,
placement = 'top'
}: ComposerTriggerPopoverProps) {
+ const { t } = useI18n()
+ const copy = t.composer
+
return (
{items.length === 0 ? (
-
+
{kind === '@' ? (
<>
- Try @file: or{' '}
+ {copy.lookupTry} @file: {copy.lookupOr}{' '}
@folder: .
>
) : (
<>
- Try /help .
+ {copy.lookupTry} /help .
>
)}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index e120e6f6fda..c143aa5576e 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -83,6 +83,13 @@ const TOOL_SECTION_SURFACE_CLASS =
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
+interface ToolStatusCopy {
+ statusDone: string
+ statusError: string
+ statusRecovered: string
+ statusRunning: string
+}
+
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
@@ -102,11 +109,11 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
return parts.join('\n')
}
-function statusGlyph(status: ToolStatus): ReactNode {
+function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
@@ -114,22 +121,32 @@ function statusGlyph(status: ToolStatus): ReactNode {
}
if (status === 'error') {
- return
+ return
}
if (status === 'warning') {
- return
+ return (
+
+ )
}
- return
+ return (
+
+ )
}
// Leading glyph for any tool-row header. Status (running/error/warning)
// takes precedence; otherwise falls back to the tool's codicon. Returns
// null when neither applies so callers can render unconditionally.
-function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
+function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) {
const node = status ? (
- statusGlyph(status)
+ statusGlyph(status, copy)
) : icon ? (
) : null
@@ -296,7 +313,7 @@ function ToolEntry({ part }: ToolEntryProps) {
trailing={trailing}
>
-
+
-
+
{
slot?: string
}
+interface ImageActionCopy {
+ downloadImage: string
+ savingImage: string
+}
+
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const { t } = useI18n()
const copy = t.desktop
@@ -112,7 +117,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
onClick={() => setLightboxOpen(false)}
src={src}
/>
-
+
@@ -128,12 +133,12 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
- title={canOpen ? 'Open image' : undefined}
+ title={canOpen ? copy.openImage : undefined}
type="button"
>
- {src && }
+ {src && }
{lightbox}
>
@@ -141,17 +146,19 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
}
function ImageActionButton({
+ copy,
onClick,
saving,
variant
}: {
+ copy: ImageActionCopy
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index de9cfe560f9..e35eb5829dc 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -116,6 +116,7 @@ export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
+ const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
const boot = useStore($desktopBoot)
const ctxRef = useRef({ requestGateway, onCompleted })
@@ -196,7 +197,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
{onboarding.manual ? (
closeManualOnboarding()}
size="icon-sm"
diff --git a/apps/desktop/src/components/ui/copy-button.test.tsx b/apps/desktop/src/components/ui/copy-button.test.tsx
new file mode 100644
index 00000000000..d0cbb480028
--- /dev/null
+++ b/apps/desktop/src/components/ui/copy-button.test.tsx
@@ -0,0 +1,36 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { I18nProvider } from '@/i18n'
+
+import { CopyButton } from './copy-button'
+
+describe('CopyButton i18n', () => {
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('uses localized default labels and copied feedback', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText }
+ })
+
+ render(
+
+
+
+ )
+
+ const button = screen.getByRole('button', { name: '复制' })
+
+ expect(button.textContent).toContain('复制')
+ fireEvent.click(button)
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith('hello'))
+ await waitFor(() => expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy())
+ expect(screen.getByRole('button', { name: '已复制' }).textContent).toContain('已复制')
+ })
+})
diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx
index 8cdc832e120..18d43103678 100644
--- a/apps/desktop/src/components/ui/copy-button.tsx
+++ b/apps/desktop/src/components/ui/copy-button.tsx
@@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -59,10 +60,10 @@ export function CopyButton({
children,
className,
disabled = false,
- errorMessage = 'Copy failed',
+ errorMessage,
haptic = true,
iconClassName,
- label = 'Copy',
+ label,
onCopied,
onCopyError,
preventDefault = false,
@@ -71,6 +72,9 @@ export function CopyButton({
text,
title
}: CopyButtonProps) {
+ const { t } = useI18n()
+ const resolvedErrorMessage = errorMessage ?? t.common.copyFailed
+ const resolvedLabel = label ?? t.common.copy
const [status, setStatus] = React.useState('idle')
const resetRef = React.useRef(null)
@@ -138,10 +142,10 @@ export function CopyButton({
const visibleChildren =
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
? status === 'copied'
- ? 'Copied'
+ ? t.common.copied
: status === 'error'
- ? 'Failed'
- : (children ?? label)
+ ? t.common.failed
+ : (children ?? resolvedLabel)
: null
const content = (
@@ -151,8 +155,9 @@ export function CopyButton({
>
)
- const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
- const ariaLabel = status === 'idle' ? label : feedbackLabel
+ const feedbackLabel =
+ status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
+ const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item') {
return (
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 8f99e8cdac5..c7ff0385fd3 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -20,10 +20,12 @@ export const en: Translations = {
continue: 'Continue',
copied: 'Copied',
copy: 'Copy',
+ copyFailed: 'Copy failed',
delete: 'Delete',
docs: 'Docs',
done: 'Done',
error: 'Error',
+ failed: 'Failed',
free: 'Free',
loading: 'Loading…',
notSet: 'Not set',
@@ -110,6 +112,25 @@ export const en: Translations = {
openaiRejectedApiKey: 'OpenAI rejected the API key.',
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
+ },
+ voice: {
+ configureSpeechToText: 'Configure speech-to-text to use voice mode.',
+ couldNotStartSession: 'Could not start voice session',
+ microphoneAccessDenied: 'Microphone access denied.',
+ microphoneConstraintsUnsupported: 'Microphone constraints are not supported by this device.',
+ microphoneFailed: 'Microphone failed',
+ microphoneInUse: 'Microphone is already in use by another app.',
+ microphonePermissionDenied: 'Microphone permission was denied.',
+ microphoneStartFailed: 'Could not start microphone recording.',
+ microphoneUnsupported: 'This runtime does not support microphone recording.',
+ noMicrophone: 'No microphone was found.',
+ noSpeechDetected: 'No speech detected',
+ playbackFailed: 'Voice playback failed',
+ recordingFailed: 'Voice recording failed',
+ transcriptionFailed: 'Voice transcription failed',
+ transcriptionUnavailable: 'Voice transcription is not available yet.',
+ tryRecordingAgain: 'Try recording again.',
+ unavailable: 'Voice unavailable'
}
},
@@ -1016,6 +1037,10 @@ export const en: Translations = {
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceDictation: 'Voice dictation',
+ lookupLoading: 'Looking up…',
+ lookupNoMatches: 'No matches.',
+ lookupTry: 'Try',
+ lookupOr: 'or',
commonCommands: 'Common commands',
hotkeys: 'Hotkeys',
helpFooter: 'opens the full panel · backspace dismisses',
@@ -1524,7 +1549,11 @@ export const en: Translations = {
recoveredOne: 'Recovered after 1 failed step',
recoveredMany: count => `Recovered after ${count} failed steps`,
failedOne: '1 step failed',
- failedMany: count => `${count} steps failed`
+ failedMany: count => `${count} steps failed`,
+ statusRunning: 'Running',
+ statusError: 'Error',
+ statusRecovered: 'Recovered',
+ statusDone: 'Done'
}
},
@@ -1587,6 +1616,9 @@ export const en: Translations = {
restartToUseSaveImage: 'Restart Hermes Desktop to use Save Image.',
restartToSaveImages: 'Restart Hermes Desktop to save images',
imageDownloadFailed: 'Image download failed',
+ openImage: 'Open image',
+ downloadImage: 'Download image',
+ savingImage: 'Saving image',
imagePreviewFailed: 'Image preview failed',
imageAttach: 'Image attach',
imageWriteFailed: 'Failed to write image to disk.',
diff --git a/apps/desktop/src/i18n/runtime.test.ts b/apps/desktop/src/i18n/runtime.test.ts
index 62ca8610ef7..0c2bc4870b3 100644
--- a/apps/desktop/src/i18n/runtime.test.ts
+++ b/apps/desktop/src/i18n/runtime.test.ts
@@ -15,7 +15,10 @@ describe('desktop i18n runtime translator', () => {
it('translates string paths for the active runtime locale', () => {
setRuntimeI18nLocale('zh')
- expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
+ expect(translateNow('boot.ready')).toBe('Hermes 桌面版已就绪')
+ expect(translateNow('notifications.voice.noSpeechDetected')).toBe('没有检测到语音')
+ expect(translateNow('composer.lookupNoMatches')).toBe('没有匹配项。')
+ expect(translateNow('assistant.tool.statusRecovered')).toBe('已恢复')
})
it('passes arguments to function translations', () => {
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 7e794fc900d..b41be524a80 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -35,10 +35,12 @@ export interface Translations {
continue: string
copied: string
copy: string
+ copyFailed: string
delete: string
docs: string
done: string
error: string
+ failed: string
free: string
loading: string
notSet: string
@@ -122,6 +124,25 @@ export interface Translations {
openaiRejectedApiKeyWithStatus: (status: string) => string
openaiTtsNeedsKey: string
}
+ voice: {
+ configureSpeechToText: string
+ couldNotStartSession: string
+ microphoneAccessDenied: string
+ microphoneConstraintsUnsupported: string
+ microphoneFailed: string
+ microphoneInUse: string
+ microphonePermissionDenied: string
+ microphoneStartFailed: string
+ microphoneUnsupported: string
+ noMicrophone: string
+ noSpeechDetected: string
+ playbackFailed: string
+ recordingFailed: string
+ transcriptionFailed: string
+ transcriptionUnavailable: string
+ tryRecordingAgain: string
+ unavailable: string
+ }
}
titlebar: {
@@ -831,6 +852,10 @@ export interface Translations {
stopDictation: string
transcribingDictation: string
voiceDictation: string
+ lookupLoading: string
+ lookupNoMatches: string
+ lookupTry: string
+ lookupOr: string
commonCommands: string
hotkeys: string
helpFooter: string
@@ -1270,6 +1295,10 @@ export interface Translations {
recoveredMany: (count: number) => string
failedOne: string
failedMany: (count: number) => string
+ statusRunning: string
+ statusError: string
+ statusRecovered: string
+ statusDone: string
}
}
@@ -1331,6 +1360,9 @@ export interface Translations {
restartToUseSaveImage: string
restartToSaveImages: string
imageDownloadFailed: string
+ openImage: string
+ downloadImage: string
+ savingImage: string
imagePreviewFailed: string
imageAttach: string
imageWriteFailed: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index f9546491c1e..25a794fd874 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -20,10 +20,12 @@ export const zh: Translations = {
continue: '继续',
copied: '已复制',
copy: '复制',
+ copyFailed: '复制失败',
delete: '删除',
docs: '文档',
done: '完成',
error: '错误',
+ failed: '失败',
free: '免费',
loading: '加载中…',
notSet: '未设置',
@@ -41,14 +43,14 @@ export const zh: Translations = {
},
boot: {
- ready: 'Hermes Desktop 已就绪',
- desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
+ ready: 'Hermes 桌面版已就绪',
+ desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
steps: {
- connectingGateway: '正在连接实时桌面网关',
+ connectingGateway: '正在连接桌面网关',
loadingSettings: '正在加载 Hermes 设置',
loadingSessions: '正在加载最近会话',
startingDesktopConnection: '正在启动桌面连接',
- startingHermesDesktop: '正在启动 Hermes Desktop…'
+ startingHermesDesktop: '正在启动 Hermes 桌面版…'
},
errors: {
backgroundExited: 'Hermes 后台进程已退出。',
@@ -60,14 +62,14 @@ export const zh: Translations = {
},
failure: {
title: 'Hermes 无法启动',
- description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
+ description: '后台网关没有启动。请尝试下面的恢复步骤;这里不会删除你的对话或设置。',
remoteTitle: '需要重新登录远程网关',
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
retry: '重试',
repairInstall: '修复安装',
useLocalGateway: '使用本地网关',
openLogs: '打开日志',
- repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。',
+ repairHint: '修复会重新运行安装器,在新机器上可能需要几分钟。',
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
hideRecentLogs: '隐藏最近日志',
showRecentLogs: '显示最近日志',
@@ -106,6 +108,25 @@ export const zh: Translations = {
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
+ },
+ voice: {
+ configureSpeechToText: '配置语音转文字后即可使用语音模式。',
+ couldNotStartSession: '无法启动语音会话',
+ microphoneAccessDenied: '麦克风访问被拒绝。',
+ microphoneConstraintsUnsupported: '此设备不支持当前麦克风约束。',
+ microphoneFailed: '麦克风出错',
+ microphoneInUse: '麦克风正被其他应用占用。',
+ microphonePermissionDenied: '麦克风权限被拒绝。',
+ microphoneStartFailed: '无法开始麦克风录音。',
+ microphoneUnsupported: '当前运行环境不支持麦克风录音。',
+ noMicrophone: '未找到麦克风。',
+ noSpeechDetected: '没有检测到语音',
+ playbackFailed: '语音播放失败',
+ recordingFailed: '语音录制失败',
+ transcriptionFailed: '语音转写失败',
+ transcriptionUnavailable: '语音转写暂不可用。',
+ tryRecordingAgain: '请再录一次。',
+ unavailable: '语音不可用'
}
},
@@ -1166,6 +1187,10 @@ export const zh: Translations = {
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceDictation: '语音听写',
+ lookupLoading: '查找中…',
+ lookupNoMatches: '没有匹配项。',
+ lookupTry: '试试',
+ lookupOr: '或',
commonCommands: '常用命令',
hotkeys: '快捷键',
helpFooter: '打开完整面板 · 退格键关闭',
@@ -1669,7 +1694,11 @@ export const zh: Translations = {
recoveredOne: '在 1 个失败步骤后已恢复',
recoveredMany: count => `在 ${count} 个失败步骤后已恢复`,
failedOne: '1 个步骤失败',
- failedMany: count => `${count} 个步骤失败`
+ failedMany: count => `${count} 个步骤失败`,
+ statusRunning: '运行中',
+ statusError: '错误',
+ statusRecovered: '已恢复',
+ statusDone: '完成'
}
},
@@ -1700,11 +1729,11 @@ export const zh: Translations = {
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 失败',
+ profileStatus: current => `配置档案:${current}。使用 /profile 或“新建会话”选择器在其他配置档案中开始对话。`,
+ unknownProfile: '未知配置档案',
+ noProfileNamed: (target, available) => `没有名为“${target}”的配置档案。可用:${available}`,
+ newChatsProfile: name => `新对话将使用配置档案 ${name}。`,
+ setProfileFailed: '设置配置档案失败',
sttDisabled: '设置中已禁用语音转文字。',
stopFailed: '停止失败',
regenerateFailed: '重新生成失败',
@@ -1728,9 +1757,12 @@ export const zh: Translations = {
sessionExportFailed: '无法导出会话',
imageSaved: '图片已保存',
downloadStarted: '下载已开始',
- restartToUseSaveImage: '重启 Hermes Desktop 后可使用保存图片。',
- restartToSaveImages: '重启 Hermes Desktop 以保存图片',
+ restartToUseSaveImage: '重启 Hermes 桌面版后可使用保存图片。',
+ restartToSaveImages: '重启 Hermes 桌面版以保存图片',
imageDownloadFailed: '图片下载失败',
+ openImage: '打开图片',
+ downloadImage: '下载图片',
+ savingImage: '正在保存图片',
imagePreviewFailed: '图片预览失败',
imageAttach: '附加图片',
imageWriteFailed: '无法将图片写入磁盘。',
From 112a0732c6a7a80f1acc38ede4b38abfbf0e59b0 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 23:16:10 -0500
Subject: [PATCH 7/9] Translate missing desktop i18n strings for ja and zh-hant
---
apps/desktop/src/i18n/ja.ts | 1547 +++++++++++++++++++++++++++++-
apps/desktop/src/i18n/zh-hant.ts | 1513 ++++++++++++++++++++++++++++-
2 files changed, 3051 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 93177c380cf..afd0008a904 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -4,18 +4,151 @@ import { defineLocale } from './define-locale'
export const ja = defineLocale({
common: {
+ apply: '適用',
+ back: '戻る',
save: '保存',
saving: '保存中…',
cancel: 'キャンセル',
+ change: '変更',
+ choose: '選択',
+ clear: 'クリア',
close: '閉じる',
+ collapse: '折りたたむ',
confirm: '確認',
+ connect: '接続',
+ connecting: '接続中',
+ continue: '続ける',
+ copied: 'コピーしました',
+ copy: 'コピー',
+ copyFailed: 'コピーに失敗しました',
delete: '削除',
+ docs: 'ドキュメント',
+ done: '完了',
+ error: 'エラー',
+ failed: '失敗',
+ free: '無料',
+ loading: '読み込み中…',
+ notSet: '未設定',
refresh: '更新',
+ remove: '削除',
+ replace: '置き換え',
retry: '再試行',
+ run: '実行',
+ send: '送信',
+ set: '設定',
+ skip: 'スキップ',
+ update: '更新',
on: 'オン',
off: 'オフ'
},
+ boot: {
+ ready: 'Hermes Desktop の準備ができました',
+ desktopBootFailedWithMessage: message => `デスクトップの起動に失敗しました: ${message}`,
+ steps: {
+ connectingGateway: 'ライブデスクトップゲートウェイに接続中',
+ loadingSettings: 'Hermes の設定を読み込み中',
+ loadingSessions: '最近のセッションを読み込み中',
+ startingDesktopConnection: 'デスクトップ接続を開始中',
+ startingHermesDesktop: 'Hermes Desktop を起動中…'
+ },
+ errors: {
+ backgroundExited: 'Hermes バックグラウンドプロセスが終了しました。',
+ backgroundExitedDuringStartup: '起動中に Hermes バックグラウンドプロセスが終了しました。',
+ backendStopped: 'バックエンドが停止しました',
+ desktopBootFailed: 'デスクトップの起動に失敗しました',
+ gatewaySignInRequired: 'ゲートウェイへのサインインが必要です',
+ ipcBridgeUnavailable: 'デスクトップ IPC ブリッジが利用できません。'
+ },
+ failure: {
+ title: 'Hermes を起動できませんでした',
+ description:
+ 'バックグラウンドゲートウェイが起動しませんでした。以下の回復手順をお試しください。チャットや設定は削除されません。',
+ remoteTitle: 'リモートゲートウェイへのサインインが必要です',
+ remoteDescription:
+ 'リモートゲートウェイのセッションが期限切れです。再接続するにはもう一度サインインしてください。チャットや設定は削除されません。',
+ retry: '再試行',
+ repairInstall: 'インストールを修復',
+ useLocalGateway: 'ローカルゲートウェイを使用',
+ openLogs: 'ログを開く',
+ repairHint: '修復はインストーラーを再実行します。新しいマシンでは数分かかる場合があります。',
+ remoteSignInHint:
+ 'ゲートウェイのログインウィンドウを開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。',
+ hideRecentLogs: '最近のログを非表示',
+ showRecentLogs: '最近のログを表示',
+ signedInTitle: 'サインインしました',
+ signedInMessage: 'リモートゲートウェイに再接続中…',
+ signInIncompleteTitle: 'サインインが完了していません',
+ signInIncompleteMessage: '認証が完了する前にログインウィンドウが閉じられました。',
+ signInFailed: 'サインインに失敗しました',
+ signInToRemoteGateway: 'リモートゲートウェイにサインイン',
+ signInWithProvider: provider => `${provider} でサインイン`,
+ identityProvider: 'ID プロバイダー'
+ }
+ },
+
+ notifications: {
+ region: '通知',
+ hide: '非表示',
+ show: '表示',
+ more: count => `他 ${count} 件の通知`,
+ clearAll: 'すべてクリア',
+ dismiss: '通知を閉じる',
+ details: '詳細',
+ copyDetail: '詳細をコピー',
+ copyDetailFailed: '通知の詳細をコピーできませんでした',
+ backendOutOfDateTitle: 'バックエンドが古いです',
+ backendOutOfDateMessage:
+ 'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。',
+ updateHermes: 'Hermes を更新',
+ updateReadyTitle: '更新の準備ができました',
+ updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
+ seeWhatsNew: '新機能を見る',
+ errors: {
+ elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
+ elevenLabsRejectedKey: 'ElevenLabs が API キーを拒否しました (401)。',
+ methodNotAllowed:
+ 'デスクトップバックエンドがそのリクエストを拒否しました (405 Method Not Allowed)。Hermes Desktop を再起動してください。',
+ microphonePermission: 'マイクのアクセス許可が拒否されました。',
+ openaiRejectedApiKey: 'OpenAI が API キーを拒否しました。',
+ openaiRejectedApiKeyWithStatus: status => `OpenAI が API キーを拒否しました (${status} invalid_api_key)。`,
+ openaiTtsNeedsKey: 'OpenAI TTS には VOICE_TOOLS_OPENAI_KEY または OPENAI_API_KEY が必要です。'
+ },
+ voice: {
+ configureSpeechToText: '音声モードを使用するには音声認識を設定してください。',
+ couldNotStartSession: '音声セッションを開始できませんでした',
+ microphoneAccessDenied: 'マイクへのアクセスが拒否されました。',
+ microphoneConstraintsUnsupported: 'このデバイスはマイクの制約をサポートしていません。',
+ microphoneFailed: 'マイクが失敗しました',
+ microphoneInUse: 'マイクは他のアプリで使用中です。',
+ microphonePermissionDenied: 'マイクのアクセス許可が拒否されました。',
+ microphoneStartFailed: 'マイクの録音を開始できませんでした。',
+ microphoneUnsupported: 'このランタイムはマイク録音をサポートしていません。',
+ noMicrophone: 'マイクが見つかりませんでした。',
+ noSpeechDetected: '音声が検出されませんでした',
+ playbackFailed: '音声再生に失敗しました',
+ recordingFailed: '音声録音に失敗しました',
+ transcriptionFailed: '音声文字起こしに失敗しました',
+ transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
+ tryRecordingAgain: 'もう一度録音してください。',
+ unavailable: '音声は利用できません'
+ }
+ },
+
+ titlebar: {
+ hideSidebar: 'サイドバーを非表示',
+ showSidebar: 'サイドバーを表示',
+ search: '検索',
+ searchTitle: 'セッション、ビュー、アクションを検索',
+ swapSidebarSides: 'サイドバーの向きを切り替え',
+ swapSidebarSidesTitle: 'セッションとファイルブラウザーの位置を入れ替える',
+ hideRightSidebar: '右サイドバーを非表示',
+ showRightSidebar: '右サイドバーを表示',
+ muteHaptics: '触覚フィードバックをオフ',
+ unmuteHaptics: '触覚フィードバックをオン',
+ openSettings: '設定を開く'
+ },
+
language: {
label: '言語',
description: 'デスクトップインターフェイスの言語を選択します。',
@@ -277,37 +410,1445 @@ export const ja = defineLocale({
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: 'env オーバーライド',
+ intro:
+ 'Hermes Desktop はデフォルトで独自のローカルゲートウェイを起動します。別のマシンや信頼できるプロキシの背後で既に動作している Hermes バックエンドをこのアプリで制御する場合は、リモートゲートウェイを使用してください。以下でプロファイルを選択して、それぞれのリモートホストを設定します。',
+ appliesTo: '適用対象',
+ allProfiles: 'すべてのプロファイル',
+ defaultConnection: '独自のオーバーライドがないすべてのプロファイルのデフォルト接続。',
+ profileConnection: profile =>
+ `"${profile}" がアクティブプロファイルのときのみ使用される接続。ローカルに設定するとデフォルトを継承します。`,
+ envOverrideTitle: '環境変数がこのデスクトップセッションを制御しています。',
+ envOverrideDesc:
+ '保存された設定を使用するには HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN の設定を解除してください。',
+ localTitle: 'ローカルゲートウェイ',
+ localDesc: 'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。',
+ remoteTitle: 'リモートゲートウェイ',
+ remoteDesc:
+ 'このデスクトップシェルをリモートの Hermes バックエンドに接続します。ホスト型ゲートウェイは OAuth またはユーザー名とパスワードを使用します。自己ホスト型はセッショントークンを使用する場合があります。',
+ remoteUrlTitle: 'リモート URL',
+ remoteUrlDesc: 'リモートダッシュボードバックエンドのベース URL。/hermes などのパスプレフィックスもサポートしています。',
+ probing: 'このゲートウェイの認証方法を確認中…',
+ probeError:
+ 'このゲートウェイにまだ到達できません。URL を確認してください。応答後に認証方法が表示されます。',
+ signedIn: 'サインイン済み',
+ signIn: 'サインイン',
+ signOut: 'サインアウト',
+ signInWith: provider => `${provider} でサインイン`,
+ authTitle: '認証',
+ authSignedInPassword:
+ 'このゲートウェイはユーザー名とパスワードを使用します。サインイン済みです。セッションは自動的に更新されます。',
+ authSignedInOauth: 'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。',
+ authNeedsPassword:
+ 'このゲートウェイはユーザー名とパスワードを使用します。このデスクトップアプリを承認するにはサインインしてください。',
+ authNeedsOauth: provider =>
+ `このゲートウェイは OAuth を使用します。このデスクトップアプリを承認するには ${provider} でサインインしてください。`,
+ tokenTitle: 'セッショントークン',
+ tokenDesc:
+ 'REST および WebSocket アクセスに使用するダッシュボードセッショントークン。保存済みトークンを維持するには空欄にしてください。',
+ existingToken: value => `既存のトークン ${value}`,
+ savedToken: '保存済み',
+ pasteSessionToken: 'セッショントークンを貼り付け',
+ testRemote: 'リモートをテスト',
+ saveForRestart: '次回起動時のために保存',
+ saveAndReconnect: '保存して再接続',
+ diagnostics: '診断',
+ diagnosticsDesc:
+ 'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。',
+ openLogs: 'ログを開く',
+ incompleteTitle: 'リモートゲートウェイの設定が不完全です',
+ incompleteSignIn: 'リモートに切り替える前にリモート URL を入力してサインインしてください。',
+ incompleteToken: 'リモートに切り替える前にリモート URL とセッショントークンを入力してください。',
+ incompleteSignInTest: 'テストする前にリモート URL を入力してサインインしてください。',
+ incompleteTokenTest: 'テストする前にリモート URL とセッショントークンを入力してください。',
+ 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: '新しいツールスキーマは新しいターンに適用されます。',
+ reloadFailed: 'MCP の再読み込みに失敗しました',
+ savedTitle: 'MCP サーバーを保存しました',
+ savedMessage: name => `${name} は MCP の再読み込み後に適用されます。`,
+ newServer: '新しいサーバー',
+ reload: 'MCP を再読み込み',
+ reloading: '再読み込み中...',
+ emptyTitle: 'MCP サーバーがありません',
+ emptyDesc: 'MCP ツールを公開するには stdio または HTTP サーバーを追加してください。',
+ 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:
+ '別のフォルダーを選択しない限り、新しいセッションはこのフォルダーで開始します。未設定の場合はホームディレクトリが使用されます。',
+ 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 を実行してください。`
}
},
skills: {
+ tabSkills: 'スキル',
+ tabToolsets: 'ツールセット',
all: 'すべて',
- noDescription: '説明はありません。'
+ searchSkills: 'スキルを検索...',
+ searchToolsets: 'ツールセットを検索...',
+ refresh: 'スキルを更新',
+ refreshing: 'スキルを更新中',
+ loading: '機能を読み込み中...',
+ noSkillsTitle: 'スキルが見つかりません',
+ noSkillsDesc: '検索を広げるか、別のカテゴリーを試してください。',
+ noToolsetsTitle: 'ツールセットが見つかりません',
+ noToolsetsDesc: '検索キーワードを広げてください。',
+ noDescription: '説明はありません。',
+ configured: '設定済み',
+ needsKeys: 'キーが必要',
+ toolsetsEnabled: (enabled, total) => `${enabled}/${total} ツールセットが有効`,
+ configureToolset: label => `${label} を設定`,
+ toggleToolset: label => `${label} ツールセットを切り替え`,
+ skillsLoadFailed: 'スキルの読み込みに失敗しました',
+ toolsetsRefreshFailed: 'ツールセットの更新に失敗しました',
+ skillEnabled: 'スキルを有効にしました',
+ skillDisabled: 'スキルを無効にしました',
+ toolsetEnabled: 'ツールセットを有効にしました',
+ toolsetDisabled: 'ツールセットを無効にしました',
+ appliesToNewSessions: name => `${name} は新しいセッションに適用されます。`,
+ failedToUpdate: name => `${name} の更新に失敗しました`
+ },
+
+ agents: {
+ close: 'エージェントを閉じる',
+ title: 'スポーンツリー',
+ subtitle: '現在のターンのライブサブエージェントのアクティビティ。',
+ emptyTitle: 'ライブサブエージェントはありません',
+ emptyDesc: 'ターンで作業を委任すると、子エージェントの進捗状況がここにストリームされます。',
+ running: '実行中',
+ failed: '失敗',
+ done: '完了',
+ streaming: 'ストリーミング中',
+ files: 'ファイル',
+ moreFiles: count => `+${count} 件のファイル`,
+ delegation: index => `委任 ${index}`,
+ workers: count => `${count} ワーカー`,
+ workersActive: count => `${count} アクティブ`,
+ agentsCount: count => `${count} エージェント`,
+ activeCount: count => `${count} アクティブ`,
+ failedCount: count => `${count} 失敗`,
+ toolsCount: count => `${count} ツール`,
+ filesCount: count => `${count} ファイル`,
+ updatedAgo: age => `${age} に更新`,
+ ageNow: 'たった今',
+ ageSeconds: seconds => `${seconds}秒前`,
+ ageMinutes: minutes => `${minutes}分前`,
+ ageHours: hours => `${hours}時間前`,
+ durationSeconds: seconds => `${seconds}秒`,
+ durationMinutes: (minutes, seconds) => `${minutes}分 ${seconds}秒`,
+ tokensK: k => `${k}k トーク`,
+ tokens: value => `${value} トーク`
+ },
+
+ commandCenter: {
+ close: 'コマンドセンターを閉じる',
+ paletteTitle: 'コマンドパレット',
+ back: '戻る',
+ searchPlaceholder: 'セッション、ビュー、アクションを検索',
+ goTo: '移動',
+ commandCenter: 'コマンドセンター',
+ appearance: '外観',
+ settings: '設定',
+ changeTheme: 'テーマを変更...',
+ changeColorMode: 'カラーモードを変更...',
+ settingsFields: '設定フィールド',
+ mcpServers: 'MCP サーバー',
+ archivedChats: 'アーカイブ済みチャット',
+ sections: { sessions: 'セッション', system: 'システム', usage: '使用状況' },
+ sectionDescriptions: {
+ sessions: 'セッションの検索と管理',
+ system: 'ステータス、ログ、システムアクション',
+ usage: 'トークン、コスト、スキルの活動履歴'
+ },
+ nav: {
+ newChat: { title: '新しいセッション', detail: '新しいセッションを開始' },
+ settings: { title: '設定', detail: 'Hermes デスクトップを設定' },
+ skills: { title: 'スキルとツール', detail: 'スキル、ツールセット、プロバイダーを有効化' },
+ messaging: { title: 'メッセージング', detail: 'Telegram、Slack、Discord などを設定' },
+ artifacts: { title: 'アーティファクト', detail: '生成された出力を閲覧' }
+ },
+ sectionEntries: {
+ sessions: { title: 'セッションパネル', detail: 'セッションの検索、ピン留め、管理' },
+ system: { title: 'システムパネル', detail: 'ゲートウェイのステータス、ログ、再起動/更新' },
+ usage: { title: '使用状況パネル', detail: 'トークン、コスト、スキルの活動' }
+ },
+ providerNavigate: 'ナビゲート',
+ providerSessions: 'セッション',
+ refresh: '更新',
+ refreshing: '更新中...',
+ noResults: '一致する結果が見つかりません。',
+ pinSession: 'セッションをピン留め',
+ unpinSession: 'セッションのピン留めを解除',
+ exportSession: 'セッションをエクスポート',
+ deleteSession: 'セッションを削除',
+ noSessions: 'セッションはまだありません。',
+ gatewayRunning: 'メッセージングゲートウェイが実行中',
+ gatewayStopped: 'メッセージングゲートウェイが停止中',
+ hermesActiveSessions: (version, count) => `Hermes ${version} · アクティブセッション ${count}`,
+ restartMessaging: 'メッセージングを再起動',
+ updateHermes: 'Hermes を更新',
+ actionRunning: '実行中',
+ actionDone: '完了',
+ actionFailed: '失敗',
+ actionStartedWaiting: 'アクションが開始されました。ステータスを待機中...',
+ loadingStatus: 'ステータスを読み込み中...',
+ recentLogs: '最近のログ',
+ noLogs: 'ログはまだ読み込まれていません。',
+ days: count => `${count}日`,
+ statSessions: 'セッション',
+ statApiCalls: 'API コール',
+ statTokens: 'トークン入力/出力',
+ statCost: '推定コスト',
+ actualCost: cost => `実際 ${cost}`,
+ loadingUsage: '使用状況を読み込み中...',
+ noUsage: period => `過去 ${period} 日間に使用履歴がありません。`,
+ retry: '再試行',
+ dailyTokens: '日別トークン',
+ input: '入力',
+ output: '出力',
+ noDailyActivity: '日別アクティビティがありません。',
+ topModels: 'よく使うモデル',
+ noModelUsage: 'モデルの使用履歴はまだありません。',
+ topSkills: 'よく使うスキル',
+ noSkillActivity: 'スキルのアクティビティはまだありません。',
+ actions: count => `${count} アクション`
+ },
+
+ messaging: {
+ search: 'メッセージングを検索...',
+ loading: 'メッセージングプラットフォームを読み込み中...',
+ loadFailed: 'メッセージングプラットフォームの読み込みに失敗しました',
+ states: {
+ connected: '接続済み',
+ connecting: '接続中',
+ disabled: '無効',
+ fatal: 'エラー',
+ gateway_stopped: 'メッセージングゲートウェイが停止中',
+ not_configured: '設定が必要',
+ pending_restart: '再起動が必要',
+ retrying: '再試行中',
+ startup_failed: '起動失敗'
+ },
+ unknown: '不明',
+ hintPendingRestart: 'この変更を適用するにはステータスバーからゲートウェイを再起動してください。',
+ hintGatewayStopped: 'ステータスバーからゲートウェイを起動して接続してください。',
+ credentialsSet: '認証情報を設定しました',
+ needsSetup: '設定が必要',
+ gatewayStopped: 'メッセージングゲートウェイが停止中',
+ getCredentials: '認証情報を取得',
+ openSetupGuide: 'セットアップガイドを開く',
+ required: '必須',
+ recommended: '推奨',
+ advanced: count => `詳細設定 (${count})`,
+ noTokenNeeded:
+ 'このプラットフォームはここでトークンが必要ありません。上のセットアップガイドを使用してから、以下で有効にしてください。',
+ enabled: '有効',
+ disabled: '無効',
+ unsavedChanges: '未保存の変更',
+ saving: '保存中...',
+ saveChanges: '変更を保存',
+ saved: '保存しました',
+ replaceValue: '現在の値を置き換え',
+ openDocs: 'ドキュメントを開く',
+ clearField: key => `${key} をクリア`,
+ enableAria: name => `${name} を有効にする`,
+ disableAria: name => `${name} を無効にする`,
+ platformEnabled: name => `${name} を有効にしました`,
+ platformDisabled: name => `${name} を無効にしました`,
+ restartToApply: 'この変更を有効にするにはゲートウェイを再起動してください。',
+ setupSaved: name => `${name} の設定を保存しました`,
+ restartToReconnect: '新しい認証情報で再接続するにはゲートウェイを再起動してください。',
+ keyCleared: key => `${key} をクリアしました`,
+ setupUpdated: name => `${name} の設定が更新されました。`,
+ failedUpdate: name => `${name} の更新に失敗しました`,
+ failedSave: name => `${name} の保存に失敗しました`,
+ failedClear: key => `${key} のクリアに失敗しました`,
+ fieldCopy: {
+ TELEGRAM_BOT_TOKEN: {
+ label: 'ボットトークン',
+ help: '@BotFather でボットを作成し、表示されたトークンを貼り付けてください。',
+ placeholder: 'Telegram ボットトークンを貼り付け'
+ },
+ TELEGRAM_ALLOWED_USERS: {
+ label: '許可する Telegram ユーザー ID',
+ help: '推奨。@userinfobot の数値 ID をカンマ区切りで。設定しないと誰でもボットに DM できます。'
+ },
+ TELEGRAM_PROXY: { label: 'プロキシ URL', help: 'Telegram がブロックされているネットワークでのみ必要です。' },
+ DISCORD_BOT_TOKEN: {
+ label: 'ボットトークン',
+ help: 'Discord Developer Portal でアプリケーションを作成し、ボットを追加してからトークンを貼り付けてください。'
+ },
+ DISCORD_ALLOWED_USERS: {
+ label: '許可する Discord ユーザー ID',
+ help: '推奨。カンマ区切りの Discord ユーザー ID。'
+ },
+ DISCORD_REPLY_TO_MODE: { label: '返信スタイル', help: 'first、all、または off。' },
+ DISCORD_ALLOW_ALL_USERS: {
+ label: 'すべての Discord ユーザーを許可',
+ help: '開発用のみ。true にすると、許可リストなしで誰でもボットに DM できます。'
+ },
+ DISCORD_HOME_CHANNEL: {
+ label: 'ホームチャンネル ID',
+ help: 'ボットがプロアクティブなメッセージを送信するチャンネル(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 ボットトークン',
+ help: 'Slack アプリをインストール後、OAuth & Permissions のボットトークンを使用してください。',
+ placeholder: 'Slack ボットトークンを貼り付け'
+ },
+ SLACK_APP_TOKEN: {
+ label: 'Slack アプリトークン',
+ help: 'Socket Mode に必要なアプリレベルのトークンを使用してください。',
+ placeholder: 'Slack アプリトークンを貼り付け'
+ },
+ SLACK_ALLOWED_USERS: {
+ label: '許可する Slack ユーザー ID',
+ help: '推奨。カンマ区切りの Slack ユーザー ID。'
+ },
+ MATTERMOST_URL: { label: 'サーバー URL', placeholder: 'https://mattermost.example.com' },
+ MATTERMOST_TOKEN: { label: 'ボットトークン' },
+ MATTERMOST_ALLOWED_USERS: {
+ label: '許可するユーザー ID',
+ help: '推奨。カンマ区切りの Mattermost ユーザー ID。'
+ },
+ MATRIX_HOMESERVER: { label: 'ホームサーバー URL', placeholder: 'https://matrix.org' },
+ MATRIX_ACCESS_TOKEN: { label: 'アクセストークン' },
+ MATRIX_USER_ID: { label: 'ボットユーザー ID', placeholder: '@hermes:example.org' },
+ MATRIX_ALLOWED_USERS: {
+ label: '許可する Matrix ユーザー ID',
+ help: '推奨。@user:server 形式のカンマ区切りユーザー ID。'
+ },
+ 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_MODE: { label: 'ブリッジモード' },
+ WHATSAPP_ALLOWED_USERS: {
+ label: '許可する WhatsApp ユーザー',
+ help: '推奨。カンマ区切りの電話番号または WhatsApp ID。'
+ }
+ },
+ platformIntro: {}
},
profiles: {
+ close: 'プロファイルを閉じる',
+ nameHint: '小文字、数字、ハイフン、アンダースコア。文字または数字で始める必要があります。',
+ title: 'プロファイル',
+ 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: 'プロファイルを更新',
+ refreshing: 'プロファイルを更新中',
+ default: 'デフォルト',
skills: count => `${count} スキル`,
+ env: 'env',
defaultBadge: 'デフォルト',
rename: '名前を変更',
+ copySetup: 'セットアップをコピー',
+ copying: 'コピー中...',
+ modelLabel: 'モデル',
+ skillsLabel: 'スキル',
+ notSet: '未設定',
+ soulDesc: 'このプロファイルに組み込まれたシステムプロンプトとペルソナの指示。',
+ soulOptional: '省略可能',
+ soulPlaceholder: mode => `このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,
+ soulPlaceholderCloned: 'クローン済み',
+ soulPlaceholderEmpty: '空',
+ unsavedChanges: '未保存の変更',
+ loadingSoul: 'SOUL.md を読み込み中...',
+ emptySoul: '空の SOUL.md — ペルソナの記述を始めてください...',
+ saving: '保存中...',
saveSoul: 'SOUL を保存',
+ deleteTitle: 'プロファイルを削除しますか?',
+ deleteDescPrefix: 'これにより ',
+ deleteDescMid: ' が削除され、その ',
+ deleteDescSuffix: ' ディレクトリが削除されます。この操作は元に戻せません。',
+ deleting: '削除中...',
+ createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
+ nameLabel: '名前',
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
+ cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
invalidName: hint => `無効なプロファイル名。${hint}`,
nameRequired: '名前は必須です',
+ creating: '作成中...',
+ createAction: 'プロファイルを作成',
+ renameTitle: 'プロファイルの名前を変更',
+ renameDescPrefix: '名前を変更するとプロファイルディレクトリと ',
+ renameDescSuffix: ' 内のラッパースクリプトが更新されます。',
+ newNameLabel: '新しい名前',
+ renaming: '名前を変更中...',
created: '作成しました',
renamed: '名前を変更しました',
deleted: '削除しました',
- soulSaved: 'SOUL.md を保存しました'
+ setupCopied: 'セットアップコマンドをコピーしました',
+ soulSaved: 'SOUL.md を保存しました',
+ failedLoad: 'プロファイルの読み込みに失敗しました',
+ failedDelete: 'プロファイルの削除に失敗しました',
+ failedCopy: 'セットアップコマンドのコピーに失敗しました',
+ failedLoadSoul: 'SOUL.md の読み込みに失敗しました',
+ failedSaveSoul: 'SOUL.md の保存に失敗しました',
+ failedCreate: 'プロファイルの作成に失敗しました',
+ failedRename: 'プロファイルの名前変更に失敗しました'
},
cron: {
+ close: 'Cron を閉じる',
+ search: 'Cron ジョブを検索...',
+ refresh: 'Cron ジョブを更新',
+ refreshing: 'Cron ジョブを更新中',
+ loading: 'Cron ジョブを読み込み中...',
+ states: {
+ enabled: '有効',
+ scheduled: 'スケジュール済み',
+ running: '実行中',
+ paused: '一時停止中',
+ disabled: '無効',
+ error: 'エラー',
+ completed: '完了'
+ },
+ deliveryLabels: {
+ local: 'このデスクトップ',
+ telegram: 'Telegram',
+ discord: 'Discord',
+ slack: 'Slack',
+ email: 'メール'
+ },
+ scheduleLabels: {
+ daily: '毎日',
+ weekdays: '平日',
+ weekly: '毎週',
+ monthly: '毎月',
+ hourly: '毎時',
+ 'every-15-minutes': '15 分ごと',
+ custom: 'カスタム'
+ },
+ scheduleHints: {
+ daily: '毎日午前 9:00',
+ weekdays: '月曜日から金曜日の午前 9:00',
+ weekly: '毎週月曜日午前 9:00',
+ monthly: '毎月 1 日午前 9:00',
+ hourly: '毎時 0 分',
+ 'every-15-minutes': '15 分ごと',
+ custom: 'Cron 構文または自然言語'
+ },
+ days: {
+ '0': '日曜日',
+ '1': '月曜日',
+ '2': '火曜日',
+ '3': '水曜日',
+ '4': '木曜日',
+ '5': '金曜日',
+ '6': '土曜日',
+ '7': '日曜日'
+ },
+ dayFallback: value => `${value}日`,
+ everyDayAt: time => `毎日 ${time} に`,
+ weekdaysAt: time => `平日 ${time} に`,
+ everyDayOfWeekAt: (day, time) => `毎週 ${day} ${time} に`,
+ monthlyOnDayAt: (dayOfMonth, time) => `毎月 ${dayOfMonth} 日 ${time} に`,
+ topOfHour: '毎時 0 分',
+ everyHourAt: minute => `毎時 :${minute} に`,
+ active: (enabled, total) => `${enabled}/${total} 有効`,
+ newCron: '新しい Cron',
+ createFirst: '最初の Cron を作成',
+ emptyDescNew:
+ 'Cron 式でプロンプトを実行するスケジュールを設定します。Hermes が実行して、選択した宛先に結果を送信します。',
+ emptyDescSearch: '検索キーワードを広げてください。',
+ emptyTitleNew: 'スケジュールされたジョブがまだありません',
+ emptyTitleSearch: '一致なし',
last: '前回',
next: '次回',
+ actionsFor: title => `${title} のアクション`,
+ actionsTitle: 'Cron ジョブのアクション',
resume: '再開',
pause: '一時停止',
+ resumeTitle: '再開',
+ pauseTitle: '一時停止',
triggerNow: '今すぐ実行',
+ edit: 'Cron を編集',
+ deleteTitle: 'Cron ジョブを削除しますか?',
+ deleteDescPrefix: 'これにより ',
+ deleteDescSuffix: ' が完全に削除され、即座に実行が停止されます。',
+ deleting: '削除中...',
+ resumed: 'Cron を再開しました',
+ paused: 'Cron を一時停止しました',
+ triggered: 'Cron をトリガーしました',
+ deleted: 'Cron を削除しました',
+ created: 'Cron を作成しました',
+ updated: 'Cron を更新しました',
+ failedLoad: 'Cron ジョブの読み込みに失敗しました',
+ failedUpdate: 'Cron ジョブの更新に失敗しました',
+ failedTrigger: 'Cron ジョブのトリガーに失敗しました',
+ failedDelete: 'Cron ジョブの削除に失敗しました',
+ failedSave: 'Cron ジョブの保存に失敗しました',
+ editTitle: 'Cron ジョブを編集',
+ createTitle: '新しい Cron ジョブ',
+ editDesc: 'スケジュール、プロンプト、または配信先を更新します。変更は次回の実行時に適用されます。',
+ createDesc:
+ 'プロンプトを自動実行するスケジュールを設定します。Cron 構文または「15 分ごと」などのフレーズを使用します。',
+ nameLabel: '名前',
namePlaceholder: '例: 日次サマリー',
- promptPlaceholder: '実行ごとにエージェントが行う内容は?'
+ promptLabel: 'プロンプト',
+ promptPlaceholder: '実行ごとにエージェントが行う内容は?',
+ frequencyLabel: '頻度',
+ deliverLabel: '配信先',
+ customScheduleLabel: 'カスタムスケジュール',
+ customPlaceholder: '0 9 * * * または weekdays at 9am',
+ customHint: 'Cron 式、または「every hour」「weekdays at 9am」のようなフレーズ。',
+ optional: '省略可能',
+ promptScheduleRequired: 'プロンプトとスケジュールは必須です。',
+ saveChanges: '変更を保存',
+ createAction: 'Cron を作成'
+ },
+
+ artifacts: {
+ search: 'アーティファクトを検索...',
+ refresh: 'アーティファクトを更新',
+ refreshing: 'アーティファクトを更新中',
+ indexing: '最近のセッションのアーティファクトをインデックス中',
+ tabAll: 'すべて',
+ tabImages: '画像',
+ tabFiles: 'ファイル',
+ tabLinks: 'リンク',
+ noArtifactsTitle: 'アーティファクトが見つかりません',
+ noArtifactsDesc: 'セッションで生成された画像やファイルの出力がここに表示されます。',
+ failedLoad: 'アーティファクトの読み込みに失敗しました',
+ openFailed: '開くことができませんでした',
+ itemsImage: '画像',
+ itemsLink: 'リンク',
+ itemsFile: 'ファイル',
+ itemsGeneric: '項目',
+ zero: '0',
+ rangeOf: (start, end, total) => `${total} 件中 ${start}-${end}`,
+ goToPage: (itemLabel, page) => `${itemLabel} ページ ${page} に移動`,
+ colTitleLink: 'リンクタイトル',
+ colTitleFile: '名前',
+ colTitleDefault: 'タイトル / 名前',
+ colLocationLink: 'URL',
+ colLocationFile: 'パス',
+ colLocationDefault: '場所',
+ colSession: 'セッション',
+ kindImage: '画像',
+ kindFile: 'ファイル',
+ kindLink: 'リンク',
+ chat: 'チャット',
+ copyUrl: 'URL をコピー',
+ copyPath: 'パスをコピー'
+ },
+
+ sidebar: {
+ nav: {
+ 'new-session': '新しいセッション',
+ skills: 'スキルとツール',
+ messaging: 'メッセージング',
+ artifacts: 'アーティファクト'
+ },
+ searchAria: 'セッションを検索',
+ searchPlaceholder: 'セッションを検索…',
+ clearSearch: '検索をクリア',
+ noMatch: query => `"${query}" に一致するセッションがありません。`,
+ results: '結果',
+ pinned: 'ピン留め',
+ sessions: 'セッション',
+ groupAriaGrouped: 'セッションを単一リストとして表示',
+ groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化',
+ groupTitleGrouped: 'セッションのグループ化を解除',
+ groupTitleUngrouped: 'ワークスペースでグループ化',
+ allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
+ shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え',
+ noWorkspace: 'ワークスペースなし',
+ newSessionIn: label => `${label} で新しいセッション`,
+ reorderWorkspace: label => `ワークスペース ${label} を並べ替え`,
+ showMoreIn: (count, label) => `${label} でさらに ${count} 件を表示`,
+ loading: '読み込み中…',
+ loadMore: 'さらに読み込む',
+ loadCount: step => `さらに ${step} 件を読み込む`,
+ row: {
+ pin: 'ピン留め',
+ unpin: 'ピン留めを解除',
+ copyId: 'ID をコピー',
+ export: 'エクスポート',
+ rename: '名前を変更',
+ archive: 'アーカイブ',
+ copyIdFailed: 'セッション ID をコピーできませんでした',
+ actionsFor: title => `${title} のアクション`,
+ sessionActions: 'セッションアクション',
+ sessionRunning: 'セッション実行中',
+ needsInput: '入力が必要です',
+ waitingForAnswer: '回答を待っています',
+ renamed: '名前を変更しました',
+ renameFailed: '名前の変更に失敗しました',
+ renameTitle: 'セッションの名前を変更',
+ renameDesc: 'このチャットにわかりやすいタイトルをつけてください。空欄にするとクリアされます。',
+ untitledPlaceholder: '無題のセッション',
+ ageNow: 'たった今',
+ ageDay: '日',
+ ageHour: '時間',
+ ageMin: '分'
+ }
+ },
+
+ composer: {
+ message: 'メッセージ',
+ wakingProfile: profile => `${profile} を起動中…`,
+ placeholderStarting: 'Hermes を起動中...',
+ placeholderReconnecting: 'Hermes に再接続中…',
+ placeholderFollowUp: 'フォローアップを送信',
+ newSessionPlaceholders: [
+ '何を作りますか?',
+ 'Hermes にタスクを与える',
+ '何か考えていることはありますか?',
+ '必要なことを説明してください',
+ '何に取り組みますか?',
+ '何でも聞いてください',
+ '目標から始める'
+ ],
+ followUpPlaceholders: [
+ 'フォローアップを送信',
+ 'さらにコンテキストを追加',
+ 'リクエストを改善',
+ '次は何ですか?',
+ '続けましょう',
+ 'さらに進める',
+ '調整または続行'
+ ],
+ startVoice: '音声会話を開始',
+ queueMessage: 'メッセージをキューに入れる',
+ stop: '停止',
+ send: '送信',
+ speaking: '話しています',
+ transcribing: '文字起こし中',
+ thinking: '考え中',
+ muted: 'ミュート',
+ listening: '聴いています',
+ muteMic: 'マイクをミュート',
+ unmuteMic: 'マイクのミュートを解除',
+ stopListening: '聴き取りを停止して送信',
+ stopShort: '停止',
+ endConversation: '音声会話を終了',
+ endShort: '終了',
+ stopDictation: '口述を停止',
+ transcribingDictation: '口述を文字起こし中',
+ voiceDictation: '音声口述',
+ lookupLoading: '検索中…',
+ lookupNoMatches: '一致なし。',
+ lookupTry: '試す',
+ lookupOr: 'または',
+ commonCommands: '一般的なコマンド',
+ hotkeys: 'ホットキー',
+ helpFooter: 'フルパネルを開く · Backspace で閉じる',
+ commandDescs: {
+ '/help': 'コマンドとホットキーの全リスト',
+ '/clear': '新しいセッションを開始',
+ '/resume': '以前のセッションを再開',
+ '/details': 'トランスクリプトの詳細レベルを制御',
+ '/copy': '選択または最後のアシスタントメッセージをコピー',
+ '/quit': 'hermes を終了'
+ },
+ hotkeyDescs: {
+ '@': 'ファイル、フォルダー、URL、Git を参照',
+ '/': 'スラッシュコマンドパレット',
+ '?': 'クイックヘルプ(削除で閉じる)',
+ Enter: '送信 · 改行は Shift+Enter',
+ 'Cmd/Ctrl+K': '次のキュー済みターンを送信',
+ 'Cmd/Ctrl+L': '再描画',
+ Esc: 'ポップオーバーを閉じる · 実行をキャンセル',
+ '↑ / ↓': 'ポップオーバー / 履歴を切り替え'
+ },
+ attachUrlTitle: 'URL を添付',
+ attachUrlDesc: 'Hermes がページを取得し、このターンのコンテキストとして含めます。',
+ urlPlaceholder: 'https://example.com/post',
+ urlHintPre: '完全な URL を入力してください。例: ',
+ attach: '添付',
+ queued: count => `${count} 件キュー済み`,
+ attachmentOnly: '添付のみのターン',
+ emptyTurn: '空のターン',
+ attachments: count => `${count} 件の添付`,
+ editingInComposer: 'コンポーザーで編集中',
+ editingQueuedInComposer: 'コンポーザーでキュー済みターンを編集中',
+ editQueued: 'キュー済みターンを編集',
+ sendQueuedNow: 'キュー済みターンを今すぐ送信',
+ deleteQueued: 'キュー済みターンを削除',
+ previewUnavailable: 'プレビューは利用できません',
+ previewLabel: label => `${label} のプレビュー`,
+ couldNotPreview: label => `${label} をプレビューできませんでした`,
+ removeAttachment: label => `${label} を削除`,
+ dictating: '口述中',
+ preparingAudio: '音声を準備中',
+ speakingResponse: '応答を読み上げ中',
+ readingAloud: '読み上げ中',
+ themeSuggestions: 'デスクトップテーマの候補',
+ noMatchingThemes: '一致するテーマがありません。',
+ themeTryPre: '試してみる: ',
+ themeTryPost: '。',
+ attachLabel: '添付',
+ files: 'ファイル…',
+ folder: 'フォルダー…',
+ images: '画像…',
+ pasteImage: '画像を貼り付け',
+ url: 'URL…',
+ promptSnippets: 'プロンプトスニペット…',
+ tipPre: 'ヒント: ',
+ tipPost: ' と入力してファイルをインラインで参照。',
+ snippetsTitle: 'プロンプトスニペット',
+ snippetsDesc: 'スターターのプロンプトをコンポーザーに挿入します。',
+ dropFiles: 'ファイルをドロップして添付',
+ dropSession: 'ドロップしてこのチャットをリンク',
+ snippets: {
+ codeReview: {
+ label: 'コードレビュー',
+ description: '回帰、エッジケースの欠落、テストの欠如を確認します。',
+ text: 'バグ、回帰、テストの欠如を確認してください。'
+ },
+ implementationPlan: {
+ label: '実装計画',
+ description: 'コードに手をつける前にアプローチを概説して、差分を集中させます。',
+ text: 'コードを変更する前に簡潔な実装計画を立ててください。'
+ },
+ explainThis: {
+ label: 'これを説明する',
+ 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 インスタンスをすべて停止してから再試行してください。詳細は以下またはデスクトップログで確認できます。',
+ activeDesc:
+ 'これは一回限りのセットアップです。Hermes インストーラーが依存関係をダウンロードしてマシンを設定しています。以降の起動ではこの手順はスキップされます。',
+ progress: (completed, total) => `${total} ステップ中 ${completed} 完了`,
+ currentStage: stage => ` — 現在: ${stage}`,
+ fetchingManifest: 'インストーラーマニフェストを取得中...',
+ error: 'エラー',
+ hideOutput: 'インストーラーの出力を非表示',
+ showOutput: 'インストーラーの出力を表示',
+ lines: count => `${count} 行`,
+ noOutput: 'まだ出力がありません。',
+ cancelling: 'キャンセル中...',
+ cancelInstall: 'インストールをキャンセル',
+ transcriptSaved: 'フルトランスクリプトを保存しました:',
+ copiedOutput: 'コピーしました!',
+ copyOutput: '出力をコピー',
+ reloadRetry: '再読み込みして再試行'
+ },
+
+ onboarding: {
+ headerTitle: 'Hermes Agent のセットアップをしましょう',
+ headerDesc: 'チャットを始めるにはモデルプロバイダーを接続してください。ほとんどのオプションはワンクリックです。',
+ preparingInstall: 'Hermes はインストールを完了中です。初回実行では通常 1 分以内に完了します。',
+ starting: 'Hermes を起動中…',
+ lookingUpProviders: 'プロバイダーを検索中...',
+ collapse: '折りたたむ',
+ otherProviders: 'その他のプロバイダー',
+ haveApiKey: 'API キーをお持ちです',
+ chooseLater: '後でプロバイダーを選択します',
+ recommended: '推奨',
+ connected: '接続済み',
+ featuredPitch: '1 つのサブスクリプションで 300 以上の最先端モデル — Hermes を実行するための推奨方法',
+ openRouterPitch: '1 つのキーで数百のモデル — 堅実なデフォルト',
+ apiKeyOptions: {
+ openrouter: {
+ short: '1 つのキーで多くのモデル',
+ description: '1 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。'
+ },
+ openai: { short: 'GPT クラスのモデル', description: 'OpenAI モデルへの直接アクセス。' },
+ gemini: { short: 'Gemini モデル', description: 'Google Gemini モデルへの直接アクセス。' },
+ xai: { short: 'Grok モデル', description: 'xAI Grok モデルへの直接アクセス。' },
+ local: {
+ short: 'セルフホスト',
+ description:
+ 'ローカルまたはセルフホストの OpenAI 互換エンドポイント(vLLM、llama.cpp、Ollama など)に Hermes を接続。'
+ }
+ },
+ 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} 出力 per 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: '100 万トークンあたりの入力/出力価格'
+ },
+
+ 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: 'Cron',
+ openCron: 'Cron ジョブを開く',
+ 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} つのステップが失敗しました`,
+ statusRunning: '実行中',
+ statusError: 'エラー',
+ statusRecovered: '回復しました',
+ statusDone: '完了'
+ }
+ },
+
+ prompts: {
+ gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
+ sudoSendFailed: 'sudo パスワードを送信できませんでした',
+ secretSendFailed: 'シークレットを送信できませんでした',
+ sudoTitle: '管理者パスワード',
+ sudoDesc:
+ 'Hermes は特権コマンドを実行するために sudo パスワードが必要です。ローカルエージェントにのみ送信されます。',
+ sudoPlaceholder: 'sudo パスワード',
+ secretTitle: 'シークレットが必要です',
+ secretDesc: 'Hermes は続行するための認証情報が必要です。',
+ secretPlaceholder: 'シークレット値'
+ },
+
+ desktop: {
+ audioReadFailed: '録音した音声を読み取れませんでした',
+ sessionUnavailable: 'セッションが利用できません',
+ createSessionFailed: '新しいセッションを作成できませんでした',
+ promptFailed: 'プロンプトに失敗しました',
+ providerCredentialRequired: '最初のメッセージを送信する前にプロバイダー認証情報を追加してください。',
+ emptySlashCommand: '空のスラッシュコマンド',
+ desktopCommands: 'デスクトップコマンド',
+ skillCommandsAvailable: count => `${count} 件のスキルコマンドが利用可能です。`,
+ warningLine: message => `警告: ${message}`,
+ yoloArmed: 'このチャットでは YOLO が有効になっています',
+ yoloOff: 'YOLO オフ',
+ yoloSystem: active => `このセッションの YOLO ${active ? 'オン' : 'オフ'}`,
+ yoloTitle: 'YOLO',
+ yoloToggleFailed: 'YOLO を切り替えられませんでした',
+ profileStatus: current =>
+ `プロファイル: ${current}。/profile または「新しいセッション」ピッカーを使って別のプロファイルでチャットを始めてください。`,
+ unknownProfile: '不明なプロファイル',
+ noProfileNamed: (target, available) => `"${target}" という名前のプロファイルはありません。利用可能: ${available}`,
+ newChatsProfile: name => `新しいチャットはプロファイル ${name} を使用します。`,
+ setProfileFailed: 'プロファイルの設定に失敗しました',
+ sttDisabled: '音声認識は設定で無効になっています。',
+ stopFailed: '停止に失敗しました',
+ regenerateFailed: '再生成に失敗しました',
+ editFailed: '編集に失敗しました',
+ resumeFailed: '再開に失敗しました',
+ nothingToBranch: 'ブランチするものがありません',
+ branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
+ sessionBusy: 'セッションが使用中',
+ branchStopCurrent: 'このチャットをブランチする前に現在のターンを停止してください。',
+ branchNoText: 'このメッセージにはブランチするテキストがありません。',
+ branchTitle: 'ブランチ',
+ branchFailed: 'ブランチに失敗しました',
+ deleteFailed: '削除に失敗しました',
+ archived: 'アーカイブしました',
+ archiveFailed: 'アーカイブに失敗しました',
+ cwdChangeFailed: '作業ディレクトリの変更に失敗しました',
+ cwdStagedTitle: '作業ディレクトリがステージングされました',
+ cwdStagedMessage:
+ 'このアクティブなセッションへの cwd の変更を適用するにはデスクトップバックエンドを再起動してください。',
+ modelSwitchFailed: 'モデルの切り替えに失敗しました',
+ sessionExported: 'セッションをエクスポートしました',
+ sessionExportFailed: 'セッションをエクスポートできませんでした',
+ imageSaved: '画像を保存しました',
+ downloadStarted: 'ダウンロードを開始しました',
+ restartToUseSaveImage: '画像を保存するには Hermes Desktop を再起動してください。',
+ restartToSaveImages: '画像を保存するには Hermes Desktop を再起動してください',
+ imageDownloadFailed: '画像のダウンロードに失敗しました',
+ openImage: '画像を開く',
+ downloadImage: '画像をダウンロード',
+ savingImage: '画像を保存中',
+ 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/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index d6d1b8c8b12..dac65ca5d3a 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -4,18 +4,146 @@ import { defineLocale } from './define-locale'
export const zhHant = defineLocale({
common: {
+ apply: '套用',
+ back: '返回',
save: '儲存',
saving: '儲存中…',
cancel: '取消',
+ change: '變更',
+ choose: '選擇',
+ clear: '清除',
close: '關閉',
+ collapse: '收合',
confirm: '確認',
+ connect: '連線',
+ connecting: '連線中',
+ continue: '繼續',
+ copied: '已複製',
+ copy: '複製',
+ copyFailed: '複製失敗',
delete: '刪除',
+ docs: '文件',
+ done: '完成',
+ error: '錯誤',
+ failed: '失敗',
+ free: '免費',
+ loading: '載入中…',
+ notSet: '未設定',
refresh: '重新整理',
+ remove: '移除',
+ replace: '取代',
retry: '重試',
+ run: '執行',
+ send: '傳送',
+ set: '設定',
+ skip: '略過',
+ update: '更新',
on: '開啟',
off: '關閉'
},
+ boot: {
+ ready: 'Hermes Desktop 已就緒',
+ desktopBootFailedWithMessage: message => `桌面啟動失敗:${message}`,
+ steps: {
+ connectingGateway: '正在連線桌面閘道',
+ loadingSettings: '正在載入 Hermes 設定',
+ loadingSessions: '正在載入最近工作階段',
+ startingDesktopConnection: '正在啟動桌面連線',
+ startingHermesDesktop: '正在啟動 Hermes Desktop…'
+ },
+ errors: {
+ backgroundExited: 'Hermes 背景程序已結束。',
+ backgroundExitedDuringStartup: 'Hermes 背景程序在啟動期間結束。',
+ backendStopped: '後端已停止',
+ desktopBootFailed: '桌面啟動失敗',
+ gatewaySignInRequired: '需要閘道登入',
+ ipcBridgeUnavailable: '桌面 IPC 橋接器不可用。'
+ },
+ failure: {
+ title: 'Hermes 無法啟動',
+ description: '背景閘道未啟動。請嘗試下面的復原步驟。這裡的操作不會刪除您的聊天或設定。',
+ remoteTitle: '需要重新登入遠端閘道',
+ remoteDescription: '您的遠端閘道工作階段已過期。請重新登入以重新連線。這裡的操作不會刪除您的聊天或設定。',
+ retry: '重試',
+ repairInstall: '修復安裝',
+ useLocalGateway: '使用本機閘道',
+ openLogs: '開啟記錄',
+ repairHint: '修復會重新執行安裝程式,在新機器上可能需要幾分鐘。',
+ remoteSignInHint: '開啟閘道登入視窗。使用本機閘道可切換至內建後端。',
+ hideRecentLogs: '隱藏最近記錄',
+ showRecentLogs: '顯示最近記錄',
+ signedInTitle: '已登入',
+ signedInMessage: '正在重新連線至遠端閘道…',
+ signInIncompleteTitle: '登入未完成',
+ signInIncompleteMessage: '登入視窗在驗證完成前關閉。',
+ signInFailed: '登入失敗',
+ signInToRemoteGateway: '登入遠端閘道',
+ signInWithProvider: provider => `使用 ${provider} 登入`,
+ identityProvider: '您的身分提供方'
+ }
+ },
+
+ notifications: {
+ region: '通知',
+ hide: '隱藏',
+ show: '顯示',
+ more: count => `另外 ${count} 則通知`,
+ clearAll: '全部清除',
+ dismiss: '關閉通知',
+ details: '詳細資訊',
+ copyDetail: '複製詳情',
+ copyDetailFailed: '無法複製通知詳情',
+ backendOutOfDateTitle: '後端版本過舊',
+ backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。',
+ updateHermes: '更新 Hermes',
+ updateReadyTitle: '有可用更新',
+ updateReadyMessage: count => `有 ${count} 項新變更可用。`,
+ seeWhatsNew: '查看新增內容',
+ errors: {
+ elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
+ elevenLabsRejectedKey: 'ElevenLabs 拒絕了該 API 金鑰 (401)。',
+ methodNotAllowed: '桌面後端拒絕了該請求 (405 Method Not Allowed)。請嘗試重新啟動 Hermes Desktop。',
+ microphonePermission: '麥克風權限已被拒絕。',
+ openaiRejectedApiKey: 'OpenAI 拒絕了該 API 金鑰。',
+ openaiRejectedApiKeyWithStatus: status => `OpenAI 拒絕了該 API 金鑰 (${status} invalid_api_key)。`,
+ openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
+ },
+ voice: {
+ configureSpeechToText: '設定語音轉文字後即可使用語音模式。',
+ couldNotStartSession: '無法啟動語音工作階段',
+ microphoneAccessDenied: '麥克風存取被拒絕。',
+ microphoneConstraintsUnsupported: '此裝置不支援目前的麥克風限制條件。',
+ microphoneFailed: '麥克風發生錯誤',
+ microphoneInUse: '麥克風正被其他應用程式使用中。',
+ microphonePermissionDenied: '麥克風權限被拒絕。',
+ microphoneStartFailed: '無法開始麥克風錄音。',
+ microphoneUnsupported: '目前執行環境不支援麥克風錄音。',
+ noMicrophone: '找不到麥克風。',
+ noSpeechDetected: '未偵測到語音',
+ playbackFailed: '語音播放失敗',
+ recordingFailed: '語音錄製失敗',
+ transcriptionFailed: '語音轉寫失敗',
+ transcriptionUnavailable: '語音轉寫暫不可用。',
+ tryRecordingAgain: '請再錄製一次。',
+ unavailable: '語音不可用'
+ }
+ },
+
+ titlebar: {
+ hideSidebar: '隱藏側邊欄',
+ showSidebar: '顯示側邊欄',
+ search: '搜尋',
+ searchTitle: '搜尋工作階段、檢視和動作',
+ swapSidebarSides: '交換側邊欄位置',
+ swapSidebarSidesTitle: '交換工作階段欄和檔案瀏覽器的位置',
+ hideRightSidebar: '隱藏右側邊欄',
+ showRightSidebar: '顯示右側邊欄',
+ muteHaptics: '靜音觸感回饋',
+ unmuteHaptics: '開啟觸感回饋',
+ openSettings: '開啟設定'
+ },
+
language: {
label: '語言',
description: '選擇桌面介面的語言。',
@@ -276,37 +404,1410 @@ export const zhHant = defineLocale({
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 後端,請使用遠端閘道。在下方按設定檔指定各自的遠端主機。',
+ appliesTo: '套用至',
+ allProfiles: '全部設定檔',
+ defaultConnection: '預設連線適用於所有沒有自訂覆寫的設定檔。',
+ profileConnection: profile => `僅當「${profile}」為作用中設定檔時使用此連線。設為本機可繼承預設連線。`,
+ envOverrideTitle: '環境變數正在控制此桌面工作階段。',
+ envOverrideDesc:
+ '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。',
+ localTitle: '本機閘道',
+ localDesc: '在 localhost 啟動私有 Hermes 後端。這是預設方式,可離線使用。',
+ remoteTitle: '遠端閘道',
+ remoteDesc:
+ '將此桌面殼層連線至遠端 Hermes 後端。託管閘道使用 OAuth 或帳號密碼;自託管閘道也可使用工作階段 Token。',
+ remoteUrlTitle: '遠端 URL',
+ remoteUrlDesc: '遠端儀表板後端的基礎 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 存取的儀表板工作階段 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:
+ '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
+ 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。`
}
},
skills: {
+ tabSkills: '技能',
+ tabToolsets: '工具集',
all: '全部',
- noDescription: '無可用描述。'
+ searchSkills: '搜尋技能...',
+ searchToolsets: '搜尋工具集...',
+ refresh: '重新整理技能',
+ refreshing: '正在重新整理技能',
+ loading: '正在載入功能…',
+ noSkillsTitle: '找不到技能',
+ noSkillsDesc: '請嘗試更廣泛的搜尋或不同類別。',
+ noToolsetsTitle: '找不到工具集',
+ noToolsetsDesc: '請嘗試更廣泛的搜尋詞。',
+ noDescription: '無可用描述。',
+ configured: '已設定',
+ needsKeys: '需要金鑰',
+ toolsetsEnabled: (enabled, total) => `已啟用 ${enabled}/${total} 個工具集`,
+ configureToolset: label => `設定 ${label}`,
+ toggleToolset: label => `切換 ${label} 工具集`,
+ skillsLoadFailed: '技能載入失敗',
+ toolsetsRefreshFailed: '工具集重新整理失敗',
+ skillEnabled: '技能已啟用',
+ skillDisabled: '技能已停用',
+ toolsetEnabled: '工具集已啟用',
+ toolsetDisabled: '工具集已停用',
+ appliesToNewSessions: name => `${name} 將套用至新工作階段。`,
+ failedToUpdate: name => `更新 ${name} 失敗`
+ },
+
+ agents: {
+ close: '關閉代理',
+ title: '派生樹',
+ subtitle: '目前回合的子代理即時活動。',
+ emptyTitle: '暫無活躍子代理',
+ emptyDesc: '當某個回合派發任務時,子代理會在此即時顯示進度。',
+ running: '執行中',
+ failed: '失敗',
+ done: '完成',
+ streaming: '串流傳輸中',
+ files: '檔案',
+ moreFiles: count => `還有 ${count} 個檔案`,
+ delegation: index => `派發 ${index}`,
+ workers: count => `${count} 個工作單元`,
+ workersActive: count => `${count} 個活躍`,
+ agentsCount: count => `${count} 個代理`,
+ activeCount: count => `${count} 個活躍`,
+ failedCount: count => `${count} 個失敗`,
+ toolsCount: count => `${count} 個工具`,
+ filesCount: count => `${count} 個檔案`,
+ updatedAgo: age => `更新於 ${age}`,
+ ageNow: '剛才',
+ ageSeconds: seconds => `${seconds} 秒前`,
+ ageMinutes: minutes => `${minutes} 分鐘前`,
+ ageHours: hours => `${hours} 小時前`,
+ durationSeconds: seconds => `${seconds} 秒`,
+ durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
+ tokensK: k => `${k}k 詞元`,
+ tokens: value => `${value} 詞元`
+ },
+
+ commandCenter: {
+ close: '關閉命令中心',
+ paletteTitle: '命令面板',
+ back: '返回',
+ searchPlaceholder: '搜尋工作階段、檢視和動作',
+ goTo: '前往',
+ commandCenter: '命令中心',
+ appearance: '外觀',
+ settings: '設定',
+ changeTheme: '變更主題...',
+ changeColorMode: '變更色彩模式...',
+ settingsFields: '設定欄位',
+ mcpServers: 'MCP 伺服器',
+ archivedChats: '已封存聊天',
+ sections: { sessions: '工作階段', system: '系統', usage: '使用量' },
+ sectionDescriptions: {
+ sessions: '搜尋和管理工作階段',
+ system: '狀態、記錄和系統動作',
+ usage: '一段時間內的詞元、費用和技能活動'
+ },
+ nav: {
+ newChat: { title: '新工作階段', detail: '開始新的工作階段' },
+ settings: { title: '設定', detail: '設定 Hermes 桌面端' },
+ skills: { title: '技能與工具', detail: '啟用技能、工具集和提供方' },
+ messaging: { title: '訊息平台', detail: '設定 Telegram、Slack、Discord 等' },
+ artifacts: { title: '成品', detail: '瀏覽產生的輸出' }
+ },
+ sectionEntries: {
+ sessions: { title: '工作階段面板', detail: '搜尋、釘選和管理工作階段' },
+ system: { title: '系統面板', detail: '閘道狀態、記錄、重新啟動/更新' },
+ usage: { title: '使用量面板', detail: '詞元、費用和技能活動' }
+ },
+ providerNavigate: '導覽',
+ providerSessions: '工作階段',
+ refresh: '重新整理',
+ refreshing: '重新整理中…',
+ noResults: '找不到相符的結果。',
+ pinSession: '釘選工作階段',
+ unpinSession: '取消釘選',
+ exportSession: '匯出工作階段',
+ deleteSession: '刪除工作階段',
+ noSessions: '暫無工作階段。',
+ gatewayRunning: '訊息閘道執行中',
+ gatewayStopped: '訊息閘道已停止',
+ hermesActiveSessions: (version, count) => `Hermes ${version} · 活躍工作階段 ${count}`,
+ restartMessaging: '重新啟動訊息服務',
+ updateHermes: '更新 Hermes',
+ actionRunning: '執行中',
+ actionDone: '完成',
+ actionFailed: '失敗',
+ actionStartedWaiting: '動作已啟動,等待狀態…',
+ loadingStatus: '正在載入狀態…',
+ recentLogs: '最近記錄',
+ noLogs: '尚未載入記錄。',
+ days: count => `${count} 天`,
+ statSessions: '工作階段',
+ statApiCalls: 'API 呼叫',
+ statTokens: '輸入/輸出詞元',
+ statCost: '預估費用',
+ actualCost: cost => `實際 ${cost}`,
+ loadingUsage: '正在載入使用量…',
+ noUsage: period => `最近 ${period} 天暫無使用量。`,
+ retry: '重試',
+ dailyTokens: '每日詞元',
+ input: '輸入',
+ output: '輸出',
+ noDailyActivity: '暫無每日活動。',
+ topModels: '常用模型',
+ noModelUsage: '暫無模型使用量。',
+ topSkills: '常用技能',
+ noSkillActivity: '暫無技能活動。',
+ actions: count => `${count} 次動作`
+ },
+
+ messaging: {
+ search: '搜尋訊息平台…',
+ loading: '正在載入訊息平台…',
+ loadFailed: '訊息平台載入失敗',
+ states: {
+ connected: '已連線',
+ connecting: '連線中',
+ disabled: '已停用',
+ fatal: '錯誤',
+ gateway_stopped: '訊息閘道已停止',
+ not_configured: '需要設定',
+ pending_restart: '需要重新啟動',
+ retrying: '重試中',
+ startup_failed: '啟動失敗'
+ },
+ unknown: '未知',
+ hintPendingRestart: '在狀態列重新啟動閘道以套用此變更。',
+ hintGatewayStopped: '在狀態列啟動閘道以建立連線。',
+ credentialsSet: '憑證已設定',
+ needsSetup: '需要設定',
+ gatewayStopped: '訊息閘道已停止',
+ getCredentials: '取得您的憑證',
+ openSetupGuide: '開啟設定指南',
+ required: '必填',
+ recommended: '建議',
+ advanced: count => `進階 (${count})`,
+ noTokenNeeded: '此平台不需要在此填寫 Token。請按照上方設定指南操作,然後在下方啟用。',
+ enabled: '已啟用',
+ disabled: '已停用',
+ unsavedChanges: '有未儲存的變更',
+ saving: '儲存中…',
+ saveChanges: '儲存變更',
+ saved: '已儲存',
+ replaceValue: '取代目前值',
+ openDocs: '開啟文件',
+ clearField: key => `清除 ${key}`,
+ enableAria: name => `啟用 ${name}`,
+ disableAria: name => `停用 ${name}`,
+ platformEnabled: name => `${name} 已啟用`,
+ platformDisabled: name => `${name} 已停用`,
+ restartToApply: '重新啟動閘道後此變更才會生效。',
+ setupSaved: name => `${name} 設定已儲存`,
+ restartToReconnect: '重新啟動閘道以使用新憑證重新連線。',
+ keyCleared: key => `${key} 已清除`,
+ setupUpdated: name => `${name} 設定已更新。`,
+ failedUpdate: name => `更新 ${name} 失敗`,
+ failedSave: name => `儲存 ${name} 失敗`,
+ failedClear: key => `清除 ${key} 失敗`,
+ fieldCopy: {
+ TELEGRAM_BOT_TOKEN: {
+ label: 'Bot Token',
+ help: '用 @BotFather 建立機器人,然後貼上它給您的 Token。',
+ placeholder: '貼上 Telegram bot Token'
+ },
+ TELEGRAM_ALLOWED_USERS: {
+ label: '允許的 Telegram 使用者 ID',
+ help: '建議設定。來自 @userinfobot 的逗號分隔數字 ID。不設定則任何人都能私訊您的機器人。'
+ },
+ TELEGRAM_PROXY: { label: '代理 URL', help: '僅在 Telegram 被封鎖的網路中需要。' },
+ DISCORD_BOT_TOKEN: {
+ label: 'Bot Token',
+ help: '在 Discord 開發者入口網站建立應用程式,新增機器人,然後貼上其 Token。'
+ },
+ DISCORD_ALLOWED_USERS: { label: '允許的 Discord 使用者 ID', help: '建議設定。逗號分隔的 Discord 使用者 ID。' },
+ DISCORD_REPLY_TO_MODE: { label: '回覆方式', help: 'first、all 或 off。' },
+ DISCORD_ALLOW_ALL_USERS: {
+ label: '允許所有 Discord 使用者',
+ help: '僅供開發使用。為 true 時,任何人都可以私訊機器人,不需要允許清單。'
+ },
+ DISCORD_HOME_CHANNEL: {
+ label: '主頻道 ID',
+ help: '機器人主動傳送訊息的頻道(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 Token',
+ help: '安裝 Slack 應用程式後,在 OAuth & Permissions 中找到 bot Token。',
+ placeholder: '貼上 Slack bot Token'
+ },
+ SLACK_APP_TOKEN: {
+ label: 'Slack app Token',
+ help: 'Socket Mode 需要 app 層級 Token。',
+ placeholder: '貼上 Slack app Token'
+ },
+ SLACK_ALLOWED_USERS: { label: '允許的 Slack 使用者 ID', help: '建議設定。逗號分隔的 Slack 使用者 ID。' },
+ MATTERMOST_URL: { label: '伺服器 URL', placeholder: 'https://mattermost.example.com' },
+ MATTERMOST_TOKEN: { label: 'Bot Token' },
+ MATTERMOST_ALLOWED_USERS: { label: '允許的使用者 ID', help: '建議設定。逗號分隔的 Mattermost 使用者 ID。' },
+ MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' },
+ MATRIX_ACCESS_TOKEN: { label: '存取 Token' },
+ 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',
+ 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_MODE: { label: '橋接模式' },
+ WHATSAPP_ALLOWED_USERS: {
+ label: '允許的 WhatsApp 使用者',
+ help: '建議設定。逗號分隔的電話號碼或 WhatsApp ID。'
+ }
+ },
+ platformIntro: {}
},
profiles: {
+ close: '關閉設定檔',
+ nameHint: '小寫字母、數字、連字號和底線。必須以字母或數字開頭。',
+ title: '設定檔',
+ 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: '重新整理設定檔',
+ refreshing: '正在重新整理設定檔',
+ default: '預設',
skills: count => `${count} 個技能`,
+ env: 'env',
defaultBadge: '預設',
rename: '重新命名',
- saveSoul: '儲存 SOUL',
+ copySetup: '複製安裝指令',
+ copying: '複製中…',
+ modelLabel: '模型',
+ skillsLabel: '技能',
+ notSet: '未設定',
+ soulDesc: '內建於此設定檔的系統提示詞與角色指令。',
+ soulOptional: '選填',
+ soulPlaceholder: mode => `此設定檔的系統提示詞 / 角色說明。\n留空則保留${mode}預設值。`,
+ soulPlaceholderCloned: '複製的',
+ soulPlaceholderEmpty: '空的',
+ unsavedChanges: '有未儲存的變更',
+ loadingSoul: '正在載入 SOUL.md…',
+ emptySoul: '空的 SOUL.md — 開始撰寫角色設定…',
+ saving: '儲存中…',
+ saveSoul: '儲存 SOUL.md',
+ deleteTitle: '刪除設定檔?',
+ deleteDescPrefix: '這將刪除 ',
+ deleteDescMid: ' 並移除其 ',
+ deleteDescSuffix: ' 目錄。此操作無法復原。',
+ deleting: '刪除中…',
+ createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
+ nameLabel: '名稱',
cloneFromDefault: '從預設設定檔複製設定',
+ cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
invalidName: hint => `設定檔名稱無效。${hint}`,
nameRequired: '名稱為必填',
+ creating: '建立中…',
+ createAction: '建立設定檔',
+ renameTitle: '重新命名設定檔',
+ renameDescPrefix: '重新命名會更新設定檔目錄以及 ',
+ renameDescSuffix: ' 中的所有包裝指令碼。',
+ newNameLabel: '新名稱',
+ renaming: '重新命名中…',
created: '已建立',
renamed: '已重新命名',
deleted: '已刪除',
- soulSaved: 'SOUL.md 已儲存'
+ setupCopied: '安裝指令已複製',
+ soulSaved: 'SOUL.md 已儲存',
+ failedLoad: '載入設定檔失敗',
+ failedDelete: '刪除設定檔失敗',
+ failedCopy: '複製安裝指令失敗',
+ failedLoadSoul: '載入 SOUL.md 失敗',
+ failedSaveSoul: '儲存 SOUL.md 失敗',
+ failedCreate: '建立設定檔失敗',
+ failedRename: '重新命名設定檔失敗'
},
cron: {
- last: '上次',
- next: '下次',
+ close: '關閉排程',
+ search: '搜尋排程工作…',
+ refresh: '重新整理排程工作',
+ refreshing: '正在重新整理排程工作',
+ loading: '正在載入排程工作…',
+ states: {
+ enabled: '已啟用',
+ scheduled: '已排程',
+ running: '執行中',
+ paused: '已暫停',
+ disabled: '已停用',
+ error: '錯誤',
+ completed: '已完成'
+ },
+ deliveryLabels: {
+ local: '此桌面',
+ telegram: 'Telegram',
+ discord: 'Discord',
+ slack: 'Slack',
+ email: '電子郵件'
+ },
+ scheduleLabels: {
+ daily: '每天',
+ weekdays: '工作日',
+ weekly: '每週',
+ monthly: '每月',
+ hourly: '每小時',
+ 'every-15-minutes': '每 15 分鐘',
+ custom: '自訂'
+ },
+ scheduleHints: {
+ daily: '每天上午 9:00',
+ weekdays: '週一至週五上午 9:00',
+ weekly: '每週一上午 9:00',
+ monthly: '每月第一天上午 9:00',
+ hourly: '每個整點',
+ 'every-15-minutes': '每 15 分鐘',
+ custom: 'Cron 語法或自然語言'
+ },
+ days: {
+ '0': '週日',
+ '1': '週一',
+ '2': '週二',
+ '3': '週三',
+ '4': '週四',
+ '5': '週五',
+ '6': '週六',
+ '7': '週日'
+ },
+ dayFallback: value => `第 ${value} 天`,
+ everyDayAt: time => `每天 ${time}`,
+ weekdaysAt: time => `工作日 ${time}`,
+ everyDayOfWeekAt: (day, time) => `每${day} ${time}`,
+ monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
+ topOfHour: '每個整點',
+ everyHourAt: minute => `每小時的 :${minute}`,
+ active: (enabled, total) => `${enabled}/${total} 個啟用`,
+ newCron: '新排程工作',
+ createFirst: '建立第一個排程工作',
+ emptyDescNew:
+ '按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
+ emptyDescSearch: '請嘗試更廣泛的搜尋詞。',
+ emptyTitleNew: '暫無排程工作',
+ emptyTitleSearch: '無相符項目',
+ last: '上次:',
+ next: '下次:',
+ actionsFor: title => `${title} 的動作`,
+ actionsTitle: '排程工作動作',
resume: '繼續',
pause: '暫停',
+ resumeTitle: '繼續',
+ pauseTitle: '暫停',
triggerNow: '立即觸發',
+ edit: '編輯排程工作',
+ deleteTitle: '刪除排程工作?',
+ deleteDescPrefix: '這將永久移除 ',
+ deleteDescSuffix: '。它會立即停止觸發。',
+ deleting: '刪除中…',
+ resumed: '排程工作已繼續',
+ paused: '排程工作已暫停',
+ triggered: '排程工作已觸發',
+ deleted: '排程工作已刪除',
+ created: '排程工作已建立',
+ updated: '排程工作已更新',
+ failedLoad: '載入排程工作失敗',
+ failedUpdate: '更新排程工作失敗',
+ failedTrigger: '觸發排程工作失敗',
+ failedDelete: '刪除排程工作失敗',
+ failedSave: '儲存排程工作失敗',
+ editTitle: '編輯排程工作',
+ createTitle: '新排程工作',
+ editDesc: '更新排程、提示詞或傳遞目標。變更將在下次執行時生效。',
+ createDesc: '排程一個提示詞以自動執行。使用 cron 語法或類似「每 15 分鐘」的自然語言。',
+ nameLabel: '名稱',
namePlaceholder: '例如:每日摘要',
- promptPlaceholder: '代理每次執行時應做什麼?'
+ promptLabel: '提示詞',
+ promptPlaceholder: '代理每次執行時應做什麼?',
+ frequencyLabel: '頻率',
+ deliverLabel: '傳遞至',
+ customScheduleLabel: '自訂排程',
+ customPlaceholder: '0 9 * * * 或 weekdays at 9am',
+ customHint: 'Cron 表達式,或類似「每小時」「工作日上午 9 點」的短語。',
+ optional: '選填',
+ promptScheduleRequired: '提示詞和排程為必填項目。',
+ saveChanges: '儲存變更',
+ createAction: '建立排程工作'
+ },
+
+ artifacts: {
+ search: '搜尋成品…',
+ refresh: '重新整理成品',
+ refreshing: '正在重新整理成品',
+ indexing: '正在索引最近工作階段的成品',
+ tabAll: '全部',
+ tabImages: '圖片',
+ tabFiles: '檔案',
+ tabLinks: '連結',
+ noArtifactsTitle: '找不到成品',
+ noArtifactsDesc: '當工作階段產生圖片和檔案輸出時,它們會顯示在這裡。',
+ failedLoad: '成品載入失敗',
+ openFailed: '開啟失敗',
+ itemsImage: '張圖片',
+ itemsLink: '個連結',
+ itemsFile: '個檔案',
+ itemsGeneric: '項',
+ zero: '0',
+ rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
+ goToPage: (itemLabel, page) => `前往${itemLabel}第 ${page} 頁`,
+ colTitleLink: '連結標題',
+ colTitleFile: '名稱',
+ colTitleDefault: '標題 / 名稱',
+ colLocationLink: 'URL',
+ colLocationFile: '路徑',
+ colLocationDefault: '位置',
+ colSession: '工作階段',
+ kindImage: '圖片',
+ kindFile: '檔案',
+ kindLink: '連結',
+ chat: '聊天',
+ copyUrl: '複製 URL',
+ copyPath: '複製路徑'
+ },
+
+ sidebar: {
+ nav: {
+ 'new-session': '新工作階段',
+ skills: '技能與工具',
+ messaging: '訊息平台',
+ artifacts: '成品'
+ },
+ searchAria: '搜尋工作階段',
+ searchPlaceholder: '搜尋工作階段…',
+ clearSearch: '清除搜尋',
+ noMatch: query => `沒有工作階段符合「${query}」。`,
+ results: '結果',
+ pinned: '已釘選',
+ sessions: '工作階段',
+ groupAriaGrouped: '以單一清單顯示工作階段',
+ groupAriaUngrouped: '依工作區分組工作階段',
+ groupTitleGrouped: '取消分組',
+ groupTitleUngrouped: '依工作區分組',
+ allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
+ shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序',
+ noWorkspace: '無工作區',
+ newSessionIn: label => `在 ${label} 中新建工作階段`,
+ reorderWorkspace: label => `重新排序工作區 ${label}`,
+ showMoreIn: (count, label) => `在 ${label} 中再顯示 ${count} 個`,
+ loading: '載入中…',
+ loadMore: '載入更多',
+ loadCount: step => `再載入 ${step} 個`,
+ row: {
+ pin: '釘選',
+ unpin: '取消釘選',
+ copyId: '複製 ID',
+ export: '匯出',
+ rename: '重新命名',
+ archive: '封存',
+ copyIdFailed: '無法複製工作階段 ID',
+ actionsFor: title => `${title} 的動作`,
+ sessionActions: '工作階段動作',
+ sessionRunning: '工作階段執行中',
+ needsInput: '需要您的輸入',
+ waitingForAnswer: '等待您的回答',
+ renamed: '已重新命名',
+ renameFailed: '重新命名失敗',
+ renameTitle: '重新命名工作階段',
+ renameDesc: '為此聊天取一個好記的標題。留空則清除。',
+ untitledPlaceholder: '未命名工作階段',
+ ageNow: '剛才',
+ ageDay: '天',
+ ageHour: '時',
+ ageMin: '分'
+ }
+ },
+
+ composer: {
+ message: '訊息',
+ wakingProfile: profile => `正在喚醒 ${profile}…`,
+ placeholderStarting: '正在啟動 Hermes...',
+ placeholderReconnecting: '正在重新連線至 Hermes…',
+ placeholderFollowUp: '傳送後續訊息',
+ newSessionPlaceholders: [
+ '我們要建立什麼?',
+ '給 Hermes 一個任務',
+ '您在想什麼?',
+ '描述您需要什麼',
+ '我們該處理什麼?',
+ '盡管問',
+ '從一個目標開始'
+ ],
+ followUpPlaceholders: [
+ '傳送後續訊息',
+ '補充更多脈絡',
+ '細化此請求',
+ '下一步是什麼?',
+ '繼續推進',
+ '再深入一點',
+ '調整或繼續'
+ ],
+ startVoice: '開始語音對話',
+ queueMessage: '排隊訊息',
+ stop: '停止',
+ send: '傳送',
+ speaking: '說話中',
+ transcribing: '轉寫中',
+ thinking: '思考中',
+ muted: '已靜音',
+ listening: '聆聽中',
+ muteMic: '麥克風靜音',
+ unmuteMic: '取消麥克風靜音',
+ stopListening: '停止聆聽並傳送',
+ stopShort: '停止',
+ endConversation: '結束語音對話',
+ endShort: '結束',
+ stopDictation: '停止聽寫',
+ transcribingDictation: '正在轉寫聽寫',
+ voiceDictation: '語音聽寫',
+ lookupLoading: '查詢中…',
+ lookupNoMatches: '沒有相符項目。',
+ lookupTry: '試試',
+ lookupOr: '或',
+ commonCommands: '常用指令',
+ hotkeys: '快捷鍵',
+ helpFooter: '開啟完整面板 · 退格鍵關閉',
+ commandDescs: {
+ '/help': '指令與快捷鍵的完整清單',
+ '/clear': '開始新工作階段',
+ '/resume': '繼續之前的工作階段',
+ '/details': '控制對話記錄的詳細程度',
+ '/copy': '複製所選內容或最後一條助手訊息',
+ '/quit': '結束 hermes'
+ },
+ hotkeyDescs: {
+ '@': '參照檔案、資料夾、URL、git',
+ '/': '斜線指令面板',
+ '?': '此快速說明(刪除以關閉)',
+ Enter: '傳送 · Shift+Enter 換行',
+ 'Cmd/Ctrl+K': '傳送下一個排隊的回合',
+ 'Cmd/Ctrl+L': '重繪',
+ Esc: '關閉彈出視窗 · 取消執行',
+ '↑ / ↓': '循環彈出視窗 / 歷史記錄'
+ },
+ attachUrlTitle: '附加 URL',
+ attachUrlDesc: 'Hermes 將擷取該頁面並作為此回合的脈絡。',
+ urlPlaceholder: 'https://example.com/post',
+ urlHintPre: '請輸入完整 URL,例如 ',
+ attach: '附加',
+ queued: count => `${count} 個排隊中`,
+ attachmentOnly: '僅附件回合',
+ emptyTurn: '空回合',
+ attachments: count => `${count} 個附件`,
+ editingInComposer: '在輸入框中編輯',
+ editingQueuedInComposer: '在輸入框中編輯排隊回合',
+ editQueued: '編輯排隊回合',
+ sendQueuedNow: '立即傳送排隊回合',
+ deleteQueued: '刪除排隊回合',
+ previewUnavailable: '預覽不可用',
+ previewLabel: label => `預覽 ${label}`,
+ couldNotPreview: label => `無法預覽 ${label}`,
+ removeAttachment: label => `移除 ${label}`,
+ dictating: '聽寫中',
+ preparingAudio: '正在準備音訊',
+ speakingResponse: '正在朗讀回覆',
+ readingAloud: '朗讀中',
+ themeSuggestions: '桌面主題建議',
+ noMatchingThemes: '沒有相符的主題。',
+ themeTryPre: '試試 ',
+ themeTryPost: '。',
+ attachLabel: '附加',
+ files: '檔案…',
+ folder: '資料夾…',
+ images: '圖片…',
+ pasteImage: '貼上圖片',
+ url: 'URL…',
+ promptSnippets: '提示詞片段…',
+ tipPre: '提示:輸入 ',
+ tipPost: ' 以行內參照檔案。',
+ snippetsTitle: '提示詞片段',
+ snippetsDesc: '選擇一個起始提示詞放入輸入框。',
+ dropFiles: '拖曳檔案以附加',
+ dropSession: '拖曳以連結此聊天',
+ snippets: {
+ codeReview: {
+ label: '程式碼審查',
+ description: '審查目前的變更是否有回歸、遺漏的邊緣情況和缺少的測試。',
+ text: '請審查這部分是否有錯誤、回歸和缺少的測試。'
+ },
+ implementationPlan: {
+ label: '實作計劃',
+ description: '在動程式碼之前先勾勒方案,讓 diff 保持聚焦。',
+ text: '請在修改程式碼前制定一個簡潔的實作計劃。'
+ },
+ explainThis: {
+ label: '解釋這段',
+ 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} 個步驟失敗`,
+ statusRunning: '執行中',
+ statusError: '錯誤',
+ statusRecovered: '已復原',
+ statusDone: '完成'
+ }
+ },
+
+ prompts: {
+ gatewayDisconnected: 'Hermes 閘道未連線',
+ sudoSendFailed: '無法傳送 sudo 密碼',
+ secretSendFailed: '無法傳送密鑰',
+ sudoTitle: '管理員密碼',
+ sudoDesc: 'Hermes 需要您的 sudo 密碼來執行特權指令。它只會傳送給您的本機代理。',
+ sudoPlaceholder: 'sudo 密碼',
+ secretTitle: '需要密鑰',
+ secretDesc: 'Hermes 需要一個憑證才能繼續。',
+ secretPlaceholder: '密鑰值'
+ },
+
+ desktop: {
+ audioReadFailed: '無法讀取錄製的音訊',
+ sessionUnavailable: '工作階段不可用',
+ createSessionFailed: '無法建立新工作階段',
+ promptFailed: '提示詞傳送失敗',
+ providerCredentialRequired: '傳送第一則訊息前請先新增提供方憑證。',
+ emptySlashCommand: '空的斜線指令',
+ desktopCommands: '桌面端指令',
+ skillCommandsAvailable: count => `${count} 個技能指令可用。`,
+ warningLine: message => `警告:${message}`,
+ yoloArmed: '此聊天已啟用 YOLO',
+ yoloOff: 'YOLO 已關閉',
+ yoloSystem: active => `此工作階段 YOLO ${active ? '已開啟' : '已關閉'}`,
+ yoloTitle: 'YOLO',
+ yoloToggleFailed: '無法切換 YOLO',
+ profileStatus: current =>
+ `設定檔:${current}。使用 /profile 或「新工作階段」選擇器在其他設定檔中開始聊天。`,
+ unknownProfile: '未知設定檔',
+ noProfileNamed: (target, available) => `沒有名為「${target}」的設定檔。可用的:${available}`,
+ newChatsProfile: name => `新聊天將使用設定檔 ${name}。`,
+ setProfileFailed: '設定設定檔失敗',
+ 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: '圖片下載失敗',
+ openImage: '開啟圖片',
+ downloadImage: '下載圖片',
+ savingImage: '正在儲存圖片',
+ 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: '切換側邊欄'
+ }
}
})
From c24abf5b32865f294d91a9cb8bd89c285f3d695f 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 23:17:40 -0500
Subject: [PATCH 8/9] Add missing Chinese desktop i18n translations
---
apps/desktop/src/i18n/zh.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 25a794fd874..42f01a2d366 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -318,6 +318,9 @@ export const zh: Translations = {
max_concurrent_children: '并行子智能体',
child_timeout_seconds: '子智能体超时',
reasoning_effort: '子智能体推理强度'
+ },
+ updates: {
+ non_interactive_local_changes: '应用内更新本地更改'
}
}),
fieldDescriptions: defineFieldCopy({
@@ -370,6 +373,10 @@ export const zh: Translations = {
elevenlabs: {
language_code: '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。'
}
+ },
+ updates: {
+ non_interactive_local_changes:
+ 'Hermes 从应用内更新时(无终端提示),保留本地源码修改(暂存)或丢弃(放弃)。通过终端更新时始终会询问。'
}
}),
about: {
From 1c2189839d0bb57c8e0ac0aa44dd53fef73665b2 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 23:28:51 -0500
Subject: [PATCH 9/9] Refactor desktop settings i18n keys to camelCase
---
.../desktop/src/app/command-palette/index.tsx | 6 +-
.../src/app/settings/config-settings.tsx | 9 +-
apps/desktop/src/app/settings/constants.ts | 114 +++++++-------
apps/desktop/src/app/settings/field-copy.ts | 12 ++
apps/desktop/src/app/settings/helpers.test.ts | 29 +++-
apps/desktop/src/i18n/ja.ts | 114 +++++++-------
apps/desktop/src/i18n/runtime.test.ts | 25 ++-
apps/desktop/src/i18n/zh-hant.ts | 114 +++++++-------
apps/desktop/src/i18n/zh.ts | 144 +++++++++---------
9 files changed, 312 insertions(+), 255 deletions(-)
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 236aac244bd..35a246ff330 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -51,6 +51,7 @@ import {
SKILLS_ROUTE
} from '../routes'
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
+import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
interface PaletteItem {
@@ -198,7 +199,10 @@ export function CommandPalette() {
[t.settings.sections]
)
const configFieldLabel = useCallback(
- (key: string) => t.settings.fieldLabels[key] ?? FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key),
+ (key: string) =>
+ fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
+ fieldCopyForSchemaKey(FIELD_LABELS, key) ??
+ prettyName(key.split('.').pop() ?? key),
[t.settings.fieldLabels]
)
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 0c12d551184..2d550560764 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -19,6 +19,7 @@ import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
+import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
@@ -42,13 +43,15 @@ function ConfigField({
const c = t.settings.config
const label =
- t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
+ fieldCopyForSchemaKey(t.settings.fieldLabels, schemaKey) ??
+ fieldCopyForSchemaKey(FIELD_LABELS, schemaKey) ??
+ prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (
- t.settings.fieldDescriptions[schemaKey] ??
- FIELD_DESCRIPTIONS[schemaKey] ??
+ fieldCopyForSchemaKey(t.settings.fieldDescriptions, schemaKey) ??
+ fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, schemaKey) ??
schema.description ??
''
).trim()
diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts
index 6fb8ad9e97d..4d0e11b2822 100644
--- a/apps/desktop/src/app/settings/constants.ts
+++ b/apps/desktop/src/app/settings/constants.ts
@@ -248,59 +248,59 @@ export const ENUM_OPTIONS: Record = {
export const FIELD_LABELS: Record = defineFieldCopy({
model: 'Default Model',
- model_context_length: 'Context Window',
- fallback_providers: 'Fallback Models',
+ modelContextLength: 'Context Window',
+ fallbackProviders: 'Fallback Models',
toolsets: 'Enabled Toolsets',
timezone: 'Timezone',
display: {
personality: 'Personality',
- show_reasoning: 'Reasoning Blocks'
+ showReasoning: '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'
+ maxTurns: 'Max Agent Steps',
+ imageInputMode: 'Image Attachments',
+ apiMaxRetries: 'API Retries',
+ serviceTier: 'Service Tier',
+ toolUseEnforcement: 'Tool-Use Enforcement'
},
terminal: {
cwd: 'Working Directory',
backend: 'Execution Backend',
timeout: 'Command Timeout',
- persistent_shell: 'Persistent Shell',
- env_passthrough: 'Environment Passthrough'
+ persistentShell: 'Persistent Shell',
+ envPassthrough: 'Environment Passthrough'
},
- file_read_max_chars: 'File Read Limit',
- tool_output: {
- max_bytes: 'Terminal Output Limit',
- max_lines: 'File Page Limit',
- max_line_length: 'Line Length Limit'
+ fileReadMaxChars: 'File Read Limit',
+ toolOutput: {
+ maxBytes: 'Terminal Output Limit',
+ maxLines: 'File Page Limit',
+ maxLineLength: 'Line Length Limit'
},
- code_execution: {
+ codeExecution: {
mode: 'Code Execution Mode'
},
approvals: {
mode: 'Approval Mode',
timeout: 'Approval Timeout',
- mcp_reload_confirm: 'Confirm MCP Reloads'
+ mcpReloadConfirm: 'Confirm MCP Reloads'
},
- command_allowlist: 'Command Allowlist',
+ commandAllowlist: 'Command Allowlist',
security: {
- redact_secrets: 'Redact Secrets',
- allow_private_urls: 'Allow Private URLs'
+ redactSecrets: 'Redact Secrets',
+ allowPrivateUrls: 'Allow Private URLs'
},
browser: {
- allow_private_urls: 'Browser Private URLs',
- auto_local_for_private_urls: 'Local Browser For Private URLs'
+ allowPrivateUrls: 'Browser Private URLs',
+ autoLocalForPrivateUrls: 'Local Browser For Private URLs'
},
checkpoints: {
enabled: 'File Checkpoints',
- max_snapshots: 'Checkpoint Limit'
+ maxSnapshots: 'Checkpoint Limit'
},
voice: {
- record_key: 'Voice Shortcut',
- max_recording_seconds: 'Max Recording Length',
- auto_tts: 'Read Responses Aloud'
+ recordKey: 'Voice Shortcut',
+ maxRecordingSeconds: 'Max Recording Length',
+ autoTts: 'Read Responses Aloud'
},
stt: {
enabled: 'Speech To Text',
@@ -310,9 +310,9 @@ export const FIELD_LABELS: Record = defineFieldCopy({
language: 'Transcription Language'
},
elevenlabs: {
- model_id: 'ElevenLabs STT Model',
- language_code: 'ElevenLabs Language',
- tag_audio_events: 'Tag Audio Events',
+ modelId: 'ElevenLabs STT Model',
+ languageCode: 'ElevenLabs Language',
+ tagAudioEvents: 'Tag Audio Events',
diarize: 'Speaker Diarization'
}
},
@@ -326,15 +326,15 @@ export const FIELD_LABELS: Record = defineFieldCopy({
voice: 'OpenAI Voice'
},
elevenlabs: {
- voice_id: 'ElevenLabs Voice',
- model_id: 'ElevenLabs Model'
+ voiceId: 'ElevenLabs Voice',
+ modelId: 'ElevenLabs Model'
}
},
memory: {
- memory_enabled: 'Persistent Memory',
- user_profile_enabled: 'User Profile',
- memory_char_limit: 'Memory Budget',
- user_char_limit: 'Profile Budget',
+ memoryEnabled: 'Persistent Memory',
+ userProfileEnabled: 'User Profile',
+ memoryCharLimit: 'Memory Budget',
+ userCharLimit: 'Profile Budget',
provider: 'Memory Provider'
},
context: {
@@ -343,57 +343,57 @@ export const FIELD_LABELS: Record = defineFieldCopy({
compression: {
enabled: 'Auto-Compression',
threshold: 'Compression Threshold',
- target_ratio: 'Compression Target',
- protect_last_n: 'Protected Recent Messages'
+ targetRatio: 'Compression Target',
+ protectLastN: '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'
+ maxIterations: 'Subagent Turn Limit',
+ maxConcurrentChildren: 'Parallel Subagents',
+ childTimeoutSeconds: 'Subagent Timeout',
+ reasoningEffort: 'Subagent Reasoning Effort'
},
updates: {
- non_interactive_local_changes: 'In-App Update Local Changes'
+ nonInteractiveLocalChanges: 'In-App Update Local Changes'
}
})
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.',
+ modelContextLength: "Leave at 0 to use the selected model's detected context window.",
+ fallbackProviders: 'Backup provider:model entries to try if the default model fails.',
display: {
personality: 'Default assistant style for new sessions.',
- show_reasoning: 'Show reasoning sections when the backend provides them.'
+ showReasoning: 'Show reasoning sections when the backend provides them.'
},
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
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.'
+ imageInputMode: 'Controls how image attachments are sent to the model.',
+ maxTurns: '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.'
+ persistentShell: 'Keep shell state between commands when the backend supports it.',
+ envPassthrough: 'Environment variables to pass into tool execution.'
},
- code_execution: {
+ codeExecution: {
mode: 'How strictly code execution is scoped to the current project.'
},
- file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
+ fileReadMaxChars: 'Maximum characters Hermes can read from one file request.',
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.'
+ redactSecrets: '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.'
+ memoryEnabled: 'Save durable memories that can help future sessions.',
+ userProfileEnabled: 'Maintain a compact profile of user preferences.'
},
context: {
engine: 'Strategy for managing long conversations near the context limit.'
@@ -402,16 +402,16 @@ export const FIELD_DESCRIPTIONS: Record = defineFieldCopy({
enabled: 'Summarize older context when conversations get large.'
},
voice: {
- auto_tts: 'Automatically speak assistant responses.'
+ autoTts: '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.'
+ languageCode: 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.'
}
},
updates: {
- non_interactive_local_changes:
+ nonInteractiveLocalChanges:
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
})
diff --git a/apps/desktop/src/app/settings/field-copy.ts b/apps/desktop/src/app/settings/field-copy.ts
index 9077f6d6852..e66c00de781 100644
--- a/apps/desktop/src/app/settings/field-copy.ts
+++ b/apps/desktop/src/app/settings/field-copy.ts
@@ -2,10 +2,22 @@ export interface FieldCopyTree {
[key: string]: string | FieldCopyTree
}
+function schemaSegmentToFieldCopySegment(segment: string): string {
+ return segment.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase())
+}
+
function isFieldCopyTree(value: unknown): value is FieldCopyTree {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+export function schemaKeyToFieldCopyKey(schemaKey: string): string {
+ return schemaKey.split('.').map(schemaSegmentToFieldCopySegment).join('.')
+}
+
+export function fieldCopyForSchemaKey(copy: Record, schemaKey: string): string | undefined {
+ return copy[schemaKeyToFieldCopyKey(schemaKey)] ?? copy[schemaKey]
+}
+
export function defineFieldCopy(copy: FieldCopyTree): Record {
const result: Record = {}
diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts
index e607c165bcd..ee2377a24b1 100644
--- a/apps/desktop/src/app/settings/helpers.test.ts
+++ b/apps/desktop/src/app/settings/helpers.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
-import { defineFieldCopy } from './field-copy'
+import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
@@ -35,6 +35,33 @@ describe('settings helpers', () => {
})
})
+ it('maps schema keys to camelCase translation keys', () => {
+ expect(schemaKeyToFieldCopyKey('model_context_length')).toBe('modelContextLength')
+ expect(schemaKeyToFieldCopyKey('display.show_reasoning')).toBe('display.showReasoning')
+ expect(schemaKeyToFieldCopyKey('tool_output.max_line_length')).toBe('toolOutput.maxLineLength')
+ expect(schemaKeyToFieldCopyKey('updates.non_interactive_local_changes')).toBe(
+ 'updates.nonInteractiveLocalChanges'
+ )
+ })
+
+ it('looks up camelCase field copy by schema key with legacy fallback', () => {
+ const copy = defineFieldCopy({
+ display: {
+ showReasoning: 'Reasoning Blocks'
+ },
+ file_read_max_chars: 'Legacy File Read Limit',
+ modelContextLength: 'Context Window',
+ toolOutput: {
+ maxLineLength: 'Line Length Limit'
+ }
+ })
+
+ expect(fieldCopyForSchemaKey(copy, 'model_context_length')).toBe('Context Window')
+ expect(fieldCopyForSchemaKey(copy, 'display.show_reasoning')).toBe('Reasoning Blocks')
+ expect(fieldCopyForSchemaKey(copy, 'tool_output.max_line_length')).toBe('Line Length Limit')
+ expect(fieldCopyForSchemaKey(copy, 'file_read_max_chars')).toBe('Legacy File Read Limit')
+ })
+
it('rejects duplicate flattened paths', () => {
const duplicateKey = ['display', 'personality'].join('.')
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index afd0008a904..3565add8fe3 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -219,59 +219,59 @@ export const ja = defineLocale({
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
- model_context_length: 'コンテキストウィンドウ',
- fallback_providers: 'フォールバックモデル',
+ modelContextLength: 'コンテキストウィンドウ',
+ fallbackProviders: 'フォールバックモデル',
toolsets: '有効なツールセット',
timezone: 'タイムゾーン',
display: {
personality: '人格',
- show_reasoning: '推論ブロック'
+ showReasoning: '推論ブロック'
},
agent: {
- max_turns: '最大エージェントステップ',
- image_input_mode: '画像添付',
- api_max_retries: 'API 再試行回数',
- service_tier: 'サービス階層',
- tool_use_enforcement: 'ツール使用の強制'
+ maxTurns: '最大エージェントステップ',
+ imageInputMode: '画像添付',
+ apiMaxRetries: 'API 再試行回数',
+ serviceTier: 'サービス階層',
+ toolUseEnforcement: 'ツール使用の強制'
},
terminal: {
cwd: '作業ディレクトリ',
backend: '実行バックエンド',
timeout: 'コマンドタイムアウト',
- persistent_shell: '永続シェル',
- env_passthrough: '環境変数の引き継ぎ'
+ persistentShell: '永続シェル',
+ envPassthrough: '環境変数の引き継ぎ'
},
- file_read_max_chars: 'ファイル読み取り上限',
- tool_output: {
- max_bytes: 'ターミナル出力上限',
- max_lines: 'ファイルページ上限',
- max_line_length: '行長上限'
+ fileReadMaxChars: 'ファイル読み取り上限',
+ toolOutput: {
+ maxBytes: 'ターミナル出力上限',
+ maxLines: 'ファイルページ上限',
+ maxLineLength: '行長上限'
},
- code_execution: {
+ codeExecution: {
mode: 'コード実行モード'
},
approvals: {
mode: '承認モード',
timeout: '承認タイムアウト',
- mcp_reload_confirm: 'MCP 再読み込みの確認'
+ mcpReloadConfirm: 'MCP 再読み込みの確認'
},
- command_allowlist: 'コマンド許可リスト',
+ commandAllowlist: 'コマンド許可リスト',
security: {
- redact_secrets: 'シークレットを伏せる',
- allow_private_urls: 'プライベート URL を許可'
+ redactSecrets: 'シークレットを伏せる',
+ allowPrivateUrls: 'プライベート URL を許可'
},
browser: {
- allow_private_urls: 'ブラウザーのプライベート URL',
- auto_local_for_private_urls: 'プライベート URL にはローカルブラウザーを使用'
+ allowPrivateUrls: 'ブラウザーのプライベート URL',
+ autoLocalForPrivateUrls: 'プライベート URL にはローカルブラウザーを使用'
},
checkpoints: {
enabled: 'ファイルチェックポイント',
- max_snapshots: 'チェックポイント上限'
+ maxSnapshots: 'チェックポイント上限'
},
voice: {
- record_key: '音声ショートカット',
- max_recording_seconds: '最大録音時間',
- auto_tts: '応答を読み上げる'
+ recordKey: '音声ショートカット',
+ maxRecordingSeconds: '最大録音時間',
+ autoTts: '応答を読み上げる'
},
stt: {
enabled: '音声認識',
@@ -281,9 +281,9 @@ export const ja = defineLocale({
language: '文字起こし言語'
},
elevenlabs: {
- model_id: 'ElevenLabs STT モデル',
- language_code: 'ElevenLabs 言語',
- tag_audio_events: '音声イベントをタグ付け',
+ modelId: 'ElevenLabs STT モデル',
+ languageCode: 'ElevenLabs 言語',
+ tagAudioEvents: '音声イベントをタグ付け',
diarize: '話者分離'
}
},
@@ -297,15 +297,15 @@ export const ja = defineLocale({
voice: 'OpenAI 音声'
},
elevenlabs: {
- voice_id: 'ElevenLabs 音声',
- model_id: 'ElevenLabs モデル'
+ voiceId: 'ElevenLabs 音声',
+ modelId: 'ElevenLabs モデル'
}
},
memory: {
- memory_enabled: '永続メモリ',
- user_profile_enabled: 'ユーザープロファイル',
- memory_char_limit: 'メモリ予算',
- user_char_limit: 'プロファイル予算',
+ memoryEnabled: '永続メモリ',
+ userProfileEnabled: 'ユーザープロファイル',
+ memoryCharLimit: 'メモリ予算',
+ userCharLimit: 'プロファイル予算',
provider: 'メモリプロバイダー'
},
context: {
@@ -314,56 +314,56 @@ export const ja = defineLocale({
compression: {
enabled: '自動圧縮',
threshold: '圧縮しきい値',
- target_ratio: '圧縮目標',
- protect_last_n: '保護する直近メッセージ'
+ targetRatio: '圧縮目標',
+ protectLastN: '保護する直近メッセージ'
},
delegation: {
model: 'サブエージェントモデル',
provider: 'サブエージェントプロバイダー',
- max_iterations: 'サブエージェントターン上限',
- max_concurrent_children: '並列サブエージェント',
- child_timeout_seconds: 'サブエージェントタイムアウト',
- reasoning_effort: 'サブエージェント推論強度'
+ maxIterations: 'サブエージェントターン上限',
+ maxConcurrentChildren: '並列サブエージェント',
+ childTimeoutSeconds: 'サブエージェントタイムアウト',
+ reasoningEffort: 'サブエージェント推論強度'
},
updates: {
- non_interactive_local_changes: 'アプリ内更新時のローカル変更'
+ nonInteractiveLocalChanges: 'アプリ内更新時のローカル変更'
}
}),
fieldDescriptions: defineFieldCopy({
model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
- model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
- fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
+ modelContextLength: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
+ fallbackProviders: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
display: {
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
- show_reasoning: 'バックエンドが推論内容を提供したときに表示します。'
+ showReasoning: 'バックエンドが推論内容を提供したときに表示します。'
},
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
agent: {
- image_input_mode: '画像添付をモデルへ送る方法を制御します。',
- max_turns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
+ imageInputMode: '画像添付をモデルへ送る方法を制御します。',
+ maxTurns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
},
terminal: {
cwd: 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
- persistent_shell: 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
- env_passthrough: 'ツール実行へ渡す環境変数です。'
+ persistentShell: 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
+ envPassthrough: 'ツール実行へ渡す環境変数です。'
},
- code_execution: {
+ codeExecution: {
mode: 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。'
},
- file_read_max_chars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
+ fileReadMaxChars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
approvals: {
mode: '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
timeout: '承認プロンプトがタイムアウトするまで待つ時間です。'
},
security: {
- redact_secrets: '検出したシークレットを、可能な限りモデルから見える内容から隠します。'
+ redactSecrets: '検出したシークレットを、可能な限りモデルから見える内容から隠します。'
},
checkpoints: {
enabled: 'ファイル編集前にロールバック用スナップショットを作成します。'
},
memory: {
- memory_enabled: '将来のセッションに役立つ永続メモリを保存します。',
- user_profile_enabled: 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。'
+ memoryEnabled: '将来のセッションに役立つ永続メモリを保存します。',
+ userProfileEnabled: 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。'
},
context: {
engine: '長い会話がコンテキスト上限に近づいたときの管理戦略です。'
@@ -372,16 +372,16 @@ export const ja = defineLocale({
enabled: '会話が大きくなったとき、古いコンテキストを要約します。'
},
voice: {
- auto_tts: 'アシスタントの応答を自動で読み上げます。'
+ autoTts: 'アシスタントの応答を自動で読み上げます。'
},
stt: {
enabled: 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
elevenlabs: {
- language_code: '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。'
+ languageCode: '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。'
}
},
updates: {
- non_interactive_local_changes:
+ nonInteractiveLocalChanges:
'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
}
}),
diff --git a/apps/desktop/src/i18n/runtime.test.ts b/apps/desktop/src/i18n/runtime.test.ts
index 0c2bc4870b3..499fc1de6c9 100644
--- a/apps/desktop/src/i18n/runtime.test.ts
+++ b/apps/desktop/src/i18n/runtime.test.ts
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { fieldCopyForSchemaKey } from '@/app/settings/field-copy'
+
+import { TRANSLATIONS } from './catalog'
import { setRuntimeI18nLocale, translateNow } from './runtime'
import { zh } from './zh'
@@ -43,17 +46,25 @@ 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('.')
+ it('keeps translated settings field copy addressable from schema keys', () => {
+ const field = ['display', 'show_reasoning'].join('.')
- expect(zh.settings.fieldLabels[field]).toBe('人格')
- expect(zh.settings.fieldDescriptions[field]).toBe('新会话的默认助手风格。')
+ expect(fieldCopyForSchemaKey(zh.settings.fieldLabels, field)).toBe('推理过程块')
+ expect(fieldCopyForSchemaKey(zh.settings.fieldDescriptions, field)).toBe('当后端提供推理内容时予以显示。')
})
- it('falls back to English for untranslated desktop-only keys in partial locales', () => {
- setRuntimeI18nLocale('ja')
+ it('falls back to English when the active locale cannot resolve a key', () => {
+ const boot = TRANSLATIONS.ja.boot as { ready?: string }
+ const originalReady = boot.ready
- expect(translateNow('boot.ready')).toBe('Hermes Desktop is ready')
+ try {
+ boot.ready = undefined
+ setRuntimeI18nLocale('ja')
+
+ expect(translateNow('boot.ready')).toBe('Hermes Desktop is ready')
+ } finally {
+ boot.ready = originalReady
+ }
})
it('returns the key when no locale can resolve a path', () => {
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index dac65ca5d3a..09ce699ea09 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -213,59 +213,59 @@ export const zhHant = defineLocale({
},
fieldLabels: defineFieldCopy({
model: '預設模型',
- model_context_length: '上下文視窗',
- fallback_providers: '備用模型',
+ modelContextLength: '上下文視窗',
+ fallbackProviders: '備用模型',
toolsets: '已啟用工具集',
timezone: '時區',
display: {
personality: '人格',
- show_reasoning: '推理區塊'
+ showReasoning: '推理區塊'
},
agent: {
- max_turns: '最大代理步數',
- image_input_mode: '圖片附件',
- api_max_retries: 'API 重試次數',
- service_tier: '服務層級',
- tool_use_enforcement: '工具使用強制'
+ maxTurns: '最大代理步數',
+ imageInputMode: '圖片附件',
+ apiMaxRetries: 'API 重試次數',
+ serviceTier: '服務層級',
+ toolUseEnforcement: '工具使用強制'
},
terminal: {
cwd: '工作目錄',
backend: '執行後端',
timeout: '指令逾時',
- persistent_shell: '持久化 Shell',
- env_passthrough: '環境變數傳遞'
+ persistentShell: '持久化 Shell',
+ envPassthrough: '環境變數傳遞'
},
- file_read_max_chars: '檔案讀取上限',
- tool_output: {
- max_bytes: '終端機輸出上限',
- max_lines: '檔案頁面上限',
- max_line_length: '行長上限'
+ fileReadMaxChars: '檔案讀取上限',
+ toolOutput: {
+ maxBytes: '終端機輸出上限',
+ maxLines: '檔案頁面上限',
+ maxLineLength: '行長上限'
},
- code_execution: {
+ codeExecution: {
mode: '程式碼執行模式'
},
approvals: {
mode: '批准模式',
timeout: '批准逾時',
- mcp_reload_confirm: '確認 MCP 重新載入'
+ mcpReloadConfirm: '確認 MCP 重新載入'
},
- command_allowlist: '指令允許清單',
+ commandAllowlist: '指令允許清單',
security: {
- redact_secrets: '遮蔽密鑰',
- allow_private_urls: '允許私有 URL'
+ redactSecrets: '遮蔽密鑰',
+ allowPrivateUrls: '允許私有 URL'
},
browser: {
- allow_private_urls: '瀏覽器私有 URL',
- auto_local_for_private_urls: '私有 URL 使用本機瀏覽器'
+ allowPrivateUrls: '瀏覽器私有 URL',
+ autoLocalForPrivateUrls: '私有 URL 使用本機瀏覽器'
},
checkpoints: {
enabled: '檔案檢查點',
- max_snapshots: '檢查點上限'
+ maxSnapshots: '檢查點上限'
},
voice: {
- record_key: '語音快捷鍵',
- max_recording_seconds: '最長錄音時間',
- auto_tts: '朗讀回覆'
+ recordKey: '語音快捷鍵',
+ maxRecordingSeconds: '最長錄音時間',
+ autoTts: '朗讀回覆'
},
stt: {
enabled: '語音轉文字',
@@ -275,9 +275,9 @@ export const zhHant = defineLocale({
language: '轉寫語言'
},
elevenlabs: {
- model_id: 'ElevenLabs STT 模型',
- language_code: 'ElevenLabs 語言',
- tag_audio_events: '標記音訊事件',
+ modelId: 'ElevenLabs STT 模型',
+ languageCode: 'ElevenLabs 語言',
+ tagAudioEvents: '標記音訊事件',
diarize: '說話者分離'
}
},
@@ -291,15 +291,15 @@ export const zhHant = defineLocale({
voice: 'OpenAI 語音'
},
elevenlabs: {
- voice_id: 'ElevenLabs 語音',
- model_id: 'ElevenLabs 模型'
+ voiceId: 'ElevenLabs 語音',
+ modelId: 'ElevenLabs 模型'
}
},
memory: {
- memory_enabled: '持久記憶',
- user_profile_enabled: '使用者設定檔',
- memory_char_limit: '記憶預算',
- user_char_limit: '設定檔預算',
+ memoryEnabled: '持久記憶',
+ userProfileEnabled: '使用者設定檔',
+ memoryCharLimit: '記憶預算',
+ userCharLimit: '設定檔預算',
provider: '記憶提供方'
},
context: {
@@ -308,56 +308,56 @@ export const zhHant = defineLocale({
compression: {
enabled: '自動壓縮',
threshold: '壓縮閾值',
- target_ratio: '壓縮目標',
- protect_last_n: '保護最近訊息'
+ targetRatio: '壓縮目標',
+ protectLastN: '保護最近訊息'
},
delegation: {
model: '子代理模型',
provider: '子代理提供方',
- max_iterations: '子代理輪次上限',
- max_concurrent_children: '平行子代理',
- child_timeout_seconds: '子代理逾時',
- reasoning_effort: '子代理推理強度'
+ maxIterations: '子代理輪次上限',
+ maxConcurrentChildren: '平行子代理',
+ childTimeoutSeconds: '子代理逾時',
+ reasoningEffort: '子代理推理強度'
},
updates: {
- non_interactive_local_changes: '應用程式內更新的本機變更'
+ nonInteractiveLocalChanges: '應用程式內更新的本機變更'
}
}),
fieldDescriptions: defineFieldCopy({
model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
- model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
- fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
+ modelContextLength: '保留 0 會使用所選模型偵測到的上下文視窗。',
+ fallbackProviders: '預設模型失敗時要嘗試的備用 provider:model 項目。',
display: {
personality: '新工作階段的預設助手風格。',
- show_reasoning: '後端提供推理內容時顯示該區塊。'
+ showReasoning: '後端提供推理內容時顯示該區塊。'
},
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
agent: {
- image_input_mode: '控制圖片附件如何傳送給模型。',
- max_turns: 'Hermes 停止一次執行前的工具呼叫輪次上限。'
+ imageInputMode: '控制圖片附件如何傳送給模型。',
+ maxTurns: 'Hermes 停止一次執行前的工具呼叫輪次上限。'
},
terminal: {
cwd: '工具與終端機操作的預設專案資料夾。',
- persistent_shell: '後端支援時,在指令之間保留 Shell 狀態。',
- env_passthrough: '傳入工具執行的環境變數。'
+ persistentShell: '後端支援時,在指令之間保留 Shell 狀態。',
+ envPassthrough: '傳入工具執行的環境變數。'
},
- code_execution: {
+ codeExecution: {
mode: '程式碼執行被限制在目前專案中的嚴格程度。'
},
- file_read_max_chars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
+ fileReadMaxChars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
approvals: {
mode: 'Hermes 如何處理需要明確批准的指令。',
timeout: '批准提示逾時前等待的時間。'
},
security: {
- redact_secrets: '盡可能從模型可見內容中隱藏偵測到的密鑰。'
+ redactSecrets: '盡可能從模型可見內容中隱藏偵測到的密鑰。'
},
checkpoints: {
enabled: '在檔案編輯前建立可回復的快照。'
},
memory: {
- memory_enabled: '儲存有助於未來工作階段的持久記憶。',
- user_profile_enabled: '維護一份精簡的使用者偏好設定檔。'
+ memoryEnabled: '儲存有助於未來工作階段的持久記憶。',
+ userProfileEnabled: '維護一份精簡的使用者偏好設定檔。'
},
context: {
engine: '長對話接近上下文上限時的管理策略。'
@@ -366,16 +366,16 @@ export const zhHant = defineLocale({
enabled: '對話變大時摘要較早的上下文。'
},
voice: {
- auto_tts: '自動朗讀助手回覆。'
+ autoTts: '自動朗讀助手回覆。'
},
stt: {
enabled: '啟用本機或提供方支援的語音轉寫。',
elevenlabs: {
- language_code: '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。'
+ languageCode: '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。'
}
},
updates: {
- non_interactive_local_changes:
+ nonInteractiveLocalChanges:
'Hermes 從應用程式內更新自身時,保留本機原始碼變更(stash)或丟棄(discard)。終端機更新一律會詢問。'
}
}),
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 42f01a2d366..78b3c2fea25 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -213,59 +213,59 @@ export const zh: Translations = {
},
fieldLabels: defineFieldCopy({
model: '默认模型',
- model_context_length: '上下文窗口',
- fallback_providers: '备用模型',
+ modelContextLength: '上下文窗口',
+ fallbackProviders: '备用模型',
toolsets: '启用的工具集',
timezone: '时区',
display: {
personality: '人格',
- show_reasoning: '推理过程块'
+ showReasoning: '推理过程块'
},
agent: {
- max_turns: '最大智能体步数',
- image_input_mode: '图片附件',
- api_max_retries: 'API 重试次数',
- service_tier: '服务等级',
- tool_use_enforcement: '工具调用强制'
+ maxTurns: '最大智能体步数',
+ imageInputMode: '图片附件',
+ apiMaxRetries: 'API 重试次数',
+ serviceTier: '服务等级',
+ toolUseEnforcement: '工具调用强制'
},
terminal: {
cwd: '工作目录',
backend: '执行后端',
timeout: '命令超时',
- persistent_shell: '持久化 Shell',
- env_passthrough: '环境变量透传'
+ persistentShell: '持久化 Shell',
+ envPassthrough: '环境变量透传'
},
- file_read_max_chars: '文件读取上限',
- tool_output: {
- max_bytes: '终端输出上限',
- max_lines: '文件分页上限',
- max_line_length: '行长度上限'
+ fileReadMaxChars: '文件读取上限',
+ toolOutput: {
+ maxBytes: '终端输出上限',
+ maxLines: '文件分页上限',
+ maxLineLength: '行长度上限'
},
- code_execution: {
+ codeExecution: {
mode: '代码执行模式'
},
approvals: {
mode: '审批模式',
timeout: '审批超时',
- mcp_reload_confirm: '确认 MCP 重载'
+ mcpReloadConfirm: '确认 MCP 重载'
},
- command_allowlist: '命令白名单',
+ commandAllowlist: '命令白名单',
security: {
- redact_secrets: '隐去密钥',
- allow_private_urls: '允许私有 URL'
+ redactSecrets: '隐去密钥',
+ allowPrivateUrls: '允许私有 URL'
},
browser: {
- allow_private_urls: '浏览器私有 URL',
- auto_local_for_private_urls: '私有 URL 使用本地浏览器'
+ allowPrivateUrls: '浏览器私有 URL',
+ autoLocalForPrivateUrls: '私有 URL 使用本地浏览器'
},
checkpoints: {
enabled: '文件检查点',
- max_snapshots: '检查点上限'
+ maxSnapshots: '检查点上限'
},
voice: {
- record_key: '语音快捷键',
- max_recording_seconds: '最长录音时长',
- auto_tts: '朗读回复'
+ recordKey: '语音快捷键',
+ maxRecordingSeconds: '最长录音时长',
+ autoTts: '朗读回复'
},
stt: {
enabled: '语音转文字',
@@ -275,9 +275,9 @@ export const zh: Translations = {
language: '转写语言'
},
elevenlabs: {
- model_id: 'ElevenLabs STT 模型',
- language_code: 'ElevenLabs 语言',
- tag_audio_events: '标记音频事件',
+ modelId: 'ElevenLabs STT 模型',
+ languageCode: 'ElevenLabs 语言',
+ tagAudioEvents: '标记音频事件',
diarize: '说话人区分'
}
},
@@ -291,15 +291,15 @@ export const zh: Translations = {
voice: 'OpenAI 语音'
},
elevenlabs: {
- voice_id: 'ElevenLabs 语音',
- model_id: 'ElevenLabs 模型'
+ voiceId: 'ElevenLabs 语音',
+ modelId: 'ElevenLabs 模型'
}
},
memory: {
- memory_enabled: '持久记忆',
- user_profile_enabled: '用户画像',
- memory_char_limit: '记忆预算',
- user_char_limit: '画像预算',
+ memoryEnabled: '持久记忆',
+ userProfileEnabled: '用户画像',
+ memoryCharLimit: '记忆预算',
+ userCharLimit: '画像预算',
provider: '记忆提供方'
},
context: {
@@ -308,56 +308,56 @@ export const zh: Translations = {
compression: {
enabled: '自动压缩',
threshold: '压缩阈值',
- target_ratio: '压缩目标',
- protect_last_n: '保护最近消息'
+ targetRatio: '压缩目标',
+ protectLastN: '保护最近消息'
},
delegation: {
model: '子智能体模型',
provider: '子智能体提供方',
- max_iterations: '子智能体轮次上限',
- max_concurrent_children: '并行子智能体',
- child_timeout_seconds: '子智能体超时',
- reasoning_effort: '子智能体推理强度'
+ maxIterations: '子智能体轮次上限',
+ maxConcurrentChildren: '并行子智能体',
+ childTimeoutSeconds: '子智能体超时',
+ reasoningEffort: '子智能体推理强度'
},
updates: {
- non_interactive_local_changes: '应用内更新本地更改'
+ nonInteractiveLocalChanges: '应用内更新本地更改'
}
}),
fieldDescriptions: defineFieldCopy({
model: '用于新对话,除非你在输入框中选择其他模型。',
- model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
- fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
+ modelContextLength: '保持为 0 则使用所选模型检测到的上下文窗口。',
+ fallbackProviders: '默认模型失败时尝试的备用 provider:model 条目。',
display: {
personality: '新会话的默认助手风格。',
- show_reasoning: '当后端提供推理内容时予以显示。'
+ showReasoning: '当后端提供推理内容时予以显示。'
},
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
agent: {
- image_input_mode: '控制图片附件如何发送给模型。',
- max_turns: 'Hermes 停止一次运行前工具调用轮次的上限。'
+ imageInputMode: '控制图片附件如何发送给模型。',
+ maxTurns: 'Hermes 停止一次运行前工具调用轮次的上限。'
},
terminal: {
cwd: '工具与终端操作的默认项目目录。',
- persistent_shell: '当后端支持时,在命令之间保留 Shell 状态。',
- env_passthrough: '传入工具执行的环境变量。'
+ persistentShell: '当后端支持时,在命令之间保留 Shell 状态。',
+ envPassthrough: '传入工具执行的环境变量。'
},
- code_execution: {
+ codeExecution: {
mode: '代码执行被限定到当前项目的严格程度。'
},
- file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
+ fileReadMaxChars: 'Hermes 单次文件读取可读取的最大字符数。',
approvals: {
mode: 'Hermes 如何处理需要显式审批的命令。',
timeout: '审批提示在超时前等待的时长。'
},
security: {
- redact_secrets: '尽可能从模型可见内容中隐藏检测到的密钥。'
+ redactSecrets: '尽可能从模型可见内容中隐藏检测到的密钥。'
},
checkpoints: {
enabled: '在文件编辑前创建可回滚的快照。'
},
memory: {
- memory_enabled: '保存有助于未来会话的持久记忆。',
- user_profile_enabled: '维护一份精简的用户偏好画像。'
+ memoryEnabled: '保存有助于未来会话的持久记忆。',
+ userProfileEnabled: '维护一份精简的用户偏好画像。'
},
context: {
engine: '在接近上下文上限时管理长对话的策略。'
@@ -366,16 +366,16 @@ export const zh: Translations = {
enabled: '当对话变大时对较早的上下文进行摘要。'
},
voice: {
- auto_tts: '自动朗读助手回复。'
+ autoTts: '自动朗读助手回复。'
},
stt: {
enabled: '启用本地或提供方支持的语音转写。',
elevenlabs: {
- language_code: '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。'
+ languageCode: '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。'
}
},
updates: {
- non_interactive_local_changes:
+ nonInteractiveLocalChanges:
'Hermes 从应用内更新时(无终端提示),保留本地源码修改(暂存)或丢弃(放弃)。通过终端更新时始终会询问。'
}
}),
@@ -1338,7 +1338,7 @@ export const zh: Translations = {
'某个安装步骤失败。在 Windows 上,如果另一个 Hermes CLI 或桌面实例正在运行,可能会出现这种情况。请停止正在运行的 Hermes 实例后重试。可查看下面的详情或 desktop 日志中的完整记录。',
activeDesc: '这是一次性设置。Hermes 安装器正在下载依赖并配置你的机器。之后启动会跳过此步骤。',
progress: (completed, total) => `${completed}/${total} 个步骤已完成`,
- currentStage: stage => ` -- 当前: ${stage}`,
+ currentStage: stage => ` -- 当前:${stage}`,
fetchingManifest: '正在获取安装器 manifest...',
error: '错误',
hideOutput: '隐藏安装器输出',
@@ -1374,7 +1374,7 @@ export const zh: Translations = {
xai: { short: 'Grok 模型', description: '直接访问 xAI Grok 模型。' },
local: {
short: '自托管',
- description: '将 Hermes 指向本地或自托管的 OpenAI 兼容端点(vLLM、llama.cpp、Ollama 等)。'
+ description: '将 Hermes 指向本地或自托管的 OpenAI 兼容端点 (vLLM、llama.cpp、Ollama 等)。'
}
},
backToSignIn: '返回登录',
@@ -1422,11 +1422,11 @@ export const zh: Translations = {
modelPicker: {
title: '切换模型',
- current: '当前:',
+ current: '当前:',
unknown: '(未知)',
search: '筛选提供方和模型...',
noModels: '未找到模型。',
- persistGlobalSession: '全局保存(否则仅当前会话)',
+ persistGlobalSession: '全局保存 (否则仅当前会话)',
persistGlobal: '全局保存',
addProvider: '添加提供方',
loadFailed: '无法加载模型',
@@ -1480,7 +1480,7 @@ export const zh: Translations = {
checkingInference: '正在检查推理',
disconnected: '已断开',
openSystem: '打开系统面板',
- connection: label => `连接: ${label}`,
+ connection: label => `连接:${label}`,
recentActivity: '最近活动',
viewAllLogs: '查看全部日志 →',
messagingPlatforms: '消息平台'
@@ -1543,7 +1543,7 @@ export const zh: Translations = {
noProjectTitle: '没有项目',
noProjectBody: '从状态栏设置工作目录后即可浏览文件。',
unreadableTitle: '无法读取',
- unreadableBody: error => `无法读取此文件夹(${error})。`,
+ unreadableBody: error => `无法读取此文件夹 (${error})。`,
emptyTitle: '空文件夹',
emptyBody: '此文件夹为空。',
treeErrorTitle: '文件树错误',
@@ -1593,7 +1593,7 @@ export const zh: Translations = {
copy: '复制',
clear: '清除',
empty: '暂无控制台消息。',
- promptHeader: '预览控制台:',
+ promptHeader: '预览控制台:',
sentTitle: '已发送到对话',
sentMessage: count => `已将 ${count} 条日志添加到输入框`
},
@@ -1607,14 +1607,14 @@ export const zh: Translations = {
lookingRestart: taskId => `Hermes 正在查找要重启的预览服务器 (${taskId})`,
restartingTitle: '正在重启预览服务器',
restartingMessage: 'Hermes 正在后台工作。可在预览控制台查看进度。',
- startRestartFailed: message => `无法启动服务器重启: ${message}`,
+ startRestartFailed: message => `无法启动服务器重启:${message}`,
restartFailed: '服务器重启失败',
hideConsole: '隐藏预览控制台',
showConsole: '显示预览控制台',
hideDevTools: '隐藏预览 DevTools',
openDevTools: '打开预览 DevTools',
finishedRestarting: message => `Hermes 已完成预览服务器重启${message ? `: ${message}` : ''}`,
- failedRestarting: message => `服务器重启失败: ${message}`,
+ failedRestarting: message => `服务器重启失败:${message}`,
unknownError: '未知错误',
restartedTitle: '预览服务器已重启',
reloadingNow: '正在重新加载预览。',
@@ -1622,9 +1622,9 @@ export const zh: Translations = {
restartFailedMessage: 'Hermes 无法重启服务器。',
stillWorking: 'Hermes 仍在工作,但还没有收到重启结果。服务器命令可能正在前台运行。',
workspaceReloading: '工作区已变更,正在重新加载预览',
- fileChanged: url => `文件已变更,正在重新加载预览: ${url}`,
- filesChanged: (count, url) => `${count} 个文件变更,正在重新加载预览: ${url}`,
- watchFailed: message => `无法监听预览文件: ${message}`,
+ fileChanged: url => `文件已变更,正在重新加载预览:${url}`,
+ filesChanged: (count, url) => `${count} 个文件变更,正在重新加载预览:${url}`,
+ watchFailed: message => `无法监听预览文件:${message}`,
moduleMimeDescription:
'模块脚本使用了错误的 MIME 类型。这通常表示静态文件服务器正在服务 Vite/React 应用,而不是项目开发服务器。',
loadFailedConsole: (code, message) => `加载失败${code ? ` (${code})` : ''}: ${message}`,
@@ -1668,7 +1668,7 @@ export const zh: Translations = {
reject: '拒绝',
alwaysTitle: '始终允许此命令?',
alwaysDescription: pattern =>
- `这会将 “${pattern}” 模式加入永久允许列表(~/.hermes/config.yaml)。Hermes 对类似命令将不再询问,包括当前会话和未来会话。`,
+ `这会将“${pattern}”模式加入永久允许列表 (~/.hermes/config.yaml)。Hermes 对类似命令将不再询问,包括当前会话和未来会话。`,
alwaysAllow: '始终允许'
},
clarify: {
@@ -1676,7 +1676,7 @@ export const zh: Translations = {
gatewayDisconnected: 'Hermes 网关未连接',
sendFailed: '无法发送澄清响应',
loadingQuestion: '正在加载问题…',
- other: '其他(输入你的答案)',
+ other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: '⌘/Ctrl + Enter 发送',
back: '返回',
@@ -1730,7 +1730,7 @@ export const zh: Translations = {
emptySlashCommand: '空 slash 命令',
desktopCommands: '桌面端命令',
skillCommandsAvailable: count => `${count} 个技能命令可用。`,
- warningLine: message => `警告: ${message}`,
+ warningLine: message => `警告:${message}`,
yoloArmed: '此对话已启用 YOLO',
yoloOff: 'YOLO 已关闭',
yoloSystem: active => `此会话 YOLO ${active ? '已开启' : '已关闭'}`,