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 */} -
+
- {result.repo && ( - - - {result.repo} - - )} -
+
+ {result.repo && ( + + + {result.repo} + + )} {installed ? (
{/* 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 + + ) : ( + + )} +
+ {provider.tag && ( +

+ {provider.tag} +

+ )} + + {/* API key inputs */} + {provider.env_vars.length > 0 && ( +
+ {provider.env_vars.map((ev) => ( +
+
+ + {isSet[ev.key] && ( + + Saved + + )} +
+ + setDrafts((prev) => ({ + ...prev, + [ev.key]: e.target.value, + })) + } + /> + {ev.url && ( + + Get a key + + )} +
+ ))} + +
+ )} + + {/* 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. +

+ +
+ )} +
+ ); + }) + )} + + {/* 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} )} +
+ +
@@ -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 ( - - ) - })} +
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 = ( + + ) + + 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 ( + + ) + })} +
+ ) +} 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}
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 (
- + 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'} + label={visibleSelection.length > 0 ? copy.copySelected : copy.copyAll} text={() => formatConsoleEntries(sendableLogs)} > - Copy + {copy.copy}
@@ -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 (
) @@ -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 (
{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 => ( 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 && ( @@ -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}

- {PROFILE_NAME_HINT} + {p.nameHint}

@@ -116,22 +114,20 @@ export function CreateProfileDialog({ onCheckedChange={checked => setCloneFromDefault(checked === true)} /> - Clone from default - - Copy config, skills, and SOUL.md from your default profile. - + {p.cloneFromDefault} + {p.cloneFromDefaultDesc}