diff --git a/web/package-lock.json b/web/package-lock.json index 8299c8e49..71ca2c7a7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -64,7 +64,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1639,7 +1638,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1650,7 +1648,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1710,7 +1707,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -1988,7 +1984,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2097,7 +2092,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2374,7 +2368,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3338,7 +3331,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3399,7 +3391,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3409,7 +3400,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3676,7 +3666,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3762,7 +3751,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3884,7 +3872,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/App.tsx b/web/src/App.tsx index f2c72d5a6..3d2832ccb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, NavLink, Navigate } from "react-router-dom"; +import { useState, useEffect, useRef } from "react"; import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; import StatusPage from "@/pages/StatusPage"; import ConfigPage from "@/pages/ConfigPage"; @@ -8,89 +8,118 @@ import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { useI18n } from "@/i18n"; const NAV_ITEMS = [ - { path: "/", label: "Status", icon: Activity }, - { path: "/sessions", label: "Sessions", icon: MessageSquare }, - { path: "/analytics", label: "Analytics", icon: BarChart3 }, - { path: "/logs", label: "Logs", icon: FileText }, - { path: "/cron", label: "Cron", icon: Clock }, - { path: "/skills", label: "Skills", icon: Package }, - { path: "/config", label: "Config", icon: Settings }, - { path: "/env", label: "Keys", icon: KeyRound }, + { id: "status", labelKey: "status" as const, icon: Activity }, + { id: "sessions", labelKey: "sessions" as const, icon: MessageSquare }, + { id: "analytics", labelKey: "analytics" as const, icon: BarChart3 }, + { id: "logs", labelKey: "logs" as const, icon: FileText }, + { id: "cron", labelKey: "cron" as const, icon: Clock }, + { id: "skills", labelKey: "skills" as const, icon: Package }, + { id: "config", labelKey: "config" as const, icon: Settings }, + { id: "env", labelKey: "keys" as const, icon: KeyRound }, ] as const; +type PageId = (typeof NAV_ITEMS)[number]["id"]; + +const PAGE_COMPONENTS: Record = { + status: StatusPage, + sessions: SessionsPage, + analytics: AnalyticsPage, + logs: LogsPage, + cron: CronPage, + skills: SkillsPage, + config: ConfigPage, + env: EnvPage, +}; + export default function App() { + const [page, setPage] = useState("status"); + const [animKey, setAnimKey] = useState(0); + const initialRef = useRef(true); + const { t } = useI18n(); + + useEffect(() => { + // Skip the animation key bump on initial mount to avoid re-mounting + // the default page component (which causes duplicate API requests). + if (initialRef.current) { + initialRef.current = false; + return; + } + setAnimKey((k) => k + 1); + }, [page]); + + const PageComponent = PAGE_COMPONENTS[page]; + return (
+ {/* Global grain + warm glow (matches landing page) */}
-
+ {/* ---- Header with grid-border nav ---- */} +
+ {/* Brand — abbreviated on mobile */}
Hermes Agent
+ {/* Nav — icons only on mobile, icon+label on sm+ */} -
- - Web UI + {/* Right side: language switcher + version badge */} +
+ + + {t.app.webUi}
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - +
+
+ {/* ---- Footer ---- */}
- Hermes Agent + {t.app.footer.name} - Nous Research + {t.app.footer.org}
diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx new file mode 100644 index 000000000..4cc945e96 --- /dev/null +++ b/web/src/components/LanguageSwitcher.tsx @@ -0,0 +1,27 @@ +import { useI18n } from "@/i18n/context"; + +/** + * Compact language toggle — shows a clickable flag that switches between + * English and Chinese. Persists choice to localStorage. + */ +export function LanguageSwitcher() { + const { locale, setLocale, t } = useI18n(); + + const toggle = () => setLocale(locale === "en" ? "zh" : "en"); + + return ( + + ); +} diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 836ec4a1a..e0e756eca 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -3,29 +3,7 @@ import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; - -/** - * OAuthLoginModal — drives the in-browser OAuth flow for a single provider. - * - * Two variants share the same modal shell: - * - * - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes, - * pastes the resulting code back. We POST it to /submit which exchanges - * the (code + verifier) pair for tokens server-side. - * - * - Device code (Nous, OpenAI Codex): we display the verification URL - * and short user code; the backend polls the provider's token endpoint - * in a background thread; we poll /poll/{session_id} every 2s for status. - * - * Edge cases handled: - * - Popup blocker (we use plain anchor href + open in new tab; no popup - * window.open which is more likely to be blocked). - * - Modal dismissal mid-flight cancels the server-side session via DELETE. - * - Code expiry surfaces as a clear error state with retry button. - * - Polling continues to work if the user backgrounds the tab (setInterval - * keeps firing in modern browsers; we guard against polls firing after - * component unmount via an isMounted ref). - */ +import { useI18n } from "@/i18n"; interface Props { provider: OAuthProvider; @@ -45,6 +23,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props const [codeCopied, setCodeCopied] = useState(false); const isMounted = useRef(true); const pollTimer = useRef(null); + const { t } = useI18n(); // Initiate flow on mount useEffect(() => { @@ -57,10 +36,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props setSecondsLeft(resp.expires_in); setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); if (resp.flow === "pkce") { - // Auto-open the auth URL in a new tab window.open(resp.auth_url, "_blank", "noopener,noreferrer"); } else { - // Device-code: open the verification URL automatically window.open(resp.verification_url, "_blank", "noopener,noreferrer"); } }) @@ -73,7 +50,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); }; - // We only want to start the flow once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -85,16 +61,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props if (!isMounted.current) return; setSecondsLeft((s) => { if (s !== null && s <= 1) { - // Session expired — transition to error state setPhase("error"); - setErrorMsg("Session expired. Click Retry to start a new login."); + setErrorMsg(t.oauth.sessionExpired); return 0; } return s !== null && s > 0 ? s - 1 : 0; }); }, 1000); return () => window.clearInterval(tick); - }, [secondsLeft, phase]); + }, [secondsLeft, phase, t]); // Device-code: poll backend every 2s useEffect(() => { @@ -115,7 +90,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props if (pollTimer.current !== null) window.clearInterval(pollTimer.current); } } catch (e) { - // 404 = session expired/cleaned up; treat as error if (!isMounted.current) return; setPhase("error"); setErrorMsg(`Polling failed: ${e}`); @@ -151,12 +125,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props }; const handleClose = async () => { - // Cancel server session if still in flight if (start && phase !== "approved" && phase !== "error") { try { await api.cancelOAuthSession(start.session_id); } catch { - // ignore — server-side TTL will clean it up anyway + // ignore } } onClose(); @@ -172,7 +145,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props } }; - // Backdrop click closes const handleBackdrop = (e: React.MouseEvent) => { if (e.target === e.currentTarget) handleClose(); }; @@ -197,18 +169,18 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props type="button" onClick={handleClose} className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors" - aria-label="Close" + aria-label={t.common.close} >

- Connect {provider.name} + {t.oauth.connect} {provider.name}

{secondsLeft !== null && phase !== "approved" && phase !== "error" && (

- Session expires in {fmtTime(secondsLeft)} + {t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}

)}
@@ -217,7 +189,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "starting" && (
- Initiating login flow… + {t.oauth.initiatingLogin}
)} @@ -225,18 +197,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {start?.flow === "pkce" && phase === "awaiting_user" && ( <>
    -
  1. - A new tab opened to claude.ai. Sign in - and click Authorize. -
  2. -
  3. Copy the authorization code shown after authorizing.
  4. -
  5. Paste it below and submit.
  6. +
  7. {t.oauth.pkceStep1}
  8. +
  9. {t.oauth.pkceStep2}
  10. +
  11. {t.oauth.pkceStep3}
setPkceCode(e.target.value)} - placeholder="Paste authorization code (with #state suffix is fine)" + placeholder={t.oauth.pasteCode} onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} autoFocus /> @@ -248,10 +217,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > - Re-open auth page + {t.oauth.reOpenAuth}
@@ -262,7 +231,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "submitting" && (
- Exchanging code for tokens… + {t.oauth.exchangingCode}
)} @@ -270,7 +239,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {start?.flow === "device_code" && phase === "polling" && ( <>

- A new tab opened. Enter this code if prompted: + {t.oauth.enterCodePrompt}

@@ -296,11 +265,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > - Re-open verification page + {t.oauth.reOpenVerification}
- Waiting for you to authorize in the browser… + {t.oauth.waitingAuth}
)} @@ -309,7 +278,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "approved" && (
- Connected! Closing… + {t.oauth.connectedClosing}
)} @@ -317,16 +286,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {phase === "error" && ( <>
- {errorMsg || "Login failed."} + {errorMsg || t.oauth.loginFailed}
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 4449ac9b1..513afc00c 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -5,29 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; - -/** - * OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its - * current connection status, a truncated token preview when connected, and - * action buttons (Copy CLI command for setup, Disconnect for cleanup). - * - * Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI - * command. Phase 2 will add in-browser PKCE / device-code flows so users - * never need to drop to a terminal. - */ +import { useI18n } from "@/i18n"; interface Props { onError?: (msg: string) => void; onSuccess?: (msg: string) => void; } -const FLOW_LABELS: Record = { - pkce: "Browser login (PKCE)", - device_code: "Device code", - external: "External CLI", -}; - -function formatExpiresAt(expiresAt: string | null | undefined): string | null { +function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null { if (!expiresAt) return null; try { const dt = new Date(expiresAt); @@ -36,11 +21,11 @@ function formatExpiresAt(expiresAt: string | null | undefined): string | null { const diff = dt.getTime() - now; if (diff < 0) return "expired"; const mins = Math.floor(diff / 60_000); - if (mins < 60) return `expires in ${mins}m`; + if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`); const hours = Math.floor(mins / 60); - if (hours < 24) return `expires in ${hours}h`; + if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`); const days = Math.floor(hours / 24); - return `expires in ${days}d`; + return expiresInTemplate.replace("{time}", `${days}d`); } catch { return null; } @@ -51,10 +36,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [copiedId, setCopiedId] = useState(null); - // Provider that the login modal is currently open for. null = modal closed. const [loginFor, setLoginFor] = useState(null); + const { t } = useI18n(); - // Use refs for callbacks to avoid re-creating refresh() when parent re-renders const onErrorRef = useRef(onError); onErrorRef.current = onError; @@ -83,16 +67,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { }; const handleDisconnect = async (provider: OAuthProvider) => { - if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) { + if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) { return; } setBusyId(provider.id); try { await api.disconnectOAuthProvider(provider.id); - onSuccess?.(`${provider.name} disconnected`); + onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`); refresh(); } catch (e) { - onError?.(`Disconnect failed: ${e}`); + onError?.(`${t.oauth.disconnect} failed: ${e}`); } finally { setBusyId(null); } @@ -107,7 +91,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
- Provider Logins (OAuth) + {t.oauth.providerLogins}
- {connectedCount} of {totalCount} OAuth providers connected. Login flows currently - run via the CLI; click Copy command and paste into a terminal to set up. + {t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))} @@ -133,12 +116,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} {providers && providers.length === 0 && (

- No OAuth-capable providers detected. + {t.oauth.noProviders}

)}
{providers?.map((p) => { - const expiresLabel = formatExpiresAt(p.status.expires_at); + const expiresLabel = formatExpiresAt(p.status.expires_at, t.oauth.expiresIn); const isBusy = busyId === p.id; return (
{p.name} - {FLOW_LABELS[p.flow]} + {t.oauth.flowLabels[p.flow]} {p.status.logged_in && ( - Connected + {t.oauth.connected} )} {expiresLabel === "expired" && ( - Expired + {t.oauth.expired} )} {expiresLabel && expiresLabel !== "expired" && ( @@ -187,11 +170,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { )} {!p.status.logged_in && ( - Not connected. Run{" "} - + {t.oauth.notConnected.split("{command}")[0]} + {p.cli_command} - {" "} - in a terminal. + + {t.oauth.notConnected.split("{command}")[1]} )} {p.status.error && ( @@ -222,10 +205,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { size="sm" onClick={() => setLoginFor(p)} className="text-xs h-7" - title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`} > - Login + {t.oauth.login} )} {!p.status.logged_in && ( @@ -234,14 +216,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { size="sm" onClick={() => handleCopy(p)} className="text-xs h-7" - title="Copy CLI command (for external / fallback)" + title={t.oauth.copyCliCommand} > {copiedId === p.id ? ( - <>Copied ✓ + <>{t.oauth.copied} ) : ( <> - CLI + {t.oauth.cli} )} @@ -259,13 +241,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { ) : ( )} - Disconnect + {t.oauth.disconnect} )} {p.status.logged_in && p.flow === "external" && ( - Managed externally + {t.oauth.managedExternally} )}
@@ -279,7 +261,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { provider={loginFor} onClose={() => { setLoginFor(null); - refresh(); // always refresh on close so token preview updates after login + refresh(); }} onSuccess={(msg) => onSuccess?.(msg)} onError={(msg) => onError?.(msg)} diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx new file mode 100644 index 000000000..6fc6f6e56 --- /dev/null +++ b/web/src/i18n/context.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import type { Locale, Translations } from "./types"; +import { en } from "./en"; +import { zh } from "./zh"; + +const TRANSLATIONS: Record = { en, zh }; +const STORAGE_KEY = "hermes-locale"; + +function getInitialLocale(): Locale { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "en" || stored === "zh") return stored; + } catch { + // SSR or privacy mode + } + return "en"; +} + +interface I18nContextValue { + locale: Locale; + setLocale: (l: Locale) => void; + t: Translations; +} + +const I18nContext = createContext({ + locale: "en", + setLocale: () => {}, + t: en, +}); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(getInitialLocale); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + try { + localStorage.setItem(STORAGE_KEY, l); + } catch { + // ignore + } + }, []); + + const value: I18nContextValue = { + locale, + setLocale, + t: TRANSLATIONS[locale], + }; + + return ( + + {children} + + ); +} + +export function useI18n() { + return useContext(I18nContext); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts new file mode 100644 index 000000000..8b387b463 --- /dev/null +++ b/web/src/i18n/en.ts @@ -0,0 +1,275 @@ +import type { Translations } from "./types"; + +export const en: Translations = { + common: { + save: "Save", + saving: "Saving...", + cancel: "Cancel", + close: "Close", + delete: "Delete", + refresh: "Refresh", + retry: "Retry", + search: "Search...", + loading: "Loading...", + create: "Create", + creating: "Creating...", + set: "Set", + replace: "Replace", + clear: "Clear", + live: "Live", + off: "Off", + enabled: "enabled", + disabled: "disabled", + active: "active", + inactive: "inactive", + unknown: "unknown", + untitled: "Untitled", + none: "None", + form: "Form", + noResults: "No results", + of: "of", + page: "Page", + msgs: "msgs", + tools: "tools", + match: "match", + other: "Other", + configured: "configured", + removed: "removed", + failedToToggle: "Failed to toggle", + failedToRemove: "Failed to remove", + failedToReveal: "Failed to reveal", + collapse: "Collapse", + expand: "Expand", + general: "General", + messaging: "Messaging", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + webUi: "Web UI", + footer: { + name: "Hermes Agent", + org: "Nous Research", + }, + nav: { + status: "Status", + sessions: "Sessions", + analytics: "Analytics", + logs: "Logs", + cron: "Cron", + skills: "Skills", + config: "Config", + keys: "Keys", + }, + }, + + status: { + agent: "Agent", + gateway: "Gateway", + activeSessions: "Active Sessions", + recentSessions: "Recent Sessions", + connectedPlatforms: "Connected Platforms", + running: "Running", + starting: "Starting", + failed: "Failed", + stopped: "Stopped", + connected: "Connected", + disconnected: "Disconnected", + error: "Error", + notRunning: "Not running", + startFailed: "Start failed", + pid: "PID", + noneRunning: "None", + gatewayFailedToStart: "Gateway failed to start", + lastUpdate: "Last update", + platformError: "error", + platformDisconnected: "disconnected", + }, + + sessions: { + title: "Sessions", + searchPlaceholder: "Search message content...", + noSessions: "No sessions yet", + noMatch: "No sessions match your search", + startConversation: "Start a conversation to see it here", + noMessages: "No messages", + untitledSession: "Untitled session", + deleteSession: "Delete session", + previousPage: "Previous page", + nextPage: "Next page", + roles: { + user: "User", + assistant: "Assistant", + system: "System", + tool: "Tool", + }, + }, + + analytics: { + period: "Period:", + totalTokens: "Total Tokens", + totalSessions: "Total Sessions", + apiCalls: "API Calls", + dailyTokenUsage: "Daily Token Usage", + dailyBreakdown: "Daily Breakdown", + perModelBreakdown: "Per-Model Breakdown", + input: "Input", + output: "Output", + total: "Total", + noUsageData: "No usage data for this period", + startSession: "Start a session to see analytics here", + date: "Date", + model: "Model", + tokens: "Tokens", + perDayAvg: "/day avg", + acrossModels: "across {count} models", + inOut: "{input} in / {output} out", + }, + + logs: { + title: "Logs", + autoRefresh: "Auto-refresh", + file: "File", + level: "Level", + component: "Component", + lines: "Lines", + noLogLines: "No log lines found", + }, + + cron: { + newJob: "New Cron Job", + nameOptional: "Name (optional)", + namePlaceholder: "e.g. Daily summary", + prompt: "Prompt", + promptPlaceholder: "What should the agent do on each run?", + schedule: "Schedule (cron expression)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Deliver to", + scheduledJobs: "Scheduled Jobs", + noJobs: "No cron jobs configured. Create one above.", + last: "Last", + next: "Next", + pause: "Pause", + resume: "Resume", + triggerNow: "Trigger now", + delivery: { + local: "Local", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + skills: { + title: "Skills", + searchPlaceholder: "Search skills and toolsets...", + enabledOf: "{enabled}/{total} enabled", + all: "All", + noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/", + noSkillsMatch: "No skills match your search or filter.", + skillCount: "{count} skill{s}", + noDescription: "No description available.", + toolsets: "Toolsets", + noToolsetsMatch: "No toolsets match the search.", + setupNeeded: "Setup needed", + disabledForCli: "Disabled for CLI", + more: "+{count} more", + }, + + config: { + configPath: "~/.hermes/config.yaml", + exportConfig: "Export config as JSON", + importConfig: "Import config from JSON", + resetDefaults: "Reset to defaults", + rawYaml: "Raw YAML Configuration", + searchResults: "Search Results", + fields: "field{s}", + noFieldsMatch: 'No fields match "{query}"', + configSaved: "Configuration saved", + yamlConfigSaved: "YAML config saved", + failedToSave: "Failed to save", + failedToSaveYaml: "Failed to save YAML", + failedToLoadRaw: "Failed to load raw config", + configImported: "Config imported — review and save", + invalidJson: "Invalid JSON file", + categories: { + general: "General", + agent: "Agent", + terminal: "Terminal", + display: "Display", + delegation: "Delegation", + memory: "Memory", + compression: "Compression", + security: "Security", + browser: "Browser", + voice: "Voice", + tts: "Text-to-Speech", + stt: "Speech-to-Text", + logging: "Logging", + discord: "Discord", + auxiliary: "Auxiliary", + }, + }, + + env: { + description: "Manage API keys and secrets stored in", + changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.", + hideAdvanced: "Hide Advanced", + showAdvanced: "Show Advanced", + llmProviders: "LLM Providers", + providersConfigured: "{configured} of {total} providers configured", + getKey: "Get key", + notConfigured: "{count} not configured", + notSet: "Not set", + keysCount: "{count} key{s}", + enterValue: "Enter value...", + replaceCurrentValue: "Replace current value ({preview})", + showValue: "Show real value", + hideValue: "Hide value", + }, + + oauth: { + title: "Provider Logins (OAuth)", + providerLogins: "Provider Logins (OAuth)", + description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.", + connected: "Connected", + expired: "Expired", + notConnected: "Not connected. Run {command} in a terminal.", + runInTerminal: "in a terminal.", + noProviders: "No OAuth-capable providers detected.", + login: "Login", + disconnect: "Disconnect", + managedExternally: "Managed externally", + copied: "Copied ✓", + cli: "CLI", + copyCliCommand: "Copy CLI command (for external / fallback)", + connect: "Connect", + sessionExpires: "Session expires in {time}", + initiatingLogin: "Initiating login flow…", + exchangingCode: "Exchanging code for tokens…", + connectedClosing: "Connected! Closing…", + loginFailed: "Login failed.", + sessionExpired: "Session expired. Click Retry to start a new login.", + reOpenAuth: "Re-open auth page", + reOpenVerification: "Re-open verification page", + submitCode: "Submit code", + pasteCode: "Paste authorization code (with #state suffix is fine)", + waitingAuth: "Waiting for you to authorize in the browser…", + enterCodePrompt: "A new tab opened. Enter this code if prompted:", + pkceStep1: "A new tab opened to claude.ai. Sign in and click Authorize.", + pkceStep2: "Copy the authorization code shown after authorizing.", + pkceStep3: "Paste it below and submit.", + flowLabels: { + pkce: "Browser login (PKCE)", + device_code: "Device code", + external: "External CLI", + }, + expiresIn: "expires in {time}", + }, + + language: { + switchTo: "Switch to Chinese", + }, +}; diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 000000000..7a9a9471e --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,2 @@ +export { I18nProvider, useI18n } from "./context"; +export type { Locale, Translations } from "./types"; diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts new file mode 100644 index 000000000..86b21c405 --- /dev/null +++ b/web/src/i18n/types.ts @@ -0,0 +1,287 @@ +export type Locale = "en" | "zh"; + +export interface Translations { + // ── Common ── + common: { + save: string; + saving: string; + cancel: string; + close: string; + delete: string; + refresh: string; + retry: string; + search: string; + loading: string; + create: string; + creating: string; + set: string; + replace: string; + clear: string; + live: string; + off: string; + enabled: string; + disabled: string; + active: string; + inactive: string; + unknown: string; + untitled: string; + none: string; + form: string; + noResults: string; + of: string; + page: string; + msgs: string; + tools: string; + match: string; + other: string; + configured: string; + removed: string; + failedToToggle: string; + failedToRemove: string; + failedToReveal: string; + collapse: string; + expand: string; + general: string; + messaging: string; + }; + + // ── App shell ── + app: { + brand: string; + brandShort: string; + webUi: string; + footer: { + name: string; + org: string; + }; + nav: { + status: string; + sessions: string; + analytics: string; + logs: string; + cron: string; + skills: string; + config: string; + keys: string; + }; + }; + + // ── Status page ── + status: { + agent: string; + gateway: string; + activeSessions: string; + recentSessions: string; + connectedPlatforms: string; + running: string; + starting: string; + failed: string; + stopped: string; + connected: string; + disconnected: string; + error: string; + notRunning: string; + startFailed: string; + pid: string; + noneRunning: string; + gatewayFailedToStart: string; + lastUpdate: string; + platformError: string; + platformDisconnected: string; + }; + + // ── Sessions page ── + sessions: { + title: string; + searchPlaceholder: string; + noSessions: string; + noMatch: string; + startConversation: string; + noMessages: string; + untitledSession: string; + deleteSession: string; + previousPage: string; + nextPage: string; + roles: { + user: string; + assistant: string; + system: string; + tool: string; + }; + }; + + // ── Analytics page ── + analytics: { + period: string; + totalTokens: string; + totalSessions: string; + apiCalls: string; + dailyTokenUsage: string; + dailyBreakdown: string; + perModelBreakdown: string; + input: string; + output: string; + total: string; + noUsageData: string; + startSession: string; + date: string; + model: string; + tokens: string; + perDayAvg: string; + acrossModels: string; + inOut: string; + }; + + // ── Logs page ── + logs: { + title: string; + autoRefresh: string; + file: string; + level: string; + component: string; + lines: string; + noLogLines: string; + }; + + // ── Cron page ── + cron: { + newJob: string; + nameOptional: string; + namePlaceholder: string; + prompt: string; + promptPlaceholder: string; + schedule: string; + schedulePlaceholder: string; + deliverTo: string; + scheduledJobs: string; + noJobs: string; + last: string; + next: string; + pause: string; + resume: string; + triggerNow: string; + delivery: { + local: string; + telegram: string; + discord: string; + slack: string; + email: string; + }; + }; + + // ── Skills page ── + skills: { + title: string; + searchPlaceholder: string; + enabledOf: string; + all: string; + noSkills: string; + noSkillsMatch: string; + skillCount: string; + noDescription: string; + toolsets: string; + noToolsetsMatch: string; + setupNeeded: string; + disabledForCli: string; + more: string; + }; + + // ── Config page ── + config: { + configPath: string; + exportConfig: string; + importConfig: string; + resetDefaults: string; + rawYaml: string; + searchResults: string; + fields: string; + noFieldsMatch: string; + configSaved: string; + yamlConfigSaved: string; + failedToSave: string; + failedToSaveYaml: string; + failedToLoadRaw: string; + configImported: string; + invalidJson: string; + categories: { + general: string; + agent: string; + terminal: string; + display: string; + delegation: string; + memory: string; + compression: string; + security: string; + browser: string; + voice: string; + tts: string; + stt: string; + logging: string; + discord: string; + auxiliary: string; + }; + }; + + // ── Env / Keys page ── + env: { + description: string; + changesNote: string; + hideAdvanced: string; + showAdvanced: string; + llmProviders: string; + providersConfigured: string; + getKey: string; + notConfigured: string; + notSet: string; + keysCount: string; + enterValue: string; + replaceCurrentValue: string; + showValue: string; + hideValue: string; + }; + + // ── OAuth ── + oauth: { + title: string; + providerLogins: string; + description: string; + connected: string; + expired: string; + notConnected: string; + runInTerminal: string; + noProviders: string; + login: string; + disconnect: string; + managedExternally: string; + copied: string; + cli: string; + copyCliCommand: string; + connect: string; + sessionExpires: string; + initiatingLogin: string; + exchangingCode: string; + connectedClosing: string; + loginFailed: string; + sessionExpired: string; + reOpenAuth: string; + reOpenVerification: string; + submitCode: string; + pasteCode: string; + waitingAuth: string; + enterCodePrompt: string; + pkceStep1: string; + pkceStep2: string; + pkceStep3: string; + flowLabels: { + pkce: string; + device_code: string; + external: string; + }; + expiresIn: string; + }; + + // ── Language switcher ── + language: { + switchTo: string; + }; +} diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts new file mode 100644 index 000000000..5138cae05 --- /dev/null +++ b/web/src/i18n/zh.ts @@ -0,0 +1,275 @@ +import type { Translations } from "./types"; + +export const zh: Translations = { + common: { + save: "保存", + saving: "保存中...", + cancel: "取消", + close: "关闭", + delete: "删除", + refresh: "刷新", + retry: "重试", + search: "搜索...", + loading: "加载中...", + create: "创建", + creating: "创建中...", + set: "设置", + replace: "替换", + clear: "清除", + live: "在线", + off: "离线", + enabled: "已启用", + disabled: "已禁用", + active: "活跃", + inactive: "未激活", + unknown: "未知", + untitled: "无标题", + none: "无", + form: "表单", + noResults: "无结果", + of: "/", + page: "页", + msgs: "消息", + tools: "工具", + match: "匹配", + other: "其他", + configured: "已配置", + removed: "已移除", + failedToToggle: "切换失败", + failedToRemove: "移除失败", + failedToReveal: "显示失败", + collapse: "折叠", + expand: "展开", + general: "通用", + messaging: "消息平台", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + webUi: "管理面板", + footer: { + name: "Hermes Agent", + org: "Nous Research", + }, + nav: { + status: "状态", + sessions: "会话", + analytics: "分析", + logs: "日志", + cron: "定时任务", + skills: "技能", + config: "配置", + keys: "密钥", + }, + }, + + status: { + agent: "代理", + gateway: "网关", + activeSessions: "活跃会话", + recentSessions: "最近会话", + connectedPlatforms: "已连接平台", + running: "运行中", + starting: "启动中", + failed: "失败", + stopped: "已停止", + connected: "已连接", + disconnected: "已断开", + error: "错误", + notRunning: "未运行", + startFailed: "启动失败", + pid: "进程", + noneRunning: "无", + gatewayFailedToStart: "网关启动失败", + lastUpdate: "最后更新", + platformError: "错误", + platformDisconnected: "已断开", + }, + + sessions: { + title: "会话", + searchPlaceholder: "搜索消息内容...", + noSessions: "暂无会话", + noMatch: "没有匹配的会话", + startConversation: "开始对话后将显示在此处", + noMessages: "暂无消息", + untitledSession: "无标题会话", + deleteSession: "删除会话", + previousPage: "上一页", + nextPage: "下一页", + roles: { + user: "用户", + assistant: "助手", + system: "系统", + tool: "工具", + }, + }, + + analytics: { + period: "时间范围:", + totalTokens: "总 Token 数", + totalSessions: "总会话数", + apiCalls: "API 调用", + dailyTokenUsage: "每日 Token 用量", + dailyBreakdown: "每日明细", + perModelBreakdown: "模型用量明细", + input: "输入", + output: "输出", + total: "总计", + noUsageData: "该时间段暂无使用数据", + startSession: "开始会话后将在此显示分析数据", + date: "日期", + model: "模型", + tokens: "Token", + perDayAvg: "/天 平均", + acrossModels: "共 {count} 个模型", + inOut: "输入 {input} / 输出 {output}", + }, + + logs: { + title: "日志", + autoRefresh: "自动刷新", + file: "文件", + level: "级别", + component: "组件", + lines: "行数", + noLogLines: "未找到日志记录", + }, + + cron: { + newJob: "新建定时任务", + nameOptional: "名称(可选)", + namePlaceholder: "例如:每日总结", + prompt: "提示词", + promptPlaceholder: "代理每次运行时应执行什么操作?", + schedule: "调度表达式(cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "投递至", + scheduledJobs: "已调度任务", + noJobs: "暂无定时任务。在上方创建一个。", + last: "上次", + next: "下次", + pause: "暂停", + resume: "恢复", + triggerNow: "立即触发", + delivery: { + local: "本地", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "邮件", + }, + }, + + skills: { + title: "技能", + searchPlaceholder: "搜索技能和工具集...", + enabledOf: "已启用 {enabled}/{total}", + all: "全部", + noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载", + noSkillsMatch: "没有匹配的技能。", + skillCount: "{count} 个技能", + noDescription: "暂无描述。", + toolsets: "工具集", + noToolsetsMatch: "没有匹配的工具集。", + setupNeeded: "需要配置", + disabledForCli: "CLI 已禁用", + more: "还有 {count} 个", + }, + + config: { + configPath: "~/.hermes/config.yaml", + exportConfig: "导出配置为 JSON", + importConfig: "从 JSON 导入配置", + resetDefaults: "恢复默认值", + rawYaml: "原始 YAML 配置", + searchResults: "搜索结果", + fields: "个字段", + noFieldsMatch: '没有匹配"{query}"的字段', + configSaved: "配置已保存", + yamlConfigSaved: "YAML 配置已保存", + failedToSave: "保存失败", + failedToSaveYaml: "YAML 保存失败", + failedToLoadRaw: "加载原始配置失败", + configImported: "配置已导入 — 请检查后保存", + invalidJson: "无效的 JSON 文件", + categories: { + general: "通用", + agent: "代理", + terminal: "终端", + display: "显示", + delegation: "委托", + memory: "记忆", + compression: "压缩", + security: "安全", + browser: "浏览器", + voice: "语音", + tts: "文字转语音", + stt: "语音转文字", + logging: "日志", + discord: "Discord", + auxiliary: "辅助", + }, + }, + + env: { + description: "管理存储在以下位置的 API 密钥和凭据", + changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。", + hideAdvanced: "隐藏高级选项", + showAdvanced: "显示高级选项", + llmProviders: "LLM 提供商", + providersConfigured: "已配置 {configured}/{total} 个提供商", + getKey: "获取密钥", + notConfigured: "{count} 个未配置", + notSet: "未设置", + keysCount: "{count} 个密钥", + enterValue: "输入值...", + replaceCurrentValue: "替换当前值({preview})", + showValue: "显示实际值", + hideValue: "隐藏值", + }, + + oauth: { + title: "提供商登录(OAuth)", + providerLogins: "提供商登录(OAuth)", + description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。", + connected: "已连接", + expired: "已过期", + notConnected: "未连接。在终端中运行 {command}。", + runInTerminal: "在终端中。", + noProviders: "未检测到支持 OAuth 的提供商。", + login: "登录", + disconnect: "断开连接", + managedExternally: "外部管理", + copied: "已复制 ✓", + cli: "CLI", + copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", + connect: "连接", + sessionExpires: "会话将在 {time} 后过期", + initiatingLogin: "正在启动登录流程…", + exchangingCode: "正在交换令牌…", + connectedClosing: "已连接!正在关闭…", + loginFailed: "登录失败。", + sessionExpired: "会话已过期。点击重试以开始新的登录。", + reOpenAuth: "重新打开授权页面", + reOpenVerification: "重新打开验证页面", + submitCode: "提交代码", + pasteCode: "粘贴授权代码(包含 #state 后缀也可以)", + waitingAuth: "等待您在浏览器中授权…", + enterCodePrompt: "已在新标签页中打开。如果需要,请输入以下代码:", + pkceStep1: "已在新标签页打开 claude.ai。请登录并点击「授权」。", + pkceStep2: "复制授权后显示的授权代码。", + pkceStep3: "将代码粘贴到下方并提交。", + flowLabels: { + pkce: "浏览器登录(PKCE)", + device_code: "设备代码", + external: "外部 CLI", + }, + expiresIn: "{time}后过期", + }, + + language: { + switchTo: "切换到英文", + }, +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index df4d851c4..ede367cc3 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,10 @@ import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; import "./index.css"; import App from "./App"; +import { I18nProvider } from "./i18n"; createRoot(document.getElementById("root")!).render( - + - , + , ); diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 3af5e2415..fe5bd75f4 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useCallback } from "react"; -import { formatTokenCount } from "@/lib/format"; import { BarChart3, Cpu, @@ -10,6 +9,7 @@ import { api } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useI18n } from "@/i18n"; const PERIODS = [ { label: "7d", days: 7 }, @@ -19,7 +19,11 @@ const PERIODS = [ const CHART_HEIGHT_PX = 160; -const formatTokens = formatTokenCount; +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} function formatDate(day: string): string { try { @@ -56,6 +60,7 @@ function SummaryCard({ } function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { + const { t } = useI18n(); if (daily.length === 0) return null; const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1); @@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
- Daily Token Usage + {t.analytics.dailyTokenUsage}
-
- Input +
+ {t.analytics.input}
-
- Output +
+ {t.analytics.output}
@@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { > {/* Tooltip */}
-
+
{formatDate(d.day)}
-
Input: {formatTokens(d.input_tokens)}
-
Output: {formatTokens(d.output_tokens)}
-
Total: {formatTokens(total)}
+
{t.analytics.input}: {formatTokens(d.input_tokens)}
+
{t.analytics.output}: {formatTokens(d.output_tokens)}
+
{t.analytics.total}: {formatTokens(total)}
{/* Input bar */} @@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { } function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { + const { t } = useI18n(); if (daily.length === 0) return null; const sorted = [...daily].reverse(); @@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
- Daily Breakdown + {t.analytics.dailyBreakdown}
@@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { - - - - + + + + @@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { } function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { + const { t } = useI18n(); if (models.length === 0) return null; const sorted = [...models].sort( @@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
- Per-Model Breakdown + {t.analytics.perModelBreakdown}
@@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
DateSessionsInputOutput{t.analytics.date}{t.sessions.title}{t.analytics.input}{t.analytics.output}
- - - + + + @@ -225,6 +232,7 @@ export default function AnalyticsPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { t } = useI18n(); const load = useCallback(() => { setLoading(true); @@ -244,7 +252,7 @@ export default function AnalyticsPage() {
{/* Period selector */}
- Period: + {t.analytics.period} {PERIODS.map((p) => (
@@ -310,8 +318,8 @@ export default function AnalyticsPage() {
-

No usage data for this period

-

Start a session to see analytics here

+

{t.analytics.noUsageData}

+

{t.analytics.startSession}

diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 7cd6e4300..c447a46ab 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -1,76 +1,49 @@ import { useEffect, useRef, useState, useMemo } from "react"; import { - Bot, - ChevronRight, Code, - Ear, Download, - FileText, FormInput, - Globe, - Lock, - MessageSquare, - Mic, - Monitor, - Package, - Palette, RotateCcw, Save, - ScrollText, Search, - Settings, - Settings2, Upload, - Users, - Volume2, - Wrench, X, + ChevronRight, + Settings2, + FileText, } from "lucide-react"; -import type { ComponentType } from "react"; import { api } from "@/lib/api"; import { getNestedValue, setNestedValue } from "@/lib/nested"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { AutoField } from "@/components/AutoField"; -import { ModelInfoCard } from "@/components/ModelInfoCard"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { useI18n } from "@/i18n"; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -const CATEGORY_ICONS: Record> = { - general: Settings, - agent: Bot, - terminal: Monitor, - display: Palette, - delegation: Users, - memory: Package, - compression: Package, - security: Lock, - browser: Globe, - voice: Mic, - tts: Volume2, - stt: Ear, - logging: ScrollText, - discord: MessageSquare, - auxiliary: Wrench, +const CATEGORY_ICONS: Record = { + general: "⚙️", + agent: "🤖", + terminal: "💻", + display: "🎨", + delegation: "👥", + memory: "🧠", + compression: "📦", + security: "🔒", + browser: "🌐", + voice: "🎙️", + tts: "🔊", + stt: "👂", + logging: "📋", + discord: "💬", + auxiliary: "🔧", }; -const FallbackIcon = FileText; - -function prettyCategoryName(cat: string): string { - if (cat === "tts") return "Text-to-Speech"; - if (cat === "stt") return "Speech-to-Text"; - return cat.charAt(0).toUpperCase() + cat.slice(1); -} - -function CategoryIcon({ cat, className }: { cat: string; className?: string }) { - const Icon = CATEGORY_ICONS[cat] ?? FallbackIcon; - return ; -} /* ------------------------------------------------------------------ */ /* Component */ @@ -88,9 +61,15 @@ export default function ConfigPage() { const [yamlLoading, setYamlLoading] = useState(false); const [yamlSaving, setYamlSaving] = useState(false); const [activeCategory, setActiveCategory] = useState(""); - const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0); const { toast, showToast } = useToast(); const fileInputRef = useRef(null); + const { t } = useI18n(); + + function prettyCategoryName(cat: string): string { + const key = cat as keyof typeof t.config.categories; + if (t.config.categories[key]) return t.config.categories[key]; + return cat.charAt(0).toUpperCase() + cat.slice(1); + } useEffect(() => { api.getConfig().then(setConfig).catch(() => {}); @@ -118,7 +97,7 @@ export default function ConfigPage() { api .getConfigRaw() .then((resp) => setYamlText(resp.yaml)) - .catch(() => showToast("Failed to load raw config", "error")) + .catch(() => showToast(t.config.failedToLoadRaw, "error")) .finally(() => setYamlLoading(false)); } }, [yamlMode]); @@ -175,10 +154,9 @@ export default function ConfigPage() { setSaving(true); try { await api.saveConfig(config); - showToast("Configuration saved", "success"); - setModelInfoRefreshKey((k) => k + 1); + showToast(t.config.configSaved, "success"); } catch (e) { - showToast(`Failed to save: ${e}`, "error"); + showToast(`${t.config.failedToSave}: ${e}`, "error"); } finally { setSaving(false); } @@ -188,11 +166,10 @@ export default function ConfigPage() { setYamlSaving(true); try { await api.saveConfigRaw(yamlText); - showToast("YAML config saved", "success"); - setModelInfoRefreshKey((k) => k + 1); + showToast(t.config.yamlConfigSaved, "success"); api.getConfig().then(setConfig).catch(() => {}); } catch (e) { - showToast(`Failed to save YAML: ${e}`, "error"); + showToast(`${t.config.failedToSaveYaml}: ${e}`, "error"); } finally { setYamlSaving(false); } @@ -221,9 +198,9 @@ export default function ConfigPage() { try { const imported = JSON.parse(reader.result as string); setConfig(imported); - showToast("Config imported — review and save", "success"); + showToast(t.config.configImported, "success"); } catch { - showToast("Invalid JSON file", "error"); + showToast(t.config.invalidJson, "error"); } }; reader.readAsText(file); @@ -242,7 +219,6 @@ export default function ConfigPage() { const renderFields = (fields: [string, Record][], showCategory = false) => { let lastSection = ""; let lastCat = ""; - const currentModel = config ? String(getNestedValue(config, "model") ?? "") : ""; return fields.map(([key, s]) => { const parts = key.split("."); const section = parts.length > 1 ? parts[0] : ""; @@ -256,7 +232,7 @@ export default function ConfigPage() {
{showCatBadge && (
- + {CATEGORY_ICONS[cat] || "📄"} {prettyCategoryName(cat)} @@ -279,12 +255,6 @@ export default function ConfigPage() { onChange={(v) => setConfig(setNestedValue(config, key, v))} />
- {/* Inject model info card right after the model field */} - {key === "model" && currentModel && ( -
- -
- )}
); }); @@ -298,19 +268,19 @@ export default function ConfigPage() {
- - ~/.hermes/config.yaml + + {t.config.configPath}
- - - @@ -325,7 +295,7 @@ export default function ConfigPage() { {yamlMode ? ( <> - Form + {t.common.form} ) : ( <> @@ -338,12 +308,12 @@ export default function ConfigPage() { {yamlMode ? ( ) : ( )}
@@ -355,7 +325,7 @@ export default function ConfigPage() { - Raw YAML Configuration + {t.config.rawYaml} @@ -384,7 +354,7 @@ export default function ConfigPage() { setSearchQuery(e.target.value)} /> @@ -411,13 +381,13 @@ export default function ConfigPage() { setSearchQuery(""); setActiveCategory(cat); }} - className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ + className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ isActive ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" }`} > - + {CATEGORY_ICONS[cat] || "📄"} {prettyCategoryName(cat)} {categoryCounts[cat] || 0} @@ -441,17 +411,17 @@ export default function ConfigPage() {
- Search Results + {t.config.searchResults} - {searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""} + {searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
{searchMatchedFields.length === 0 ? (

- No fields match "{searchQuery}" + {t.config.noFieldsMatch.replace("{query}", searchQuery)}

) : ( renderFields(searchMatchedFields, true) @@ -464,11 +434,11 @@ export default function ConfigPage() {
- + {CATEGORY_ICONS[activeCategory] || "📄"} {prettyCategoryName(activeCategory)} - {activeFields.length} field{activeFields.length !== 1 ? "s" : ""} + {activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 9c7f186ba..cfd1bc608 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectOption } from "@/components/ui/select"; +import { Select } from "@/components/ui/select"; +import { useI18n } from "@/i18n"; function formatTime(iso?: string | null): string { if (!iso) return "—"; @@ -29,6 +30,7 @@ export default function CronPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); + const { t } = useI18n(); // New job form state const [prompt, setPrompt] = useState(""); @@ -41,7 +43,7 @@ export default function CronPage() { api .getCronJobs() .then(setJobs) - .catch(() => showToast("Failed to load cron jobs", "error")) + .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); }; @@ -51,7 +53,7 @@ export default function CronPage() { const handleCreate = async () => { if (!prompt.trim() || !schedule.trim()) { - showToast("Prompt and schedule are required", "error"); + showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); return; } setCreating(true); @@ -62,14 +64,14 @@ export default function CronPage() { name: name.trim() || undefined, deliver, }); - showToast("Cron job created", "success"); + showToast(t.common.create + " ✓", "success"); setPrompt(""); setSchedule(""); setName(""); setDeliver("local"); loadJobs(); } catch (e) { - showToast(`Failed to create job: ${e}`, "error"); + showToast(`${t.config.failedToSave}: ${e}`, "error"); } finally { setCreating(false); } @@ -80,34 +82,34 @@ export default function CronPage() { const isPaused = job.state === "paused"; if (isPaused) { await api.resumeCronJob(job.id); - showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); } else { await api.pauseCronJob(job.id); - showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); } loadJobs(); } catch (e) { - showToast(`Action failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; const handleTrigger = async (job: CronJob) => { try { await api.triggerCronJob(job.id); - showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); loadJobs(); } catch (e) { - showToast(`Trigger failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; const handleDelete = async (job: CronJob) => { try { await api.deleteCronJob(job.id); - showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success"); + showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success"); loadJobs(); } catch (e) { - showToast(`Delete failed: ${e}`, "error"); + showToast(`${t.status.error}: ${e}`, "error"); } }; @@ -128,27 +130,27 @@ export default function CronPage() { - New Cron Job + {t.cron.newJob}
- + setName(e.target.value)} />
- +
ModelSessionsTokens{t.analytics.model}{t.sessions.title}{t.analytics.tokens}