feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)

Add a lightweight i18n system to the web dashboard with English (default) and
Chinese language support. A language switcher with flag icons is placed in the
header bar, allowing users to toggle between languages. The choice persists
to localStorage.

Implementation:
- src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook
- LanguageSwitcher component shows the *other* language's flag as the toggle
- I18nProvider wraps the app in main.tsx
- All 8 pages + OAuth components updated to use t() translation calls
- Zero new dependencies — pure React context + localStorage
This commit is contained in:
Teknium 2026-04-13 23:19:13 -07:00 committed by GitHub
parent 19199cd38d
commit a2ea237db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1715 additions and 977 deletions

13
web/package-lock.json generated
View file

@ -64,7 +64,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1639,7 +1638,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1650,7 +1648,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1710,7 +1707,6 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.0",
@ -1988,7 +1984,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2097,7 +2092,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -2374,7 +2368,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3338,7 +3331,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3399,7 +3391,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3409,7 +3400,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3676,7 +3666,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -3762,7 +3751,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -3884,7 +3872,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -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 { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage"; import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage"; import ConfigPage from "@/pages/ConfigPage";
@ -8,89 +8,118 @@ import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage"; import AnalyticsPage from "@/pages/AnalyticsPage";
import CronPage from "@/pages/CronPage"; import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage"; import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { useI18n } from "@/i18n";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ path: "/", label: "Status", icon: Activity }, { id: "status", labelKey: "status" as const, icon: Activity },
{ path: "/sessions", label: "Sessions", icon: MessageSquare }, { id: "sessions", labelKey: "sessions" as const, icon: MessageSquare },
{ path: "/analytics", label: "Analytics", icon: BarChart3 }, { id: "analytics", labelKey: "analytics" as const, icon: BarChart3 },
{ path: "/logs", label: "Logs", icon: FileText }, { id: "logs", labelKey: "logs" as const, icon: FileText },
{ path: "/cron", label: "Cron", icon: Clock }, { id: "cron", labelKey: "cron" as const, icon: Clock },
{ path: "/skills", label: "Skills", icon: Package }, { id: "skills", labelKey: "skills" as const, icon: Package },
{ path: "/config", label: "Config", icon: Settings }, { id: "config", labelKey: "config" as const, icon: Settings },
{ path: "/env", label: "Keys", icon: KeyRound }, { id: "env", labelKey: "keys" as const, icon: KeyRound },
] as const; ] as const;
type PageId = (typeof NAV_ITEMS)[number]["id"];
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
status: StatusPage,
sessions: SessionsPage,
analytics: AnalyticsPage,
logs: LogsPage,
cron: CronPage,
skills: SkillsPage,
config: ConfigPage,
env: EnvPage,
};
export default function App() { export default function App() {
const [page, setPage] = useState<PageId>("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 ( return (
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden"> <div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
{/* Global grain + warm glow (matches landing page) */}
<div className="noise-overlay" /> <div className="noise-overlay" />
<div className="warm-glow" /> <div className="warm-glow" />
<header className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm"> {/* ---- Header with grid-border nav ---- */}
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch"> <div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
{/* Brand — abbreviated on mobile */}
<div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0"> <div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0">
<span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter"> <span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter">
H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span> H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
</span> </span>
</div> </div>
{/* Nav — icons only on mobile, icon+label on sm+ */}
<nav className="flex items-stretch overflow-x-auto scrollbar-none"> <nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ path, label, icon: Icon }) => ( {NAV_ITEMS.map(({ id, labelKey, icon: Icon }) => (
<NavLink <button
key={path} key={id}
to={path} type="button"
end={path === "/"} onClick={() => setPage(id)}
className={({ isActive }) => className={`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${ page === id
isActive ? "text-foreground"
? "text-foreground" : "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground" }`}
}`
}
> >
{({ isActive }) => ( <Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
<> <span className="hidden sm:inline">{t.app.nav[labelKey]}</span>
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" /> {/* Hover highlight */}
<span className="hidden sm:inline">{label}</span> <span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" /> {/* Active indicator */}
{isActive && ( {page === id && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" /> <span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
)}
</>
)} )}
</NavLink> </button>
))} ))}
</nav> </nav>
<div className="ml-auto hidden sm:flex items-center px-4 text-muted-foreground"> {/* Right side: language switcher + version badge */}
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50"> <div className="ml-auto flex items-center gap-2 px-2 sm:px-4">
Web UI <LanguageSwitcher />
<span className="hidden sm:inline font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
{t.app.webUi}
</span> </span>
</div> </div>
</div> </div>
</header> </header>
<main className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8"> <main
<Routes> key={animKey}
<Route path="/" element={<StatusPage />} /> className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 py-4 sm:py-8"
<Route path="/sessions" element={<SessionsPage />} /> style={{ animation: "fade-in 150ms ease-out" }}
<Route path="/analytics" element={<AnalyticsPage />} /> >
<Route path="/logs" element={<LogsPage />} /> <PageComponent />
<Route path="/cron" element={<CronPage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/env" element={<EnvPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main> </main>
{/* ---- Footer ---- */}
<footer className="relative z-2 border-t border-border"> <footer className="relative z-2 border-t border-border">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3"> <div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3">
<span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50"> <span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
Hermes Agent {t.app.footer.name}
</span> </span>
<span className="font-display text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40"> <span className="font-display text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
Nous Research {t.app.footer.org}
</span> </span>
</div> </div>
</footer> </footer>

View file

@ -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 (
<button
type="button"
onClick={toggle}
className="group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded"
title={t.language.switchTo}
aria-label={t.language.switchTo}
>
{/* Show the *other* language's flag as the clickable target */}
<span className="text-base leading-none">{locale === "en" ? "🇨🇳" : "🇬🇧"}</span>
<span className="hidden sm:inline font-display tracking-wide uppercase text-[0.65rem]">
{locale === "en" ? "中文" : "EN"}
</span>
</button>
);
}

View file

@ -3,29 +3,7 @@ import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
/**
* 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).
*/
interface Props { interface Props {
provider: OAuthProvider; provider: OAuthProvider;
@ -45,6 +23,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
const [codeCopied, setCodeCopied] = useState(false); const [codeCopied, setCodeCopied] = useState(false);
const isMounted = useRef(true); const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null); const pollTimer = useRef<number | null>(null);
const { t } = useI18n();
// Initiate flow on mount // Initiate flow on mount
useEffect(() => { useEffect(() => {
@ -57,10 +36,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setSecondsLeft(resp.expires_in); setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") { if (resp.flow === "pkce") {
// Auto-open the auth URL in a new tab
window.open(resp.auth_url, "_blank", "noopener,noreferrer"); window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else { } else {
// Device-code: open the verification URL automatically
window.open(resp.verification_url, "_blank", "noopener,noreferrer"); window.open(resp.verification_url, "_blank", "noopener,noreferrer");
} }
}) })
@ -73,7 +50,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
isMounted.current = false; isMounted.current = false;
if (pollTimer.current !== null) window.clearInterval(pollTimer.current); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@ -85,16 +61,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
if (!isMounted.current) return; if (!isMounted.current) return;
setSecondsLeft((s) => { setSecondsLeft((s) => {
if (s !== null && s <= 1) { if (s !== null && s <= 1) {
// Session expired — transition to error state
setPhase("error"); setPhase("error");
setErrorMsg("Session expired. Click Retry to start a new login."); setErrorMsg(t.oauth.sessionExpired);
return 0; return 0;
} }
return s !== null && s > 0 ? s - 1 : 0; return s !== null && s > 0 ? s - 1 : 0;
}); });
}, 1000); }, 1000);
return () => window.clearInterval(tick); return () => window.clearInterval(tick);
}, [secondsLeft, phase]); }, [secondsLeft, phase, t]);
// Device-code: poll backend every 2s // Device-code: poll backend every 2s
useEffect(() => { useEffect(() => {
@ -115,7 +90,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
if (pollTimer.current !== null) window.clearInterval(pollTimer.current); if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
} }
} catch (e) { } catch (e) {
// 404 = session expired/cleaned up; treat as error
if (!isMounted.current) return; if (!isMounted.current) return;
setPhase("error"); setPhase("error");
setErrorMsg(`Polling failed: ${e}`); setErrorMsg(`Polling failed: ${e}`);
@ -151,12 +125,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
}; };
const handleClose = async () => { const handleClose = async () => {
// Cancel server session if still in flight
if (start && phase !== "approved" && phase !== "error") { if (start && phase !== "approved" && phase !== "error") {
try { try {
await api.cancelOAuthSession(start.session_id); await api.cancelOAuthSession(start.session_id);
} catch { } catch {
// ignore — server-side TTL will clean it up anyway // ignore
} }
} }
onClose(); onClose();
@ -172,7 +145,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
} }
}; };
// Backdrop click closes
const handleBackdrop = (e: React.MouseEvent) => { const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose(); if (e.target === e.currentTarget) handleClose();
}; };
@ -197,18 +169,18 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
type="button" type="button"
onClick={handleClose} onClick={handleClose}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors" className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close" aria-label={t.common.close}
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
<div className="p-6 flex flex-col gap-4"> <div className="p-6 flex flex-col gap-4">
<div> <div>
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase"> <h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
Connect {provider.name} {t.oauth.connect} {provider.name}
</h2> </h2>
{secondsLeft !== null && phase !== "approved" && phase !== "error" && ( {secondsLeft !== null && phase !== "approved" && phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Session expires in {fmtTime(secondsLeft)} {t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
</p> </p>
)} )}
</div> </div>
@ -217,7 +189,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{phase === "starting" && ( {phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Initiating login flow {t.oauth.initiatingLogin}
</div> </div>
)} )}
@ -225,18 +197,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{start?.flow === "pkce" && phase === "awaiting_user" && ( {start?.flow === "pkce" && phase === "awaiting_user" && (
<> <>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground"> <ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li> <li>{t.oauth.pkceStep1}</li>
A new tab opened to <code className="text-foreground">claude.ai</code>. Sign in <li>{t.oauth.pkceStep2}</li>
and click <strong className="text-foreground">Authorize</strong>. <li>{t.oauth.pkceStep3}</li>
</li>
<li>Copy the <strong className="text-foreground">authorization code</strong> shown after authorizing.</li>
<li>Paste it below and submit.</li>
</ol> </ol>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Input <Input
value={pkceCode} value={pkceCode}
onChange={(e) => setPkceCode(e.target.value)} onChange={(e) => setPkceCode(e.target.value)}
placeholder="Paste authorization code (with #state suffix is fine)" placeholder={t.oauth.pasteCode}
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
autoFocus 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" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
Re-open auth page {t.oauth.reOpenAuth}
</a> </a>
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm"> <Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
Submit code {t.oauth.submitCode}
</Button> </Button>
</div> </div>
</div> </div>
@ -262,7 +231,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{phase === "submitting" && ( {phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Exchanging code for tokens {t.oauth.exchangingCode}
</div> </div>
)} )}
@ -270,7 +239,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{start?.flow === "device_code" && phase === "polling" && ( {start?.flow === "device_code" && phase === "polling" && (
<> <>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
A new tab opened. Enter this code if prompted: {t.oauth.enterCodePrompt}
</p> </p>
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4"> <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
<code className="font-mono-ui text-2xl tracking-widest text-foreground"> <code className="font-mono-ui text-2xl tracking-widest text-foreground">
@ -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" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
Re-open verification page {t.oauth.reOpenVerification}
</a> </a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3"> <div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
Waiting for you to authorize in the browser {t.oauth.waitingAuth}
</div> </div>
</> </>
)} )}
@ -309,7 +278,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{phase === "approved" && ( {phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success"> <div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" /> <Check className="h-5 w-5" />
Connected! Closing {t.oauth.connectedClosing}
</div> </div>
)} )}
@ -317,16 +286,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
{phase === "error" && ( {phase === "error" && (
<> <>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || "Login failed."} {errorMsg || t.oauth.loginFailed}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleClose}> <Button variant="outline" size="sm" onClick={handleClose}>
Close {t.common.close}
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
// Cancel the old session before starting a new one
if (start?.session_id) { if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {}); api.cancelOAuthSession(start.session_id).catch(() => {});
} }
@ -334,8 +302,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setStart(null); setStart(null);
setPkceCode(""); setPkceCode("");
setPhase("starting"); setPhase("starting");
// Re-trigger the start effect by remounting (caller should re-key us)
// Simpler: just kick off a new start manually
api.startOAuthLogin(provider.id).then((resp) => { api.startOAuthLogin(provider.id).then((resp) => {
if (!isMounted.current) return; if (!isMounted.current) return;
setStart(resp); setStart(resp);
@ -349,11 +315,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
}).catch((e) => { }).catch((e) => {
if (!isMounted.current) return; if (!isMounted.current) return;
setPhase("error"); setPhase("error");
setErrorMsg(`Retry failed: ${e}`); setErrorMsg(`${t.common.retry} failed: ${e}`);
}); });
}} }}
> >
Retry {t.common.retry}
</Button> </Button>
</div> </div>
</> </>

View file

@ -5,29 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
/**
* 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.
*/
interface Props { interface Props {
onError?: (msg: string) => void; onError?: (msg: string) => void;
onSuccess?: (msg: string) => void; onSuccess?: (msg: string) => void;
} }
const FLOW_LABELS: Record<OAuthProvider["flow"], string> = { function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null {
pkce: "Browser login (PKCE)",
device_code: "Device code",
external: "External CLI",
};
function formatExpiresAt(expiresAt: string | null | undefined): string | null {
if (!expiresAt) return null; if (!expiresAt) return null;
try { try {
const dt = new Date(expiresAt); const dt = new Date(expiresAt);
@ -36,11 +21,11 @@ function formatExpiresAt(expiresAt: string | null | undefined): string | null {
const diff = dt.getTime() - now; const diff = dt.getTime() - now;
if (diff < 0) return "expired"; if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000); 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); 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); const days = Math.floor(hours / 24);
return `expires in ${days}d`; return expiresInTemplate.replace("{time}", `${days}d`);
} catch { } catch {
return null; return null;
} }
@ -51,10 +36,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
// Provider that the login modal is currently open for. null = modal closed.
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null); const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const { t } = useI18n();
// Use refs for callbacks to avoid re-creating refresh() when parent re-renders
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
onErrorRef.current = onError; onErrorRef.current = onError;
@ -83,16 +67,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
}; };
const handleDisconnect = async (provider: OAuthProvider) => { 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; return;
} }
setBusyId(provider.id); setBusyId(provider.id);
try { try {
await api.disconnectOAuthProvider(provider.id); await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} disconnected`); onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
refresh(); refresh();
} catch (e) { } catch (e) {
onError?.(`Disconnect failed: ${e}`); onError?.(`${t.oauth.disconnect} failed: ${e}`);
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@ -107,7 +91,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" /> <ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Provider Logins (OAuth)</CardTitle> <CardTitle className="text-base">{t.oauth.providerLogins}</CardTitle>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@ -117,12 +101,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
className="text-xs" className="text-xs"
> >
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
Refresh {t.common.refresh}
</Button> </Button>
</div> </div>
<CardDescription> <CardDescription>
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently {t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))}
run via the CLI; click <em>Copy command</em> and paste into a terminal to set up.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -133,12 +116,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)} )}
{providers && providers.length === 0 && ( {providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No OAuth-capable providers detected. {t.oauth.noProviders}
</p> </p>
)} )}
<div className="flex flex-col divide-y divide-border"> <div className="flex flex-col divide-y divide-border">
{providers?.map((p) => { {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; const isBusy = busyId === p.id;
return ( return (
<div <div
@ -156,16 +139,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span> <span className="font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="text-[11px] uppercase tracking-wide"> <Badge variant="outline" className="text-[11px] uppercase tracking-wide">
{FLOW_LABELS[p.flow]} {t.oauth.flowLabels[p.flow]}
</Badge> </Badge>
{p.status.logged_in && ( {p.status.logged_in && (
<Badge variant="success" className="text-[11px]"> <Badge variant="success" className="text-[11px]">
Connected {t.oauth.connected}
</Badge> </Badge>
)} )}
{expiresLabel === "expired" && ( {expiresLabel === "expired" && (
<Badge variant="destructive" className="text-[11px]"> <Badge variant="destructive" className="text-[11px]">
Expired {t.oauth.expired}
</Badge> </Badge>
)} )}
{expiresLabel && expiresLabel !== "expired" && ( {expiresLabel && expiresLabel !== "expired" && (
@ -187,11 +170,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)} )}
{!p.status.logged_in && ( {!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80"> <span className="text-xs text-muted-foreground/80">
Not connected. Run{" "} {t.oauth.notConnected.split("{command}")[0]}
<code className="text-foreground bg-secondary/40 px-1"> <code className="text-foreground bg-secondary/40 px-1 rounded">
{p.cli_command} {p.cli_command}
</code>{" "} </code>
in a terminal. {t.oauth.notConnected.split("{command}")[1]}
</span> </span>
)} )}
{p.status.error && ( {p.status.error && (
@ -222,10 +205,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
size="sm" size="sm"
onClick={() => setLoginFor(p)} onClick={() => setLoginFor(p)}
className="text-xs h-7" className="text-xs h-7"
title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`}
> >
<LogIn className="h-3 w-3 mr-1" /> <LogIn className="h-3 w-3 mr-1" />
Login {t.oauth.login}
</Button> </Button>
)} )}
{!p.status.logged_in && ( {!p.status.logged_in && (
@ -234,14 +216,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
size="sm" size="sm"
onClick={() => handleCopy(p)} onClick={() => handleCopy(p)}
className="text-xs h-7" className="text-xs h-7"
title="Copy CLI command (for external / fallback)" title={t.oauth.copyCliCommand}
> >
{copiedId === p.id ? ( {copiedId === p.id ? (
<>Copied </> <>{t.oauth.copied}</>
) : ( ) : (
<> <>
<Copy className="h-3 w-3 mr-1" /> <Copy className="h-3 w-3 mr-1" />
CLI {t.oauth.cli}
</> </>
)} )}
</Button> </Button>
@ -259,13 +241,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
) : ( ) : (
<LogOut className="h-3 w-3 mr-1" /> <LogOut className="h-3 w-3 mr-1" />
)} )}
Disconnect {t.oauth.disconnect}
</Button> </Button>
)} )}
{p.status.logged_in && p.flow === "external" && ( {p.status.logged_in && p.flow === "external" && (
<span className="text-[11px] text-muted-foreground italic px-2"> <span className="text-[11px] text-muted-foreground italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" /> <Terminal className="h-3 w-3 inline mr-0.5" />
Managed externally {t.oauth.managedExternally}
</span> </span>
)} )}
</div> </div>
@ -279,7 +261,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
provider={loginFor} provider={loginFor}
onClose={() => { onClose={() => {
setLoginFor(null); setLoginFor(null);
refresh(); // always refresh on close so token preview updates after login refresh();
}} }}
onSuccess={(msg) => onSuccess?.(msg)} onSuccess={(msg) => onSuccess?.(msg)}
onError={(msg) => onError?.(msg)} onError={(msg) => onError?.(msg)}

58
web/src/i18n/context.tsx Normal file
View file

@ -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<Locale, Translations> = { 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<I18nContextValue>({
locale: "en",
setLocale: () => {},
t: en,
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(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 (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
return useContext(I18nContext);
}

275
web/src/i18n/en.ts Normal file
View file

@ -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",
},
};

2
web/src/i18n/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { I18nProvider, useI18n } from "./context";
export type { Locale, Translations } from "./types";

287
web/src/i18n/types.ts Normal file
View file

@ -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;
};
}

275
web/src/i18n/zh.ts Normal file
View file

@ -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: "切换到英文",
},
};

View file

@ -1,10 +1,10 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import { I18nProvider } from "./i18n";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<BrowserRouter> <I18nProvider>
<App /> <App />
</BrowserRouter>, </I18nProvider>,
); );

View file

@ -1,5 +1,4 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { formatTokenCount } from "@/lib/format";
import { import {
BarChart3, BarChart3,
Cpu, Cpu,
@ -10,6 +9,7 @@ import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useI18n } from "@/i18n";
const PERIODS = [ const PERIODS = [
{ label: "7d", days: 7 }, { label: "7d", days: 7 },
@ -19,7 +19,11 @@ const PERIODS = [
const CHART_HEIGHT_PX = 160; 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 { function formatDate(day: string): string {
try { try {
@ -56,6 +60,7 @@ function SummaryCard({
} }
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null; if (daily.length === 0) return null;
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1); const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" /> <BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Token Usage</CardTitle> <CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
</div> </div>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" /> <div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
Input {t.analytics.input}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" /> <div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
Output {t.analytics.output}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
> >
{/* Tooltip */} {/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none"> <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap"> <div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div> <div className="font-medium">{formatDate(d.day)}</div>
<div>Input: {formatTokens(d.input_tokens)}</div> <div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
<div>Output: {formatTokens(d.output_tokens)}</div> <div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
<div>Total: {formatTokens(total)}</div> <div>{t.analytics.total}: {formatTokens(total)}</div>
</div> </div>
</div> </div>
{/* Input bar */} {/* Input bar */}
@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
} }
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null; if (daily.length === 0) return null;
const sorted = [...daily].reverse(); const sorted = [...daily].reverse();
@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" /> <TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Breakdown</CardTitle> <CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-muted-foreground text-xs"> <tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Date</th> <th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th> <th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 px-4 font-medium">Input</th> <th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
<th className="text-right py-2 pl-4 font-medium">Output</th> <th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
} }
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
const { t } = useI18n();
if (models.length === 0) return null; if (models.length === 0) return null;
const sorted = [...models].sort( const sorted = [...models].sort(
@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" /> <Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Per-Model Breakdown</CardTitle> <CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-muted-foreground text-xs"> <tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Model</th> <th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th> <th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 pl-4 font-medium">Tokens</th> <th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -225,6 +232,7 @@ export default function AnalyticsPage() {
const [data, setData] = useState<AnalyticsResponse | null>(null); const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@ -244,7 +252,7 @@ export default function AnalyticsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Period selector */} {/* Period selector */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">Period:</span> <span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<Button <Button
key={p.label} key={p.label}
@ -278,21 +286,21 @@ export default function AnalyticsPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard <SummaryCard
icon={Hash} icon={Hash}
label="Total Tokens" label={t.analytics.totalTokens}
value={formatTokens(data.totals.total_input + data.totals.total_output)} value={formatTokens(data.totals.total_input + data.totals.total_output)}
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`} sub={t.analytics.inOut.replace("{input}", formatTokens(data.totals.total_input)).replace("{output}", formatTokens(data.totals.total_output))}
/> />
<SummaryCard <SummaryCard
icon={BarChart3} icon={BarChart3}
label="Total Sessions" label={t.analytics.totalSessions}
value={String(data.totals.total_sessions)} value={String(data.totals.total_sessions)}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`} sub={`~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg}`}
/> />
<SummaryCard <SummaryCard
icon={TrendingUp} icon={TrendingUp}
label="API Calls" label={t.analytics.apiCalls}
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))} value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
sub={`across ${data.by_model.length} models`} sub={t.analytics.acrossModels.replace("{count}", String(data.by_model.length))}
/> />
</div> </div>
@ -310,8 +318,8 @@ export default function AnalyticsPage() {
<CardContent className="py-12"> <CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground"> <div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" /> <BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">No usage data for this period</p> <p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p> <p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,76 +1,49 @@
import { useEffect, useRef, useState, useMemo } from "react"; import { useEffect, useRef, useState, useMemo } from "react";
import { import {
Bot,
ChevronRight,
Code, Code,
Ear,
Download, Download,
FileText,
FormInput, FormInput,
Globe,
Lock,
MessageSquare,
Mic,
Monitor,
Package,
Palette,
RotateCcw, RotateCcw,
Save, Save,
ScrollText,
Search, Search,
Settings,
Settings2,
Upload, Upload,
Users,
Volume2,
Wrench,
X, X,
ChevronRight,
Settings2,
FileText,
} from "lucide-react"; } from "lucide-react";
import type { ComponentType } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested"; import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast"; import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField"; import { AutoField } from "@/components/AutoField";
import { ModelInfoCard } from "@/components/ModelInfoCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useI18n } from "@/i18n";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, ComponentType<{ className?: string }>> = { const CATEGORY_ICONS: Record<string, string> = {
general: Settings, general: "⚙️",
agent: Bot, agent: "🤖",
terminal: Monitor, terminal: "💻",
display: Palette, display: "🎨",
delegation: Users, delegation: "👥",
memory: Package, memory: "🧠",
compression: Package, compression: "📦",
security: Lock, security: "🔒",
browser: Globe, browser: "🌐",
voice: Mic, voice: "🎙️",
tts: Volume2, tts: "🔊",
stt: Ear, stt: "👂",
logging: ScrollText, logging: "📋",
discord: MessageSquare, discord: "💬",
auxiliary: Wrench, 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 <Icon className={className} />;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
@ -88,9 +61,15 @@ export default function ConfigPage() {
const [yamlLoading, setYamlLoading] = useState(false); const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false); const [yamlSaving, setYamlSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>(""); const [activeCategory, setActiveCategory] = useState<string>("");
const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0);
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(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(() => { useEffect(() => {
api.getConfig().then(setConfig).catch(() => {}); api.getConfig().then(setConfig).catch(() => {});
@ -118,7 +97,7 @@ export default function ConfigPage() {
api api
.getConfigRaw() .getConfigRaw()
.then((resp) => setYamlText(resp.yaml)) .then((resp) => setYamlText(resp.yaml))
.catch(() => showToast("Failed to load raw config", "error")) .catch(() => showToast(t.config.failedToLoadRaw, "error"))
.finally(() => setYamlLoading(false)); .finally(() => setYamlLoading(false));
} }
}, [yamlMode]); }, [yamlMode]);
@ -175,10 +154,9 @@ export default function ConfigPage() {
setSaving(true); setSaving(true);
try { try {
await api.saveConfig(config); await api.saveConfig(config);
showToast("Configuration saved", "success"); showToast(t.config.configSaved, "success");
setModelInfoRefreshKey((k) => k + 1);
} catch (e) { } catch (e) {
showToast(`Failed to save: ${e}`, "error"); showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -188,11 +166,10 @@ export default function ConfigPage() {
setYamlSaving(true); setYamlSaving(true);
try { try {
await api.saveConfigRaw(yamlText); await api.saveConfigRaw(yamlText);
showToast("YAML config saved", "success"); showToast(t.config.yamlConfigSaved, "success");
setModelInfoRefreshKey((k) => k + 1);
api.getConfig().then(setConfig).catch(() => {}); api.getConfig().then(setConfig).catch(() => {});
} catch (e) { } catch (e) {
showToast(`Failed to save YAML: ${e}`, "error"); showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
} finally { } finally {
setYamlSaving(false); setYamlSaving(false);
} }
@ -221,9 +198,9 @@ export default function ConfigPage() {
try { try {
const imported = JSON.parse(reader.result as string); const imported = JSON.parse(reader.result as string);
setConfig(imported); setConfig(imported);
showToast("Config imported — review and save", "success"); showToast(t.config.configImported, "success");
} catch { } catch {
showToast("Invalid JSON file", "error"); showToast(t.config.invalidJson, "error");
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@ -242,7 +219,6 @@ export default function ConfigPage() {
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => { const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
let lastSection = ""; let lastSection = "";
let lastCat = ""; let lastCat = "";
const currentModel = config ? String(getNestedValue(config, "model") ?? "") : "";
return fields.map(([key, s]) => { return fields.map(([key, s]) => {
const parts = key.split("."); const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : ""; const section = parts.length > 1 ? parts[0] : "";
@ -256,7 +232,7 @@ export default function ConfigPage() {
<div key={key}> <div key={key}>
{showCatBadge && ( {showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0"> <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<CategoryIcon cat={cat} className="h-4 w-4 text-muted-foreground" /> <span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)} {prettyCategoryName(cat)}
</span> </span>
@ -279,12 +255,6 @@ export default function ConfigPage() {
onChange={(v) => setConfig(setNestedValue(config, key, v))} onChange={(v) => setConfig(setNestedValue(config, key, v))}
/> />
</div> </div>
{/* Inject model info card right after the model field */}
{key === "model" && currentModel && (
<div className="py-1">
<ModelInfoCard currentModel={currentModel} refreshKey={modelInfoRefreshKey} />
</div>
)}
</div> </div>
); );
}); });
@ -298,19 +268,19 @@ export default function ConfigPage() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" /> <Settings2 className="h-4 w-4 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5"> <code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
~/.hermes/config.yaml {t.config.configPath}
</code> </code>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config"> <Button variant="ghost" size="sm" onClick={handleExport} title={t.config.exportConfig} aria-label={t.config.exportConfig}>
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config"> <Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}>
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
</Button> </Button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} /> <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults"> <Button variant="ghost" size="sm" onClick={handleReset} title={t.config.resetDefaults} aria-label={t.config.resetDefaults}>
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</Button> </Button>
@ -325,7 +295,7 @@ export default function ConfigPage() {
{yamlMode ? ( {yamlMode ? (
<> <>
<FormInput className="h-3.5 w-3.5" /> <FormInput className="h-3.5 w-3.5" />
Form {t.common.form}
</> </>
) : ( ) : (
<> <>
@ -338,12 +308,12 @@ export default function ConfigPage() {
{yamlMode ? ( {yamlMode ? (
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5"> <Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
{yamlSaving ? "Saving..." : "Save"} {yamlSaving ? t.common.saving : t.common.save}
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5"> <Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
{saving ? "Saving..." : "Save"} {saving ? t.common.saving : t.common.save}
</Button> </Button>
)} )}
</div> </div>
@ -355,7 +325,7 @@ export default function ConfigPage() {
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Raw YAML Configuration {t.config.rawYaml}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
@ -384,7 +354,7 @@ export default function ConfigPage() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
className="pl-8 h-8 text-xs" className="pl-8 h-8 text-xs"
placeholder="Search..." placeholder={t.common.search}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
@ -411,13 +381,13 @@ export default function ConfigPage() {
setSearchQuery(""); setSearchQuery("");
setActiveCategory(cat); 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 isActive
? "bg-primary/10 text-primary font-medium" ? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<CategoryIcon cat={cat} className="h-4 w-4 shrink-0" /> <span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span> <span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}> <span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0} {categoryCounts[cat] || 0}
@ -441,17 +411,17 @@ export default function ConfigPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
Search Results {t.config.searchResults}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""} {searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-2 px-4 pb-4"> <CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? ( {searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No fields match "<span className="text-foreground">{searchQuery}</span>" {t.config.noFieldsMatch.replace("{query}", searchQuery)}
</p> </p>
) : ( ) : (
renderFields(searchMatchedFields, true) renderFields(searchMatchedFields, true)
@ -464,11 +434,11 @@ export default function ConfigPage() {
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<CategoryIcon cat={activeCategory} className="h-4 w-4" /> <span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
{prettyCategoryName(activeCategory)} {prettyCategoryName(activeCategory)}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""} {activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>

View file

@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { function formatTime(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
@ -29,6 +30,7 @@ export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]); const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n();
// New job form state // New job form state
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
@ -41,7 +43,7 @@ export default function CronPage() {
api api
.getCronJobs() .getCronJobs()
.then(setJobs) .then(setJobs)
.catch(() => showToast("Failed to load cron jobs", "error")) .catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@ -51,7 +53,7 @@ export default function CronPage() {
const handleCreate = async () => { const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) { if (!prompt.trim() || !schedule.trim()) {
showToast("Prompt and schedule are required", "error"); showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return; return;
} }
setCreating(true); setCreating(true);
@ -62,14 +64,14 @@ export default function CronPage() {
name: name.trim() || undefined, name: name.trim() || undefined,
deliver, deliver,
}); });
showToast("Cron job created", "success"); showToast(t.common.create + " ✓", "success");
setPrompt(""); setPrompt("");
setSchedule(""); setSchedule("");
setName(""); setName("");
setDeliver("local"); setDeliver("local");
loadJobs(); loadJobs();
} catch (e) { } catch (e) {
showToast(`Failed to create job: ${e}`, "error"); showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally { } finally {
setCreating(false); setCreating(false);
} }
@ -80,34 +82,34 @@ export default function CronPage() {
const isPaused = job.state === "paused"; const isPaused = job.state === "paused";
if (isPaused) { if (isPaused) {
await api.resumeCronJob(job.id); 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 { } else {
await api.pauseCronJob(job.id); 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(); loadJobs();
} catch (e) { } catch (e) {
showToast(`Action failed: ${e}`, "error"); showToast(`${t.status.error}: ${e}`, "error");
} }
}; };
const handleTrigger = async (job: CronJob) => { const handleTrigger = async (job: CronJob) => {
try { try {
await api.triggerCronJob(job.id); 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(); loadJobs();
} catch (e) { } catch (e) {
showToast(`Trigger failed: ${e}`, "error"); showToast(`${t.status.error}: ${e}`, "error");
} }
}; };
const handleDelete = async (job: CronJob) => { const handleDelete = async (job: CronJob) => {
try { try {
await api.deleteCronJob(job.id); 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(); loadJobs();
} catch (e) { } catch (e) {
showToast(`Delete failed: ${e}`, "error"); showToast(`${t.status.error}: ${e}`, "error");
} }
}; };
@ -128,27 +130,27 @@ export default function CronPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New Cron Job {t.cron.newJob}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cron-name">Name (optional)</Label> <Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
<Input <Input
id="cron-name" id="cron-name"
placeholder="e.g. Daily summary" placeholder={t.cron.namePlaceholder}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cron-prompt">Prompt</Label> <Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
<textarea <textarea
id="cron-prompt" id="cron-prompt"
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="What should the agent do on each run?" placeholder={t.cron.promptPlaceholder}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
/> />
@ -156,34 +158,34 @@ export default function CronPage() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label> <Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input <Input
id="cron-schedule" id="cron-schedule"
placeholder="0 9 * * *" placeholder={t.cron.schedulePlaceholder}
value={schedule} value={schedule}
onChange={(e) => setSchedule(e.target.value)} onChange={(e) => setSchedule(e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cron-deliver">Deliver to</Label> <Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select <Select
id="cron-deliver" id="cron-deliver"
value={deliver} value={deliver}
onValueChange={setDeliver} onValueChange={(v) => setDeliver(v)}
> >
<SelectOption value="local">Local</SelectOption> <option value="local">{t.cron.delivery.local}</option>
<SelectOption value="telegram">Telegram</SelectOption> <option value="telegram">{t.cron.delivery.telegram}</option>
<SelectOption value="discord">Discord</SelectOption> <option value="discord">{t.cron.delivery.discord}</option>
<SelectOption value="slack">Slack</SelectOption> <option value="slack">{t.cron.delivery.slack}</option>
<SelectOption value="email">Email</SelectOption> <option value="email">{t.cron.delivery.email}</option>
</Select> </Select>
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<Button onClick={handleCreate} disabled={creating} className="w-full"> <Button onClick={handleCreate} disabled={creating} className="w-full">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
{creating ? "Creating..." : "Create"} {creating ? t.common.creating : t.common.create}
</Button> </Button>
</div> </div>
</div> </div>
@ -195,13 +197,13 @@ export default function CronPage() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2"> <h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
Scheduled Jobs ({jobs.length}) {t.cron.scheduledJobs} ({jobs.length})
</h2> </h2>
{jobs.length === 0 && ( {jobs.length === 0 && (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
No cron jobs configured. Create one above. {t.cron.noJobs}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -229,8 +231,8 @@ export default function CronPage() {
)} )}
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule_display}</span> <span className="font-mono">{job.schedule_display}</span>
<span>Last: {formatTime(job.last_run_at)}</span> <span>{t.cron.last}: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span> <span>{t.cron.next}: {formatTime(job.next_run_at)}</span>
</div> </div>
{job.last_error && ( {job.last_error && (
<p className="text-xs text-destructive mt-1">{job.last_error}</p> <p className="text-xs text-destructive mt-1">{job.last_error}</p>
@ -242,8 +244,8 @@ export default function CronPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title={job.state === "paused" ? "Resume" : "Pause"} title={job.state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={job.state === "paused" ? "Resume job" : "Pause job"} aria-label={job.state === "paused" ? t.cron.resume : t.cron.pause}
onClick={() => handlePauseResume(job)} onClick={() => handlePauseResume(job)}
> >
{job.state === "paused" ? ( {job.state === "paused" ? (
@ -256,8 +258,8 @@ export default function CronPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Trigger now" title={t.cron.triggerNow}
aria-label="Trigger job now" aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)} onClick={() => handleTrigger(job)}
> >
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
@ -266,8 +268,8 @@ export default function CronPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Delete" title={t.common.delete}
aria-label="Delete job" aria-label={t.common.delete}
onClick={() => handleDelete(job)} onClick={() => handleDelete(job)}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />

View file

@ -24,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Provider grouping */ /* Provider grouping */
@ -72,11 +73,11 @@ interface ProviderGroup {
hasAnySet: boolean; hasAnySet: boolean;
} }
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = { const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
provider: { label: "LLM Providers", icon: Zap }, provider: Zap,
tool: { label: "Tool API Keys", icon: KeyRound }, tool: KeyRound,
messaging: { label: "Messaging Platforms", icon: MessageSquare }, messaging: MessageSquare,
setting: { label: "Agent Settings", icon: Settings }, setting: Settings,
}; };
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@ -108,6 +109,7 @@ function EnvVarRow({
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
compact?: boolean; compact?: boolean;
}) { }) {
const { t } = useI18n();
const isEditing = edits[varKey] !== undefined; const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey]; const isRevealed = !!revealed[varKey];
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---"); const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
@ -124,13 +126,13 @@ function EnvVarRow({
{info.url && ( {info.url && (
<a href={info.url} target="_blank" rel="noreferrer" <a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"> className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2" <Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-2.5 w-2.5" /> <Pencil className="h-2.5 w-2.5" />
Set {t.common.set}
</Button> </Button>
</div> </div>
</div> </div>
@ -149,13 +151,13 @@ function EnvVarRow({
{info.url && ( {info.url && (
<a href={info.url} target="_blank" rel="noreferrer" <a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"> className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]" <Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
Set {t.common.set}
</Button> </Button>
</div> </div>
</div> </div>
@ -169,13 +171,13 @@ function EnvVarRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label> <Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge variant={info.is_set ? "success" : "outline"}> <Badge variant={info.is_set ? "success" : "outline"}>
{info.is_set ? "Set" : "Not set"} {info.is_set ? t.common.set : t.env.notSet}
</Badge> </Badge>
</div> </div>
{info.url && ( {info.url && (
<a href={info.url} target="_blank" rel="noreferrer" <a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"> className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}
</div> </div>
@ -200,7 +202,7 @@ function EnvVarRow({
{info.is_set && ( {info.is_set && (
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)} <Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
title={isRevealed ? "Hide value" : "Show real value"} title={isRevealed ? t.env.hideValue : t.env.showValue}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}> aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
{isRevealed {isRevealed
? <EyeOff className="h-4 w-4" /> ? <EyeOff className="h-4 w-4" />
@ -211,7 +213,7 @@ function EnvVarRow({
<Button size="sm" variant="outline" <Button size="sm" variant="outline"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}> onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
{info.is_set ? "Replace" : "Set"} {info.is_set ? t.common.replace : t.common.set}
</Button> </Button>
{info.is_set && ( {info.is_set && (
@ -219,7 +221,7 @@ function EnvVarRow({
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}> onClick={() => onClear(varKey)} disabled={saving === varKey}>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : "Clear"} {saving === varKey ? "..." : t.common.clear}
</Button> </Button>
)} )}
</div> </div>
@ -229,15 +231,15 @@ function EnvVarRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input autoFocus type="text" value={edits[varKey]} <Input autoFocus type="text" value={edits[varKey]}
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))} onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."} placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue}
className="flex-1 font-mono-ui text-xs" /> className="flex-1 font-mono-ui text-xs" />
<Button size="sm" onClick={() => onSave(varKey)} <Button size="sm" onClick={() => onSave(varKey)}
disabled={saving === varKey || !edits[varKey]}> disabled={saving === varKey || !edits[varKey]}>
<Save className="h-3 w-3" /> <Save className="h-3 w-3" />
{saving === varKey ? "..." : "Save"} {saving === varKey ? "..." : t.common.save}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}> <Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
<X className="h-3 w-3" /> Cancel <X className="h-3 w-3" /> {t.common.cancel}
</Button> </Button>
</div> </div>
)} )}
@ -271,6 +273,7 @@ function ProviderGroupCard({
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const { t } = useI18n();
// Separate API keys from base URLs and other settings // Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN")); const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
@ -292,10 +295,10 @@ function ProviderGroupCard({
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />} {expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm tracking-wide">{group.name}</span> <span className="font-semibold text-sm tracking-wide">{group.name === "Other" ? t.common.other : group.name}</span>
{hasAnyConfigured && ( {hasAnyConfigured && (
<Badge variant="success" className="text-[0.6rem]"> <Badge variant="success" className="text-[0.6rem]">
{configuredCount} set {configuredCount} {t.common.set.toLowerCase()}
</Badge> </Badge>
)} )}
</div> </div>
@ -304,11 +307,11 @@ function ProviderGroupCard({
<a href={keyUrl} target="_blank" rel="noreferrer" <a href={keyUrl} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
Get key <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}
<span className="text-[0.65rem] text-muted-foreground/60"> <span className="text-[0.65rem] text-muted-foreground/60">
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""} {t.env.keysCount.replace("{count}", String(group.entries.length)).replace("{s}", group.entries.length !== 1 ? "s" : "")}
</span> </span>
</div> </div>
</button> </button>
@ -357,6 +360,7 @@ export default function EnvPage() {
const [saving, setSaving] = useState<string | null>(null); const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n();
useEffect(() => { useEffect(() => {
api.getEnvVars().then(setVars).catch(() => {}); api.getEnvVars().then(setVars).catch(() => {});
@ -378,9 +382,9 @@ export default function EnvPage() {
); );
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} saved`, "success"); showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
} catch (e) { } catch (e) {
showToast(`Failed to save ${key}: ${e}`, "error"); showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
} finally { } finally {
setSaving(null); setSaving(null);
} }
@ -397,9 +401,9 @@ export default function EnvPage() {
); );
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} removed`, "success"); showToast(`${key} ${t.common.removed}`, "success");
} catch (e) { } catch (e) {
showToast(`Failed to remove ${key}: ${e}`, "error"); showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
} finally { } finally {
setSaving(null); setSaving(null);
} }
@ -414,7 +418,7 @@ export default function EnvPage() {
const resp = await api.revealEnvVar(key); const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value })); setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch { } catch {
showToast(`Failed to reveal ${key}`, "error"); showToast(`${t.common.failedToReveal} ${key}`, "error");
} }
}; };
@ -447,7 +451,12 @@ export default function EnvPage() {
})) }))
.sort((a, b) => a.priority - b.priority); .sort((a, b) => a.priority - b.priority);
// Non-provider categories // Non-provider categories — use translated labels
const CATEGORY_META_LABELS: Record<string, string> = {
tool: t.app.nav.keys,
messaging: t.common.messaging,
setting: t.app.nav.config,
};
const otherCategories = ["tool", "messaging", "setting"]; const otherCategories = ["tool", "messaging", "setting"];
const nonProvider = otherCategories.map((cat) => { const nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter( const entries = Object.entries(vars).filter(
@ -456,7 +465,8 @@ export default function EnvPage() {
const setEntries = entries.filter(([, info]) => info.is_set); const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set); const unsetEntries = entries.filter(([, info]) => !info.is_set);
return { return {
...CATEGORY_META[cat], label: CATEGORY_META_LABELS[cat] ?? cat,
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
category: cat, category: cat,
setEntries, setEntries,
unsetEntries, unsetEntries,
@ -465,7 +475,7 @@ export default function EnvPage() {
}); });
return { providerGroups: groups, nonProviderGrouped: nonProvider }; return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced]); }, [vars, showAdvanced, t]);
if (!vars) { if (!vars) {
return ( return (
@ -485,18 +495,18 @@ export default function EnvPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Manage API keys and secrets stored in <code>~/.hermes/.env</code> {t.env.description} <code>~/.hermes/.env</code>
</p> </p>
<p className="text-[0.7rem] text-muted-foreground/70"> <p className="text-[0.7rem] text-muted-foreground/70">
Changes are saved to disk immediately. Active sessions pick up new keys automatically. {t.env.changesNote}
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}> <Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? "Hide Advanced" : "Show Advanced"} {showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
</Button> </Button>
</div> </div>
{/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */} {/* ═══════════════ OAuth Logins ══ */}
<OAuthProvidersCard <OAuthProvidersCard
onError={(msg) => showToast(msg, "error")} onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")} onSuccess={(msg) => showToast(msg, "success")}
@ -507,10 +517,10 @@ export default function EnvPage() {
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border"> <CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" /> <Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">LLM Providers</CardTitle> <CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
{configuredProviders} of {totalProviders} providers configured {t.env.providersConfigured.replace("{configured}", String(configuredProviders)).replace("{total}", String(totalProviders))}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -538,7 +548,7 @@ export default function EnvPage() {
<CardTitle className="text-base">{label}</CardTitle> <CardTitle className="text-base">{label}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
{setEntries.length} of {totalEntries} configured {setEntries.length} {t.common.of} {totalEntries} {t.common.configured}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -595,6 +605,7 @@ function CollapsibleUnset({
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
}) { }) {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const { t } = useI18n();
return ( return (
<> <>
@ -606,7 +617,7 @@ function CollapsibleUnset({
{collapsed {collapsed
? <ChevronRight className="h-3 w-3" /> ? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />} : <ChevronDown className="h-3 w-3" />}
<span>{unsetEntries.length} not configured</span> <span>{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}</span>
</button> </button>
{!collapsed && unsetEntries.map(([key, info]) => ( {!collapsed && unsetEntries.map(([key, info]) => (

View file

@ -1,19 +1,12 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { import { FileText, RefreshCw } from "lucide-react";
AlertTriangle,
Bug,
ChevronRight,
FileText,
Hash,
Layers,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
const FILES = ["agent", "errors", "gateway"] as const; const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const; const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
@ -35,6 +28,37 @@ const LINE_COLORS: Record<string, string> = {
debug: "text-muted-foreground/60", debug: "text-muted-foreground/60",
}; };
function FilterBar<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
<div className="flex gap-1 flex-wrap">
{options.map((opt) => (
<Button
key={opt}
variant={value === opt ? "default" : "outline"}
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => onChange(opt)}
>
{opt}
</Button>
))}
</div>
</div>
);
}
export default function LogsPage() { export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL"); const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
@ -45,6 +69,7 @@ export default function LogsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const fetchLogs = useCallback(() => { const fetchLogs = useCallback(() => {
setLoading(true); setLoading(true);
@ -53,6 +78,7 @@ export default function LogsPage() {
.getLogs({ file, lines: lineCount, level, component }) .getLogs({ file, lines: lineCount, level, component })
.then((resp) => { .then((resp) => {
setLines(resp.lines); setLines(resp.lines);
// Auto-scroll to bottom
setTimeout(() => { setTimeout(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@ -63,10 +89,12 @@ export default function LogsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [file, lineCount, level, component]); }, [file, lineCount, level, component]);
// Initial load + refetch on filter change
useEffect(() => { useEffect(() => {
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
// Auto-refresh polling
useEffect(() => { useEffect(() => {
if (!autoRefresh) return; if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000); const interval = setInterval(fetchLogs, 5000);
@ -74,176 +102,76 @@ export default function LogsPage() {
}, [autoRefresh, fetchLogs]); }, [autoRefresh, fetchLogs]);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-6">
{/* ═══════════════ Header ═══════════════ */} <Card>
<div className="flex items-center justify-between gap-4"> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<FileText className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <FileText className="h-5 w-5 text-muted-foreground" />
{file} / {level.toLowerCase()} / {component} <CardTitle className="text-base">{t.logs.title}</CardTitle>
</span> {loading && (
{loading && ( <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" /> )}
)} </div>
</div> <div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-3"> <Switch
<div className="flex items-center gap-2"> checked={autoRefresh}
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} /> onCheckedChange={setAutoRefresh}
<Label className="text-xs">Auto-refresh</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* File section */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<SidebarHeading icon={FileText} label="File" />
{FILES.map((f) => (
<SidebarItem
key={f}
label={f}
active={file === f}
indented
onClick={() => setFile(f)}
/> />
))} <Label className="text-xs">{t.logs.autoRefresh}</Label>
{autoRefresh && (
<div className="hidden sm:block border-t border-border my-1" /> <Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
<SidebarHeading icon={AlertTriangle} label="Level" /> {t.common.live}
{LEVELS.map((l) => ( </Badge>
<SidebarItem )}
key={l} </div>
label={l.toLowerCase()} <Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
active={level === l} <RefreshCw className="h-3 w-3 mr-1" />
indented {t.common.refresh}
onClick={() => setLevel(l)} </Button>
/>
))}
<div className="hidden sm:block border-t border-border my-1" />
<SidebarHeading icon={Layers} label="Component" />
{COMPONENTS.map((c) => (
<SidebarItem
key={c}
label={c}
active={component === c}
indented
onClick={() => setComponent(c)}
/>
))}
<div className="hidden sm:block border-t border-border my-1" />
<SidebarHeading icon={Hash} label="Lines" />
{LINE_COUNTS.map((n) => (
<SidebarItem
key={n}
label={String(n)}
active={lineCount === n}
indented
onClick={() => setLineCount(n)}
/>
))}
</div> </div>
</div> </div>
</div> </CardHeader>
{/* ---- Content ---- */} <CardContent>
<div className="flex-1 min-w-0"> <div className="flex flex-col gap-3 mb-4">
<Card> <FilterBar label={t.logs.file} options={FILES} value={file} onChange={setFile} />
<CardHeader className="py-3 px-4"> <FilterBar label={t.logs.level} options={LEVELS} value={level} onChange={setLevel} />
<div className="flex items-center justify-between"> <FilterBar label={t.logs.component} options={COMPONENTS} value={component} onChange={setComponent} />
<CardTitle className="text-sm flex items-center gap-2"> <FilterBar
<Bug className="h-4 w-4" /> label={t.logs.lines}
{file} logs options={LINE_COUNTS.map(String) as unknown as readonly string[]}
</CardTitle> value={String(lineCount)}
<Badge variant="secondary" className="text-[10px]"> onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
{lines.length} line{lines.length !== 1 ? "s" : ""} />
</Badge> </div>
</div>
</CardHeader> {error && (
<CardContent className="px-4 pb-4"> <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
{error && ( <p className="text-sm text-destructive">{error}</p>
<div className="bg-destructive/10 border border-destructive/20 p-3 mb-4"> </div>
<p className="text-sm text-destructive">{error}</p> )}
<div
ref={scrollRef}
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">{t.logs.noLogLines}</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
{line}
</div> </div>
)} );
})}
<div </div>
ref={scrollRef} </CardContent>
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]" </Card>
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">No log lines found</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div> </div>
); );
} }
function SidebarHeading({ icon: Icon, label }: SidebarHeadingProps) {
return (
<div className="flex items-center gap-2 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
);
}
function SidebarItem({ label, active, indented, onClick }: SidebarItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`group flex items-center gap-2 ${indented ? "sm:pl-6" : ""} px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
active
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{label}</span>
{active && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
</button>
);
}
interface SidebarHeadingProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
}
interface SidebarItemProps {
label: string;
active: boolean;
indented?: boolean;
onClick: () => void;
}

View file

@ -20,13 +20,7 @@ import { Markdown } from "@/components/Markdown";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
};
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = { const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
cli: { icon: Terminal, color: "text-primary" }, cli: { icon: Terminal, color: "text-primary" },
@ -50,7 +44,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last, match.index)); parts.push(snippet.slice(last, match.index));
} }
parts.push( parts.push(
<mark key={i++} className="bg-warning/30 text-warning px-0.5"> <mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
{match[1]} {match[1]}
</mark> </mark>
); );
@ -68,6 +62,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) { function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useI18n();
let args = toolCall.function.arguments; let args = toolCall.function.arguments;
try { try {
@ -77,12 +72,12 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
} }
return ( return (
<div className="mt-2 border border-warning/20 bg-warning/5"> <div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors" className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`} aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
> >
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span className="font-mono-ui font-medium">{toolCall.function.name}</span> <span className="font-mono-ui font-medium">{toolCall.function.name}</span>
@ -98,8 +93,17 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
} }
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) { function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
const { t } = useI18n();
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: t.sessions.roles.user },
assistant: { bg: "bg-success/10", text: "text-success", label: t.sessions.roles.assistant },
system: { bg: "bg-muted", text: "text-muted-foreground", label: t.sessions.roles.system },
tool: { bg: "bg-warning/10", text: "text-warning", label: t.sessions.roles.tool },
};
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label; const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label;
// Check if any search term appears as a prefix of any word in content // Check if any search term appears as a prefix of any word in content
const isHit = (() => { const isHit = (() => {
@ -119,7 +123,7 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span> <span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && ( {isHit && (
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge> <Badge variant="warning" className="text-[9px] py-0 px-1.5">{t.common.match}</Badge>
)} )}
{msg.timestamp && ( {msg.timestamp && (
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span> <span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
@ -184,6 +188,7 @@ function SessionRow({
const [messages, setMessages] = useState<SessionMessage[] | null>(null); const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
useEffect(() => { useEffect(() => {
if (isExpanded && messages === null && !loading) { if (isExpanded && messages === null && !loading) {
@ -217,23 +222,23 @@ function SessionRow({
<div className="flex flex-col gap-0.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}> <span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")} {hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : t.sessions.untitledSession)}
</span> </span>
{session.is_active && ( {session.is_active && (
<Badge variant="success" className="text-[10px] shrink-0"> <Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" /> <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live {t.common.live}
</Badge> </Badge>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span> <span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? t.common.unknown).split("/").pop()}</span>
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
<span>{session.message_count} msgs</span> <span>{session.message_count} {t.common.msgs}</span>
{session.tool_call_count > 0 && ( {session.tool_call_count > 0 && (
<> <>
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
<span>{session.tool_call_count} tools</span> <span>{session.tool_call_count} {t.common.tools}</span>
</> </>
)} )}
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
@ -253,7 +258,7 @@ function SessionRow({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
aria-label="Delete session" aria-label={t.sessions.deleteSession}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
@ -275,7 +280,7 @@ function SessionRow({
<p className="text-sm text-destructive py-4 text-center">{error}</p> <p className="text-sm text-destructive py-4 text-center">{error}</p>
)} )}
{messages && messages.length === 0 && ( {messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p> <p className="text-sm text-muted-foreground py-4 text-center">{t.sessions.noMessages}</p>
)} )}
{messages && messages.length > 0 && ( {messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} /> <MessageList messages={messages} highlight={searchQuery} />
@ -297,6 +302,7 @@ export default function SessionsPage() {
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const { t } = useI18n();
const loadSessions = useCallback((p: number) => { const loadSessions = useCallback((p: number) => {
setLoading(true); setLoading(true);
@ -377,7 +383,7 @@ export default function SessionsPage() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" /> <MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Sessions</h1> <h1 className="text-base font-semibold">{t.sessions.title}</h1>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{total} {total}
</Badge> </Badge>
@ -389,7 +395,7 @@ export default function SessionsPage() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)} )}
<Input <Input
placeholder="Search message content..." placeholder={t.sessions.searchPlaceholder}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-7 h-8 text-xs" className="pl-8 pr-7 h-8 text-xs"
@ -410,10 +416,10 @@ export default function SessionsPage() {
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" /> <Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{search ? "No sessions match your search" : "No sessions yet"} {search ? t.sessions.noMatch : t.sessions.noSessions}
</p> </p>
{!search && ( {!search && (
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p> <p className="text-xs mt-1 text-muted-foreground/60">{t.sessions.startConversation}</p>
)} )}
</div> </div>
) : ( ) : (
@ -438,7 +444,7 @@ export default function SessionsPage() {
{!searchResults && total > PAGE_SIZE && ( {!searchResults && total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} of {total} {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} {t.common.of} {total}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@ -447,12 +453,12 @@ export default function SessionsPage() {
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
disabled={page === 0} disabled={page === 0}
onClick={() => setPage((p) => p - 1)} onClick={() => setPage((p) => p - 1)}
aria-label="Previous page" aria-label={t.sessions.previousPage}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-xs text-muted-foreground px-2"> <span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)} {t.common.page} {page + 1} {t.common.of} {Math.ceil(total / PAGE_SIZE)}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -460,7 +466,7 @@ export default function SessionsPage() {
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
disabled={(page + 1) * PAGE_SIZE >= total} disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
aria-label="Next page" aria-label={t.sessions.nextPage}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>

View file

@ -1,28 +1,13 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { import {
Blocks,
Bot,
BrainCircuit,
ChevronRight,
Code,
Database,
FileCode,
FileSearch,
Globe,
Image,
LayoutDashboard,
Monitor,
Package, Package,
Paintbrush,
Search, Search,
Server,
Shield,
Sparkles,
Terminal,
Wrench, Wrench,
ChevronDown,
ChevronRight,
Filter,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ComponentType } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
@ -31,11 +16,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types & helpers */ /* Types & helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps", mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud", "mlops/cloud": "MLOps / Cloud",
@ -53,8 +46,8 @@ const CATEGORY_LABELS: Record<string, string> = {
ui: "UI", ui: "UI",
}; };
function prettyCategory(raw: string | null | undefined): string { function prettyCategory(raw: string | null | undefined, generalLabel: string): string {
if (!raw) return "General"; if (!raw) return generalLabel;
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw]; if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw return raw
.split(/[-_/]/) .split(/[-_/]/)
@ -62,63 +55,31 @@ function prettyCategory(raw: string | null | undefined): string {
.join(" "); .join(" ");
} }
const TOOLSET_ICONS: Record<string, ComponentType<{ className?: string }>> = {
terminal: Terminal,
shell: Terminal,
browser: Globe,
web: Globe,
code: Code,
coding: Code,
python: FileCode,
files: FileSearch,
file: FileSearch,
search: Search,
image: Image,
vision: Image,
memory: BrainCircuit,
database: Database,
db: Database,
mcp: Blocks,
ai: Sparkles,
agent: Bot,
security: Shield,
server: Server,
deploy: Server,
ui: Paintbrush,
ux: LayoutDashboard,
display: Monitor,
};
function toolsetIcon(name: string, label: string): ComponentType<{ className?: string }> {
const lower = name.toLowerCase();
if (TOOLSET_ICONS[lower]) return TOOLSET_ICONS[lower];
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
if (lower.includes(key) || label.toLowerCase().includes(key)) return icon;
}
return Wrench;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function SkillsPage() { export default function SkillsPage() {
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [skills, setSkills] = useState<SkillInfo[]>([]); const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]); const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set()); const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n();
useEffect(() => { useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()]) Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, t]) => { .then(([s, tsets]) => {
setSkills(s); setSkills(s);
setToolsets(t); setToolsets(tsets);
}) })
.catch(() => showToast("Failed to load skills/toolsets", "error")) .catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@ -133,11 +94,11 @@ export default function SkillsPage() {
) )
); );
showToast( showToast(
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`, `${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
"success" "success"
); );
} catch { } catch {
showToast(`Failed to toggle ${skill.name}`, "error"); showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
} finally { } finally {
setTogglingSkills((prev) => { setTogglingSkills((prev) => {
const next = new Set(prev); const next = new Set(prev);
@ -164,6 +125,27 @@ export default function SkillsPage() {
}); });
}, [skills, search, lowerSearch, activeCategory]); }, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => {
const map = new Map<string, SkillInfo[]>();
for (const s of filteredSkills) {
const key = s.category || "__none__";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Sort: General first, then alphabetical
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
});
return entries.map(([key, list]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const cats = new Map<string, number>(); const cats = new Map<string, number>();
for (const s of skills) { for (const s of skills) {
@ -176,39 +158,40 @@ export default function SkillsPage() {
if (b[0] === "__none__") return 1; if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]); return a[0].localeCompare(b[0]);
}) })
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count })); .map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key, t.common.general), count }));
}, [skills]); }, [skills]);
const enabledCount = skills.filter((s) => s.enabled).length; const enabledCount = skills.filter((s) => s.enabled).length;
const filteredToolsets = useMemo(() => { const filteredToolsets = useMemo(() => {
return toolsets.filter( return toolsets.filter(
(t) => (ts) =>
!search || !search ||
t.name.toLowerCase().includes(lowerSearch) || ts.name.toLowerCase().includes(lowerSearch) ||
t.label.toLowerCase().includes(lowerSearch) || ts.label.toLowerCase().includes(lowerSearch) ||
t.description.toLowerCase().includes(lowerSearch) ts.description.toLowerCase().includes(lowerSearch)
); );
}, [toolsets, search, lowerSearch]); }, [toolsets, search, lowerSearch]);
const isSearching = search.trim().length > 0; const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
const activeToolsetCount = toolsets.filter((t) => t.enabled).length; const toggleCollapse = (key: string) => {
setCollapsedCategories((prev) => {
const searchMatchedSkills = useMemo(() => { if (prev === "all") {
if (!isSearching) return []; // Switching from "all collapsed" → expand just this one
return skills.filter( const allKeys = new Set(categoryGroups.map((g) => g.key));
(s) => allKeys.delete(key);
s.name.toLowerCase().includes(lowerSearch) || return allKeys;
s.description.toLowerCase().includes(lowerSearch) || }
(s.category ?? "").toLowerCase().includes(lowerSearch), const next = new Set(prev);
); if (next.has(key)) next.delete(key);
}, [isSearching, skills, lowerSearch]); else next.add(key);
return next;
const activeSkills = useMemo(() => { });
if (isSearching) return []; };
return [...filteredSkills].sort((a, b) => a.name.localeCompare(b.name));
}, [isSearching, filteredSkills]);
/* ---- Loading ---- */ /* ---- Loading ---- */
if (loading) { if (loading) {
@ -219,303 +202,240 @@ export default function SkillsPage() {
); );
} }
const activeCategoryName = activeCategory
? prettyCategory(activeCategory === "__none__" ? null : activeCategory)
: "All Skills";
const renderSkillList = (list: SkillInfo[]) => (
<div className="grid gap-1">
{list.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled ? "text-foreground" : "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-6">
<Toast toast={toast} /> <Toast toast={toast} />
{/* ═══════════════ Header ═══════════════ */} {/* ═══════════════ Header + Search ═══════════════ */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-4">
{view === "skills" ? ( <div className="flex items-center gap-3">
<Package className="h-4 w-4 text-muted-foreground" /> <Package className="h-5 w-5 text-muted-foreground" />
) : ( <h1 className="text-base font-semibold">{t.skills.title}</h1>
<Wrench className="h-4 w-4 text-muted-foreground" /> <span className="text-xs text-muted-foreground">
)} {t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
<span className="text-xs text-muted-foreground"> </span>
{view === "skills" </div>
? `${enabledCount}/${skills.length} skills enabled`
: `${activeToolsetCount}/${toolsets.length} toolsets active`}
</span>
</div> </div>
{/* ═══════════════ Sidebar + Content ═══════════════ */} {/* ═══════════════ Search + Category Filter ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* ---- Sidebar ---- */} <div className="relative flex-1">
<div className="sm:w-52 sm:shrink-0"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1"> <Input
{/* Search */} className="pl-9"
<div className="relative mb-2 hidden sm:block"> placeholder={t.skills.searchPlaceholder}
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> value={search}
<Input onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 text-xs" />
placeholder="Search..." {search && (
value={search} <button
onChange={(e) => setSearch(e.target.value)} type="button"
/> className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
{search && ( onClick={() => setSearch("")}
<button >
type="button" <X className="h-4 w-4" />
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" </button>
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Nav items */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
{/* Skills top-level */}
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !activeCategory && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">All Skills</span>
<span className={`text-[10px] tabular-nums ${
view === "skills" && !activeCategory && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{skills.length}
</span>
{view === "skills" && !activeCategory && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill category sub-items */}
{allCategories.map(({ key, name, count }) => {
const isActive = view === "skills" && activeCategory === key && !isSearching;
return (
<button
key={key}
type="button"
onClick={() => {
setView("skills");
setActiveCategory(key);
setSearch("");
}}
className={`group flex items-center gap-2 sm:pl-6 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"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span className={`text-[10px] tabular-nums ${
isActive ? "text-primary/60" : "text-muted-foreground/50"
}`}>
{count}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
{/* Divider */}
<div className="hidden sm:block border-t border-border my-1" />
{/* Toolsets top-level */}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">Toolsets</span>
<span className={`text-[10px] tabular-nums ${
view === "toolsets" && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{toolsets.length}
</span>
{view === "toolsets" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{/* Search results (across both skills and toolsets) */}
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
Search Results
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedSkills.length} skill{searchMatchedSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{searchMatchedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No skills match &ldquo;<span className="text-foreground">{search}</span>&rdquo;
</p>
) : (
renderSkillList(searchMatchedSkills)
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* ---- Skills view ---- */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4" />
{activeCategoryName}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeSkills.length} skill{activeSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{activeSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills in this category."}
</p>
) : (
renderSkillList(activeSkills)
)}
</CardContent>
</Card>
) : (
/* ---- Toolsets view ---- */
<>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets found.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const TsIcon = toolsetIcon(ts.name, ts.label);
return (
<Card key={ts.name}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 shrink-0 mt-0.5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
{/* Category pills */}
{allCategories.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
!activeCategory
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => setActiveCategory(null)}
>
{t.skills.all} ({skills.length})
</button>
{allCategories.map(({ key, name, count }) => (
<button
key={key}
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeCategory === key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
>
{name}
<span className="ml-1 opacity-60">{count}</span>
</button>
))}
</div>
)}
{/* ═══════════════ Skills by Category ═══════════════ */}
<section className="flex flex-col gap-3">
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{skills.length === 0
? t.skills.noSkills
: t.skills.noSkillsMatch}
</CardContent>
</Card>
) : (
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
const collapsed = isCollapsed(key);
return (
<Card key={key}>
<CardHeader
className="cursor-pointer select-none py-3 px-4"
onClick={() => toggleCollapse(key)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{t.skills.skillCount.replace("{count}", String(catSkills.length)).replace("{s}", catSkills.length !== 1 ? "s" : "")}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{t.skills.enabledOf.replace("{enabled}", String(catEnabled)).replace("{total}", String(catSkills.length))}
</Badge>
</div>
</CardHeader>
{collapsed ? (
/* Peek: show first few skill names so collapsed isn't blank */
<div className="px-4 pb-3 flex items-center min-h-[28px]">
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
{catSkills.length > 4 && `, ${t.skills.more.replace("{count}", String(catSkills.length - 4))}`}
</p>
</div>
) : (
<CardContent className="pt-0 px-4 pb-3">
<div className="grid gap-1">
{catSkills.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled
? "text-foreground"
: "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || t.skills.noDescription}
</p>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
);
})
)}
</section>
{/* ═══════════════ Toolsets ═══════════════ */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wrench className="h-4 w-4" />
{t.skills.toolsets} ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
// Strip emoji prefix from label for cleaner display
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
return (
<Card key={ts.name} className="relative overflow-hidden">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? t.common.active : t.common.inactive}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
{t.skills.setupNeeded}
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div> </div>
); );
} }

View file

@ -14,37 +14,12 @@ import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { timeAgo, isoTimeAgo } from "@/lib/utils"; import { timeAgo, isoTimeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useI18n } from "@/i18n";
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
connected: { variant: "success", label: "Connected" },
disconnected: { variant: "warning", label: "Disconnected" },
fatal: { variant: "destructive", label: "Error" },
};
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
running: { badge: "success", label: "Running" },
starting: { badge: "warning", label: "Starting" },
startup_failed: { badge: "destructive", label: "Failed" },
stopped: { badge: "outline", label: "Stopped" },
};
function gatewayValue(status: StatusResponse): string {
if (status.gateway_running) return `PID ${status.gateway_pid}`;
if (status.gateway_state === "startup_failed") return "Start failed";
return "Not running";
}
function gatewayBadge(status: StatusResponse) {
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
if (info) return info;
return status.gateway_running
? { badge: "success" as const, label: "Running" }
: { badge: "outline" as const, label: "Off" };
}
export default function StatusPage() { export default function StatusPage() {
const [status, setStatus] = useState<StatusResponse | null>(null); const [status, setStatus] = useState<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const { t } = useI18n();
useEffect(() => { useEffect(() => {
const load = () => { const load = () => {
@ -64,28 +39,55 @@ export default function StatusPage() {
); );
} }
const gwBadge = gatewayBadge(status); const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
connected: { variant: "success", label: t.status.connected },
disconnected: { variant: "warning", label: t.status.disconnected },
fatal: { variant: "destructive", label: t.status.error },
};
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
running: { badge: "success", label: t.status.running },
starting: { badge: "warning", label: t.status.starting },
startup_failed: { badge: "destructive", label: t.status.failed },
stopped: { badge: "outline", label: t.status.stopped },
};
function gatewayValue(): string {
if (status!.gateway_running) return `${t.status.pid} ${status!.gateway_pid}`;
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
return t.status.notRunning;
}
function gatewayBadge() {
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null;
if (info) return info;
return status!.gateway_running
? { badge: "success" as const, label: t.status.running }
: { badge: "outline" as const, label: t.common.off };
}
const gwBadge = gatewayBadge();
const items = [ const items = [
{ {
icon: Cpu, icon: Cpu,
label: "Agent", label: t.status.agent,
value: `v${status.version}`, value: `v${status.version}`,
badgeText: "Live", badgeText: t.common.live,
badgeVariant: "success" as const, badgeVariant: "success" as const,
}, },
{ {
icon: Radio, icon: Radio,
label: "Gateway", label: t.status.gateway,
value: gatewayValue(status), value: gatewayValue(),
badgeText: gwBadge.label, badgeText: gwBadge.label,
badgeVariant: gwBadge.badge, badgeVariant: gwBadge.badge,
}, },
{ {
icon: Activity, icon: Activity,
label: "Active Sessions", label: t.status.activeSessions,
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None", value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning,
badgeText: status.active_sessions > 0 ? "Live" : "Off", badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline", badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
}, },
]; ];
@ -98,19 +100,19 @@ export default function StatusPage() {
const alerts: { message: string; detail?: string }[] = []; const alerts: { message: string; detail?: string }[] = [];
if (status.gateway_state === "startup_failed") { if (status.gateway_state === "startup_failed") {
alerts.push({ alerts.push({
message: "Gateway failed to start", message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined, detail: status.gateway_exit_reason ?? undefined,
}); });
} }
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected"); const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
for (const [name, info] of failedPlatforms) { for (const [name, info] of failedPlatforms) {
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected;
alerts.push({ alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`, message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined, detail: info.error_message ?? undefined,
}); });
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Alert banner — breaks grid monotony for critical states */} {/* Alert banner — breaks grid monotony for critical states */}
@ -157,7 +159,7 @@ export default function StatusPage() {
</div> </div>
{platforms.length > 0 && ( {platforms.length > 0 && (
<PlatformsCard platforms={platforms} /> <PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
)} )}
{activeSessions.length > 0 && ( {activeSessions.length > 0 && (
@ -165,7 +167,7 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" /> <Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">Active Sessions</CardTitle> <CardTitle className="text-base">{t.status.activeSessions}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@ -177,16 +179,16 @@ export default function StatusPage() {
> >
<div className="flex flex-col gap-1 min-w-0 w-full"> <div className="flex flex-col gap-1 min-w-0 w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{s.title ?? "Untitled"}</span> <span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
<Badge variant="success" className="text-[10px] shrink-0"> <Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" /> <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live {t.common.live}
</Badge> </Badge>
</div> </div>
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? "unknown").split("/").pop()}</span> · {s.message_count} msgs · {timeAgo(s.last_active)} <span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
</span> </span>
</div> </div>
</div> </div>
@ -200,7 +202,7 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" /> <Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Recent Sessions</CardTitle> <CardTitle className="text-base">{t.status.recentSessions}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@ -211,10 +213,10 @@ export default function StatusPage() {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
> >
<div className="flex flex-col gap-1 min-w-0 w-full"> <div className="flex flex-col gap-1 min-w-0 w-full">
<span className="font-medium text-sm truncate">{s.title ?? "Untitled"}</span> <span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? "unknown").split("/").pop()}</span> · {s.message_count} msgs · {timeAgo(s.last_active)} <span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
</span> </span>
{s.preview && ( {s.preview && (
@ -237,19 +239,21 @@ export default function StatusPage() {
); );
} }
function PlatformsCard({ platforms }: PlatformsCardProps) { function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
const { t } = useI18n();
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" /> <Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Connected Platforms</CardTitle> <CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
{platforms.map(([name, info]) => { {platforms.map(([name, info]) => {
const display = PLATFORM_STATE_BADGE[info.state] ?? { const display = platformStateBadge[info.state] ?? {
variant: "outline" as const, variant: "outline" as const,
label: info.state, label: info.state,
}; };
@ -278,7 +282,7 @@ function PlatformsCard({ platforms }: PlatformsCardProps) {
{info.updated_at && ( {info.updated_at && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Last update: {isoTimeAgo(info.updated_at)} {t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span> </span>
)} )}
</div> </div>
@ -300,4 +304,5 @@ function PlatformsCard({ platforms }: PlatformsCardProps) {
interface PlatformsCardProps { interface PlatformsCardProps {
platforms: [string, PlatformStatus][]; platforms: [string, PlatformStatus][];
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>;
} }