Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Hermes Agent — Web UI
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
## Stack
- **Vite** + **React 19** + **TypeScript**
- **Tailwind CSS v4** with custom dark theme
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
## Development
```bash
# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm install
npm run dev
```
Open the **Vite URL** printed in the terminal (usually `http://localhost:5173`). That is the live-reload UI.
`hermes dashboard` on port 9119 serves the **built** bundle from `hermes_cli/web_dist/`, not the Vite dev server — changes in `web/src/` will not appear there until you run `npm run build` and restart the dashboard (or use `web --no-open` + Vite as above).
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build
```bash
npm run build
```
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
## Structure
```
src/
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
│ └── utils.ts # cn() helper for Tailwind class merging
├── pages/
│ ├── StatusPage # Agent status, active/recent sessions
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
│ └── EnvPage # API key management with save/clear
├── App.tsx # Main layout and navigation
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```
## Typography & contrast rules
Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.
### Text size floor
- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`.
- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read.
### Opacity floor on text
- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on `<span>`s, `<p>`s, labels, etc.
- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha.
- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`:
- `text-text-primary` — default body text.
- `text-text-secondary` — subtitles, meta, inactive nav.
- `text-text-tertiary` — small chrome labels, counts, footnotes.
- `text-text-disabled` — disabled states.
- `text-text-on-accent` — text on filled accent surfaces.
### Brand uppercase via `text-display`, not raw `uppercase`
- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**.
- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`.
- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated.
- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).
### Fonts
Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.
| Tier | Classes | Use for |
|------|---------|---------|
| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings |
| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** |
| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` |
| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` |
| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs |
- Do **not** put `themedBody` or `themedFont` on `<main>`, `App`, or other layout wrappers — it overrides component-scoped styles.
- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`.
- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed.
- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.
### Color tokens
- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`).
- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name.
- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text.
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Hermes Agent - Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8887
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@nous-research/ui": "0.18.2",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.
Binary file not shown.
+1175
View File
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
/**
* AuthWidget — sidebar "Logged in as …" affordance for the dashboard
* OAuth gate (Phase 7 of .hermes/plans/2026-05-21-dashboard-oauth-auth.md).
*
* Renders nothing in loopback / --insecure mode. In gated mode, fetches
* /api/auth/me on mount and surfaces:
*
* - the user_id (truncated to 14 chars + ellipsis) since the Nous Portal
* contract V1 doesn't emit email/display_name claims (Contract Anchor
* C4 in the plan; the API responds with empty strings for those
* fields, so we use user_id as the display value)
* - the provider's display_name (looked up from /api/auth/providers,
* defaults to the bare provider key)
* - a logout button that POSTs /auth/logout and full-page-navigates to
* /login (the dashboard becomes inaccessible again)
*
* Failure modes:
* - 401 from /api/auth/me means we're not gated (or the gate is on but
* we have no cookie — in that case the gate's middleware would have
* redirected us before App.tsx renders, so we won't see this). The
* widget renders nothing.
* - Network error: shows a minimal "auth status unavailable" message
* so the user knows the widget tried.
*/
import { useEffect, useState } from "react";
import { api, type AuthMeResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { LogOut } from "lucide-react";
interface AuthWidgetProps {
className?: string;
}
/** Truncate ``user_id`` to fit a small UI without revealing the full
* opaque identifier. 14 chars is enough to disambiguate users in a
* small org and short enough to fit a single sidebar row. */
function truncateUserId(id: string): string {
if (id.length <= 14) return id;
return `${id.slice(0, 14)}`;
}
export function AuthWidget({ className }: AuthWidgetProps) {
const [me, setMe] = useState<AuthMeResponse | null>(null);
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getAuthMe()
.then((data) => {
if (cancelled) return;
setMe(data);
})
.catch((err: unknown) => {
if (cancelled) return;
// 401 from /api/auth/me means the gate isn't engaged in this
// process (loopback mode) — render nothing. fetchJSON throws an
// Error with the status code as a prefix; the global 401
// handler only redirects on the structured envelope, so a plain
// 401 from /api/auth/me with no envelope bubbles up here.
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith("401:") || msg.startsWith("403:")) {
setHidden(true);
return;
}
setError("auth status unavailable");
});
return () => {
cancelled = true;
};
}, []);
if (hidden) return null;
if (error) {
return (
<div
className={cn(
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
className,
)}
>
{error}
</div>
);
}
if (!me) {
// Loading. Reserve the row height so the sidebar doesn't flicker
// when the data arrives.
return (
<div
className={cn(
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
className,
)}
aria-busy="true"
>
</div>
);
}
const handleLogout = () => {
void api.logout();
};
// Prefer display_name → email → truncated user_id. Contract V1 only
// populates user_id; the fallthroughs are forward-compat for a future
// Portal that adds a userinfo endpoint (OQ-C1 in the plan).
const label = me.display_name || me.email || truncateUserId(me.user_id);
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2",
"border-t border-current/10",
"text-[0.65rem] tracking-[0.05em]",
className,
)}
role="status"
aria-label={`Logged in as ${label}`}
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
{label}
</span>
<span className="truncate text-muted-foreground/70">
via {me.provider}
</span>
</div>
<button
type="button"
onClick={handleLogout}
className={cn(
"shrink-0 rounded p-1.5 text-muted-foreground/70",
"transition-colors hover:bg-current/10 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
)}
aria-label="Log out"
title="Log out"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
{description && <span className="text-xs text-text-secondary">{description}</span>}
</div>
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatScalar(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function NestedValueEditor({
fieldKey,
value,
onChange,
}: {
fieldKey: string;
value: unknown;
onChange: (v: unknown) => void;
}) {
if (isRecord(value)) {
return (
<div className="grid gap-2 border border-border p-2">
{Object.entries(value).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${subKey}`}
value={subVal}
onChange={(next) => onChange({ ...value, [subKey]: next })}
/>
</div>
))}
</div>
);
}
if (Array.isArray(value)) {
return (
<div className="grid gap-2">
{value.map((item, index) => (
<div key={`${fieldKey}.${index}`} className="grid gap-1">
<Label className="text-xs text-muted-foreground">Item {index + 1}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${index}`}
value={item}
onChange={(next) =>
onChange(value.map((existing, i) => (i === index ? next : existing)))
}
/>
</div>
))}
</div>
);
}
return (
<Input
value={formatScalar(value)}
onChange={(e) => onChange(e.target.value)}
className="text-xs"
/>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) {
return (
<div className="grid gap-3 border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<NestedValueEditor fieldKey={schemaKey} value={value} onChange={onChange} />
</div>
);
}
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
{options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt || "(none)"}
</SelectOption>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
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"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}
+93
View File
@@ -0,0 +1,93 @@
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
/**
* Replicates the visual layer stack of `<Overlays dark />` from
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
*
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
* truth. Defaults match LENS_0 (the Hermes teal dark preset); the deep canvas
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
* in that case so low-power / accessibility-conscious sessions stay crisp,
* mirroring the DS `<Noise />` component's own opt-out.
*/
export function Backdrop() {
const gpuTier = useGpuTier();
return (
<>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
/>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[2]"
style={
{
// Themes can override the filler background by setting
// `assets.bg` — the <img> hides itself when a CSS bg is set
// so the two don't double-darken. CSS var fallbacks keep the
// default behaviour unchanged when no theme customises these.
mixBlendMode:
"var(--component-backdrop-filler-blend-mode, difference)",
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
backgroundImage: "var(--theme-asset-bg)",
backgroundSize: "var(--component-backdrop-background-size, cover)",
backgroundPosition:
"var(--component-backdrop-background-position, center)",
} as unknown as React.CSSProperties
}
>
<img
alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
fetchPriority="low"
src={fillerBgUrl}
/>
</div>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[99]"
style={{
background:
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
mixBlendMode: "lighten",
opacity: 0.22,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[101]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
backgroundSize: "512px 512px",
mixBlendMode: "color-dodge",
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
}}
/>
)}
</>
);
}
+394
View File
@@ -0,0 +1,394 @@
/**
* ChatSidebar — structured-events panel that sits next to the xterm.js
* terminal in the dashboard Chat tab.
*
* Two WebSockets, one per concern:
*
* 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the
* sidebar's own slot of the dashboard's in-process gateway. Owns
* the model badge / picker / connection state / error banner.
* Independent of the PTY pane's session by design — those are the
* pieces the sidebar needs to be able to drive directly (model
* switch via slash.exec, etc.).
*
* 2. **Event subscriber** (/api/events?channel=…) — passive, receives
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
* the dashboard fanned out. This is how `tool.start/progress/
* complete` from the agent loop reach the sidebar even though the
* PTY child runs three processes deep from us. The `channel` id
* ties this listener to the same chat tab's PTY child — see
* `ChatPage.tsx` for where the id is generated.
*
* Best-effort throughout: WS failures show in the badge / banner, the
* terminal pane keeps working unimpaired.
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card } from "@nous-research/ui/ui/components/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
}
const TOOL_LIMIT = 20;
const STATE_LABEL: Record<ConnectionState, string> = {
idle: "idle",
connecting: "connecting",
open: "live",
closed: "closed",
error: "error",
};
const STATE_TONE: Record<
ConnectionState,
"secondary" | "warning" | "success" | "destructive"
> = {
idle: "secondary",
connecting: "warning",
open: "success",
closed: "secondary",
error: "destructive",
};
interface ChatSidebarProps {
channel: string;
className?: string;
}
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
// it's the signal that says "rebuild the client".
const [version, setVersion] = useState(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const gw = useMemo(() => new GatewayClient(), [version]);
const [state, setState] = useState<ConnectionState>("idle");
const [sessionId, setSessionId] = useState<string | null>(null);
const [info, setInfo] = useState<SessionInfo>({});
const [tools, setTools] = useState<ToolEntry[]>([]);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
if (ev.session_id) {
setSessionId(ev.session_id);
}
if (ev.payload) {
setInfo((prev) => ({ ...prev, ...ev.payload }));
}
});
const offError = gw.on<{ message?: string }>("error", (ev) => {
const message = ev.payload?.message;
if (message) {
setError(message);
}
});
// Adopt whichever session the gateway hands us. session.create on the
// sidecar is independent of the PTY pane's session by design — we
// only need a sid to drive the model picker's slash.exec calls.
gw.connect()
.then(() => {
if (cancelled) {
return;
}
return gw.request<{ session_id: string }>("session.create", {});
})
.then((created) => {
if (cancelled || !created?.session_id) {
return;
}
setSessionId(created.session_id);
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
}
});
return () => {
cancelled = true;
offState();
offSessionInfo();
offError();
gw.close();
};
}, [gw]);
// Event subscriber WebSocket — receives the rebroadcast of every
// dispatcher emit from the PTY child's gateway. See /api/pub +
// /api/events in hermes_cli/web_server.py for the broadcast hop.
//
// Failures (auth/loopback rejection, server too old to expose the
// endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
useEffect(() => {
if (!channel) {
return;
}
// In loopback mode the legacy ?token=<session> path is fine; in gated
// mode we have to mint a single-use ticket from the cookie. The IIFE
// keeps the outer effect synchronous so its ``return cleanup`` stays
// at the top level; the local ``ws`` is hoisted to a closed-over
// binding the cleanup reads via ``wsRef``.
let unmounting = false;
let ws: WebSocket | null = null;
void (async () => {
const [authName, authValue] = await buildWsAuthParam();
if (!authValue || unmounting) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ [authName]: authValue, channel });
ws = new WebSocket(
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
const surface = (msg: string) => !unmounting && setError(msg);
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
surface(DISCONNECTED);
}
});
ws.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
if (!p?.tool_id) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
});
})();
return () => {
unmounting = true;
ws?.close();
};
}, [channel, version]);
const reconnect = useCallback(() => {
setError(null);
setTools([]);
setVersion((v) => v + 1);
}, []);
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
// via PTY, so the sidebar doesn't need to surface output of its own.
const onModelSubmit = useCallback(
(slashCommand: string) => {
if (!sessionId) {
return;
}
void gw.request("slash.exec", {
session_id: sessionId,
command: slashCommand,
});
setModelOpen(false);
},
[gw, sessionId],
);
const canPickModel = state === "open" && !!sessionId;
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
className,
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="text-display text-xs tracking-wider text-text-tertiary">
model
</div>
<Button
ghost
size="sm"
disabled={!canPickModel}
onClick={() => setModelOpen(true)}
suffix={
canPickModel ? (
<ChevronDown className="text-text-secondary" />
) : undefined
}
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>
</Button>
</div>
<Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
</Card>
{banner && (
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="wrap-break-word text-destructive">{banner}</div>
{error && (
<Button
size="sm"
outlined
className="mt-1"
onClick={reconnect}
prefix={<RefreshCw />}
>
reconnect
</Button>
)}
</div>
</Card>
)}
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
<div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
tools
</div>
<div className="flex min-h-0 flex-col gap-1.5">
{tools.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-text-secondary">
no tool calls yet
</div>
) : (
tools.map((t) => <ToolCall key={t.id} tool={t} />)
)}
</div>
</Card>
{modelOpen && canPickModel && sessionId && (
<ModelPickerDialog
gw={gw}
sessionId={sessionId}
onClose={() => setModelOpen(false)}
onSubmit={onModelSubmit}
/>
)}
</aside>
);
}
@@ -0,0 +1,40 @@
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { useI18n } from "@/i18n";
export function DeleteConfirmDialog({
cancelLabel,
confirmLabel,
description,
loading,
onCancel,
onConfirm,
open,
title,
}: DeleteConfirmDialogProps) {
const { t } = useI18n();
return (
<ConfirmDialog
open={open}
onCancel={onCancel}
onConfirm={onConfirm}
title={title}
description={description}
loading={loading}
destructive
confirmLabel={confirmLabel ?? t.common.delete}
cancelLabel={cancelLabel ?? t.common.cancel}
/>
);
}
interface DeleteConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
import { useI18n } from "@/i18n/context";
import { LOCALE_META } from "@/i18n";
import type { Locale } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Language picker — shows the current language's endonym, opens a dropdown
* of all supported locales when clicked. Persists choice to localStorage via
* the I18n context.
*
* Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
*
* No country flags by design — languages aren't countries, and flag pairings
* inevitably create political mismappings (e.g. Mandarin variants ≠ any single
* jurisdiction, English ≠ GB, Portuguese ≠ PT). Endonyms are unambiguous.
*
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
* `dropUp` so the list opens above the trigger and avoids clipping below the
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
*/
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open || useMobileSheet) return;
function onPointerDown(e: PointerEvent) {
const target = e.target as Node;
if (containerRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, useMobileSheet]);
const current = LOCALE_META[locale];
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
const sheetTitle = t.language.switchTo;
return (
<div ref={containerRef} className="relative inline-flex">
<Button
ghost
onClick={() => setOpen((v) => !v)}
title={t.language.switchTo}
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
className={cn(
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
collapsed && "hover:bg-transparent",
)}
>
<span className="inline-flex items-center gap-1.5">
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{locale === "en" ? "EN" : current.name}
</Typography>
</span>
</Button>
{useMobileSheet && (
<BottomSheet
backdropDismissLabel={t.common.close}
onClose={() => setOpen(false)}
open={open}
title={sheetTitle}
>
<div aria-label={sheetTitle} role="listbox">
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
</BottomSheet>
)}
{open && !useMobileSheet && (() => {
const rect = containerRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
function LanguageSwitcherOptions({
allLocales,
locale,
setLocale,
setOpen,
}: LanguageSwitcherOptionsProps) {
return (
<>
{allLocales.map(([code, meta]) => {
const selected = code === locale;
return (
<button
aria-selected={selected}
className={cn(
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
"font-mondwest text-display text-xs tracking-[0.08em]",
"hover:bg-accent hover:text-accent-foreground transition-colors",
selected ? "font-semibold text-foreground" : "text-muted-foreground",
)}
key={code}
onClick={() => {
setLocale(code);
setOpen(false);
}}
role="option"
type="button"
>
<span className="truncate">{meta.name}</span>
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
</button>
);
})}
</>
);
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
locale: Locale;
setLocale: (code: Locale) => void;
setOpen: (open: boolean) => void;
}
interface LanguageSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+383
View File
@@ -0,0 +1,383 @@
import { useMemo, type ReactNode } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
*
* `streaming` renders a blinking caret at the tail of the last block so it
* appears to hug the final character instead of wrapping onto a new line
* after a block element (paragraph/list/code/…).
*/
export function Markdown({
content,
highlightTerms,
streaming,
}: {
content: string;
highlightTerms?: string[];
streaming?: boolean;
}) {
const blocks = useMemo(() => parseBlocks(content), [content]);
const caret = streaming ? <StreamingCaret /> : null;
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block
key={i}
block={block}
highlightTerms={highlightTerms}
caret={caret && i === blocks.length - 1 ? caret : null}
/>
))}
{blocks.length === 0 && caret}
</div>
);
}
function StreamingCaret() {
return (
<span
aria-hidden
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
/>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({
block,
highlightTerms,
caret,
}: {
block: BlockNode;
highlightTerms?: string[];
caret?: ReactNode;
}) {
switch (block.type) {
case "code":
return (
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>
{block.content}
{caret}
</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return (
<Tag className={sizes[Tag]}>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</Tag>
);
}
case "hr":
return (
<>
<hr className="border-border" />
{caret}
</>
);
case "list": {
const Tag = block.ordered ? "ol" : "ul";
const last = block.items.length - 1;
return (
<Tag
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
>
{block.items.map((item, i) => (
<li key={i}>
<InlineContent text={item} highlightTerms={highlightTerms} />
{i === last ? caret : null}
</li>
))}
</Tag>
);
}
case "paragraph":
return (
<p>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</p>
);
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern =
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({
text,
highlightTerms,
}: {
text: string;
highlightTerms?: string[];
}) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return (
<HighlightedText
key={i}
text={node.content}
terms={highlightTerms}
/>
);
case "code":
return (
<code
key={i}
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
>
{node.content}
</code>
);
case "bold":
return (
<strong key={i} className="font-semibold">
<HighlightedText text={node.content} terms={highlightTerms} />
</strong>
);
case "italic":
return (
<em key={i}>
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
case "link": {
// Security: only render http(s)/mailto links. Other schemes
// (javascript:, data:, vbscript:) are dropped to plain text so a
// crafted link in agent/message content can't execute on click.
const href = node.href.trim();
if (!/^(https?:|mailto:)/i.test(href)) {
return (
<HighlightedText
key={i}
text={node.text}
terms={highlightTerms}
/>
);
}
return (
<a
key={i}
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
}
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning px-0.5">
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
currentModel: string;
/** Bumped after config saves to trigger re-fetch */
refreshKey?: number;
}
export function ModelInfoCard({
currentModel,
refreshKey = 0,
}: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
if (!currentModel) return;
// Re-fetch when model changes OR when refreshKey bumps (after save)
const fetchKey = `${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
setLoading(true);
api
.getModelInfo()
.then(setInfo)
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [currentModel, refreshKey]);
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Spinner className="text-xs" />
Loading model info
</div>
);
}
if (!info || !info.model || info.effective_context_length <= 0) return null;
const caps = info.capabilities;
const hasCaps = caps && Object.keys(caps).length > 0;
return (
<div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
<span className="font-medium">Context Window</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500 text-xs">
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-text-tertiary text-xs">
auto-detected
</span>
)}
</div>
</div>
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lightbulb className="h-3.5 w-3.5" />
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
</span>
</div>
)}
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{caps.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{caps.model_family && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-xs font-medium text-text-secondary">
{caps.model_family}
</span>
)}
</div>
)}
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
/**
* Two-stage model picker modal.
*
* Mirrors ui-tui/src/components/modelPicker.tsx:
* Stage 1: pick provider (authenticated providers only)
* Stage 2: pick model within that provider
*
* Two invocation modes:
*
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
* loads options via `model.options` JSON-RPC and emits the result as a
* slash command string (`/model <model> --provider <slug> [--global]`)
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
*
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
* `onApply`. The picker fetches options via the REST endpoint and calls
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
* command. This lets the Models page reuse the same UI without
* requiring an open chat PTY.
*/
interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
warning?: string;
}
interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
interface Props {
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
gw?: GatewayClient;
sessionId?: string;
onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(): Promise<ModelOptionsResponse>;
onApply?(args: {
provider: string;
model: string;
persistGlobal: boolean;
}): Promise<void> | void;
onClose(): void;
title?: string;
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
alwaysGlobal?: boolean;
}
export function ModelPickerDialog(props: Props) {
const {
gw,
sessionId,
onSubmit,
loader,
onApply,
onClose,
title = "Switch Model",
alwaysGlobal = false,
} = props;
const standalone = !!loader && !!onApply;
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
const [currentModel, setCurrentModel] = useState("");
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlug, setSelectedSlug] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
const [applying, setApplying] = useState(false);
const closedRef = useRef(false);
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
const promise = standalone
? (loader as () => Promise<ModelOptionsResponse>)()
: (gw as GatewayClient).request<ModelOptionsResponse>(
"model.options",
sessionId ? { session_id: sessionId } : {},
);
promise
.then((r) => {
if (closedRef.current) return;
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug(
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
);
setSelectedModel("");
setLoading(false);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
closedRef.current = true;
};
// Deliberately omit props from deps — stable for the dialog's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const selectedProvider = useMemo(
() => providers.find((p) => p.slug === selectedSlug) ?? null,
[providers, selectedSlug],
);
const models = useMemo(
() => selectedProvider?.models ?? [],
[selectedProvider],
);
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo(
() =>
!needle
? providers
: providers.filter(
(p) =>
p.name.toLowerCase().includes(needle) ||
p.slug.toLowerCase().includes(needle) ||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
),
[providers, needle],
);
const filteredModels = useMemo(
() =>
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
[models, needle],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const confirm = async () => {
if (!canConfirm || !selectedProvider) return;
if (standalone && onApply) {
setApplying(true);
try {
await onApply({
provider: selectedProvider.slug,
model: selectedModel,
persistGlobal,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (onSubmit) {
const global = persistGlobal ? " --global" : "";
onSubmit(
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
);
onClose();
}
};
// Portal to document.body: the main dashboard column in App.tsx is
// `relative z-2`, which creates a stacking context that traps fixed
// descendants below the app sidebar (z-50). Without the portal this
// modal's z-[100] is scoped to z-2 and the sidebar covers its left
// edge — visible especially in the Large theme variants where the
// larger root font widens the dialog into the sidebar's column. See
// Toast.tsx for the same pattern.
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="model-picker-title"
>
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={onClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="model-picker-title"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
<p className="text-xs text-muted-foreground mt-1 font-mono">
current: {currentModel || "(unknown)"}
{currentProviderSlug && ` · ${currentProviderSlug}`}
</p>
</header>
<div className="px-5 pt-3 pb-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="Filter providers and models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
<ProviderColumn
loading={loading}
error={error}
providers={filteredProviders}
total={providers.length}
selectedSlug={selectedSlug}
query={needle}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
// Confirm on next tick so state settles.
window.setTimeout(confirm, 0);
}}
/>
</div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
{alwaysGlobal ? (
<span className="text-xs text-muted-foreground">
Saves to config.yaml applies to new sessions.
</span>
) : (
<div className="flex items-center gap-2">
<Checkbox
checked={persistGlobal}
id="model-picker-persist-global"
onCheckedChange={(checked) =>
setPersistGlobal(checked === true)
}
/>
<Label
className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
htmlFor="model-picker-persist-global"
>
Persist globally (otherwise this session only)
</Label>
</div>
)}
<div className="flex items-center gap-2 ml-auto">
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>
<Button onClick={confirm} disabled={!canConfirm}>
{applying ? <Spinner /> : "Switch"}
</Button>
</div>
</footer>
</div>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------ */
/* Provider column */
/* ------------------------------------------------------------------ */
function ProviderColumn({
loading,
error,
providers,
total,
selectedSlug,
query,
onSelect,
}: {
loading: boolean;
error: string | null;
providers: ModelOptionProvider[];
total: number;
selectedSlug: string;
query: string;
onSelect(slug: string): void;
}) {
return (
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Spinner className="text-xs" /> loading
</div>
)}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && (
<div className="p-4 text-xs text-muted-foreground italic">
{query
? "no matches"
: total === 0
? "no authenticated providers"
: "no matches"}
</div>
)}
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<ListItem
key={p.slug}
active={active}
onClick={() => onSelect(p.slug)}
className={`items-start text-xs border-l-2 ${
active ? "border-l-primary" : "border-l-transparent"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-xs text-text-secondary font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</ListItem>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Model column */
/* ------------------------------------------------------------------ */
function ModelColumn({
provider,
models,
allModels,
selectedModel,
currentModel,
currentProviderSlug,
onSelect,
onConfirm,
}: {
provider: ModelOptionProvider | null;
models: string[];
allModels: string[];
selectedModel: string;
currentModel: string;
currentProviderSlug: string;
onSelect(model: string): void;
onConfirm(model: string): void;
}) {
if (!provider) {
return (
<div className="overflow-y-auto">
<div className="p-4 text-xs text-muted-foreground italic">
pick a provider
</div>
</div>
);
}
return (
<div className="overflow-y-auto">
{provider.warning && (
<div className="p-3 text-xs text-destructive border-b border-border">
{provider.warning}
</div>
)}
{models.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground italic">
{allModels.length
? "no models match your filter"
: "no models listed for this provider"}
</div>
) : (
models.map((m) => {
const active = m === selectedModel;
const isCurrent =
m === currentModel && provider.slug === currentProviderSlug;
return (
<ListItem
key={m}
active={active}
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className="px-3 py-1.5 text-xs font-mono"
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">{m}</span>
{isCurrent && <CurrentTag />}
</ListItem>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-display text-xs tracking-wider text-primary shrink-0">
current
</span>
);
}
+374
View File
@@ -0,0 +1,374 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, X, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Input } from "@nous-research/ui/ui/components/input";
import { useI18n } from "@/i18n";
import { cn, themedBody } from "@/lib/utils";
interface Props {
provider: OAuthProvider;
onClose: () => void;
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}
type Phase =
| "idle"
| "starting"
| "awaiting_user"
| "submitting"
| "polling"
| "approved"
| "error";
export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);
const { t } = useI18n();
// Initiate flow on mount
useEffect(() => {
isMounted.current = true;
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Failed to start login: ${e}`);
});
return () => {
isMounted.current = false;
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tick the countdown
useEffect(() => {
if (secondsLeft === null) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => {
if (s !== null && s <= 1) {
setPhase("error");
setErrorMsg(t.oauth.sessionExpired);
return 0;
}
return s !== null && s > 0 ? s - 1 : 0;
});
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase, t]);
// Device-code: poll backend every 2s
useEffect(() => {
if (!start || start.flow !== "device_code" || phase !== "polling") return;
const sid = start.session_id;
pollTimer.current = window.setInterval(async () => {
try {
const resp = await api.pollOAuthSession(provider.id, sid);
if (!isMounted.current) return;
if (resp.status === "approved") {
setPhase("approved");
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else if (resp.status !== "pending") {
setPhase("error");
setErrorMsg(resp.error_message || `Login ${resp.status}`);
if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Polling failed: ${e}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
}
}, 2000);
return () => {
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
const handleSubmitPkceCode = async () => {
if (!start || start.flow !== "pkce") return;
if (!pkceCode.trim()) return;
setPhase("submitting");
setErrorMsg(null);
try {
const resp = await api.submitOAuthCode(
provider.id,
start.session_id,
pkceCode.trim(),
);
if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") {
setPhase("approved");
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else {
setPhase("error");
setErrorMsg(resp.message || "Token exchange failed");
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Submit failed: ${e}`);
}
};
const handleClose = async () => {
if (start && phase !== "approved" && phase !== "error") {
try {
await api.cancelOAuthSession(start.session_id);
} catch {
// ignore
}
}
onClose();
};
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose();
};
const fmtTime = (s: number | null) => {
if (s === null) return "";
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={handleBackdrop}
role="dialog"
aria-modal="true"
aria-labelledby="oauth-modal-title"
>
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
<Button
ghost
size="icon"
onClick={handleClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label={t.common.close}
>
<X />
</Button>
<div className="p-6 flex flex-col gap-4">
<div>
<H2
id="oauth-modal-title"
variant="sm"
mondwest
className="tracking-wider uppercase"
>
{t.oauth.connect} {provider.name}
</H2>
{secondsLeft !== null &&
phase !== "approved" &&
phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1">
{t.oauth.sessionExpires.replace(
"{time}",
fmtTime(secondsLeft),
)}
</p>
)}
</div>
{phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.initiatingLogin}
</div>
)}
{start?.flow === "pkce" && phase === "awaiting_user" && (
<>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li>{t.oauth.pkceStep1}</li>
<li>{t.oauth.pkceStep2}</li>
<li>{t.oauth.pkceStep3}</li>
</ol>
<div className="flex flex-col gap-2">
<Input
value={pkceCode}
onChange={(e) => setPkceCode(e.target.value)}
placeholder={t.oauth.pasteCode}
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
autoFocus
/>
<div className="flex items-center gap-2 justify-between">
<a
href={
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
.auth_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenAuth}
</a>
<Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
>
{t.oauth.submitCode}
</Button>
</div>
</div>
</>
)}
{phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.exchangingCode}
</div>
)}
{start?.flow === "device_code" && phase === "polling" && (
<>
<p className="text-sm text-muted-foreground">
{t.oauth.enterCodePrompt}
</p>
<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">
{
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
</code>
<CopyButton
text={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
/>
</div>
<a
href={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).verification_url
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenVerification}
</a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Spinner className="text-xs" />
{t.oauth.waitingAuth}
</div>
</>
)}
{phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" />
{t.oauth.connectedClosing}
</div>
)}
{phase === "error" && (
<>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || t.oauth.loginFailed}
</div>
<div className="flex justify-end gap-2">
<Button outlined onClick={handleClose}>
{t.common.close}
</Button>
<Button
onClick={() => {
if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {});
}
setErrorMsg(null);
setStart(null);
setPkceCode("");
setPhase("starting");
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(
resp.flow === "device_code"
? "polling"
: "awaiting_user",
);
if (resp.flow === "pkce") {
window.open(
resp.auth_url,
"_blank",
"noopener,noreferrer",
);
} else {
window.open(
resp.verification_url,
"_blank",
"noopener,noreferrer",
);
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
}}
>
{t.common.retry}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ShieldCheck,
ShieldOff,
ExternalLink,
RefreshCw,
Terminal,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
interface Props {
onError?: (msg: string) => void;
onSuccess?: (msg: string) => void;
}
function formatExpiresAt(
expiresAt: string | null | undefined,
expiresInTemplate: string,
): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
if (Number.isNaN(dt.getTime())) return null;
const now = Date.now();
const diff = dt.getTime() - now;
if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000);
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
const hours = Math.floor(mins / 60);
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
const days = Math.floor(hours / 24);
return expiresInTemplate.replace("{time}", `${days}d`);
} catch {
return null;
}
}
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const [disconnectTarget, setDisconnectTarget] =
useState<OAuthProvider | null>(null);
const { t } = useI18n();
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const refresh = useCallback(() => {
setLoading(true);
api
.getOAuthProviders()
.then((resp) => setProviders(resp.providers))
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
setBusyId(provider.id);
setDisconnectTarget(null);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
refresh();
} catch (e) {
onError?.(`${t.oauth.disconnect} failed: ${e}`);
} finally {
setBusyId(null);
}
};
const connectedCount =
providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.oauth.providerLogins}
</CardTitle>
</div>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={refresh}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>
<CardDescription>
{t.oauth.description
.replace("{connected}", String(connectedCount))
.replace("{total}", String(totalCount))}
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{t.oauth.noProviders}
</p>
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(
p.status.expires_at,
t.oauth.expiresIn,
);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
) : (
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge
tone="outline"
className="text-xs tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge tone="success" className="text-xs">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge tone="destructive" className="text-xs">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-xs">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<span className="truncate text-xs font-mono-ui text-text-secondary">
<span className="text-text-tertiary">token </span>
{p.status.token_preview}
{p.status.source_label && (
<span className="text-text-tertiary">
{" "}
· {p.status.source_label}
</span>
)}
</span>
)}
{!p.status.logged_in && (
<>
<span className="text-xs text-text-secondary">
{t.oauth.notConnected.split("{command}")[0].trimEnd()}
{t.oauth.notConnected.split("{command}")[1] ?? ""}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<code className="font-courier truncate text-xs opacity-60">
{p.cli_command}
</code>
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
</div>
</>
)}
{p.status.error && (
<span className="text-xs text-destructive">
{p.status.error}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
href={p.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button ghost size="icon">
<ExternalLink />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
className="uppercase"
onClick={() => setLoginFor(p)}
>
{t.oauth.login}
</Button>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
outlined
className="uppercase"
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : undefined}
>
{t.oauth.disconnect}
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-xs text-text-tertiary italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally}
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
{loginFor && (
<OAuthLoginModal
provider={loginFor}
onClose={() => {
setLoginFor(null);
refresh();
}}
onSuccess={(msg) => onSuccess?.(msg)}
onError={(msg) => onError?.(msg)}
/>
)}
<ConfirmDialog
open={disconnectTarget !== null}
onCancel={() => setDisconnectTarget(null)}
onConfirm={() => {
if (disconnectTarget) void handleDisconnect(disconnectTarget);
}}
title={`${t.oauth.disconnect} ${disconnectTarget?.name ?? ""}?`}
description={`This will remove the stored OAuth tokens for ${disconnectTarget?.name ?? "this provider"}. You will need to re-authenticate to use it again.`}
destructive
confirmLabel={t.oauth.disconnect}
/>
</Card>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
import type { PlatformStatus } from "@/lib/api";
import { isoTimeAgo } from "@/lib/utils";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { useI18n } from "@/i18n";
export function PlatformsCard({ platforms }: PlatformsCardProps) {
const { t } = useI18n();
const platformStateBadge: Record<
string,
{ tone: "success" | "warning" | "destructive"; label: string }
> = {
connected: { tone: "success", label: t.status.connected },
disconnected: { tone: "warning", label: t.status.disconnected },
fatal: { tone: "destructive", label: t.status.error },
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = platformStateBadge[info.state] ?? {
tone: "outline" as const,
label: info.state,
};
const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return (
<div
key={name}
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 items-center gap-3 min-w-0 w-full">
<IconComponent
className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="font-mondwest normal-case text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="font-mondwest normal-case text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge
tone={display.tone}
className="shrink-0 self-start sm:self-center"
>
{display.tone === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}
+42
View File
@@ -0,0 +1,42 @@
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import type { StatusResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function SidebarFooter({ status }: SidebarFooterProps) {
const { t } = useI18n();
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2.5",
"border-t border-current/10",
)}
>
<Typography
className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
<a
href="https://nousresearch.com"
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-mondwest text-display text-xs tracking-[0.12em] text-midground",
"transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</a>
</div>
);
}
interface SidebarFooterProps {
status: StatusResponse | null;
}
+72
View File
@@ -0,0 +1,72 @@
import { Link } from "react-router-dom";
import type { StatusResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
const { t } = useI18n();
if (status === null) {
return (
<div className="px-5 py-1.5" aria-hidden>
<div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
</div>
);
}
const gw = gatewayLine(status, t);
const { activeSessionsLabel, gatewayStatusLabel } = t.app;
return (
<Link
to="/sessions"
title={t.app.statusOverview}
className={cn(
"block text-left",
"px-5 pb-2 pt-0.5",
"text-text-secondary",
"transition-colors hover:text-midground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
"focus-visible:ring-inset",
)}
>
<div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
<p className="break-words">
<span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
</p>
<p className="break-words">
<span className="text-text-tertiary">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-text-secondary">
{status.active_sessions}
</span>
</p>
</div>
</Link>
);
}
export function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
const g = t.app.gatewayStrip;
const byState: Record<string, { label: string; tone: string }> = {
running: { label: g.running, tone: "text-success" },
starting: { label: g.starting, tone: "text-warning" },
startup_failed: { label: g.failed, tone: "text-destructive" },
stopped: { label: g.stopped, tone: "text-muted-foreground" },
};
if (status.gateway_state && byState[status.gateway_state]) {
return byState[status.gateway_state];
}
return status.gateway_running
? { label: g.running, tone: "text-success" }
: { label: g.off, tone: "text-muted-foreground" };
}
interface SidebarStatusStripProps {
status: StatusResponse | null;
}
+171
View File
@@ -0,0 +1,171 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<ListItem
key={`${it.text}-${i}`}
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta}
</span>
)}
</ListItem>
);
})}
</div>
);
},
);
+243
View File
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Palette, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
import { BUILTIN_THEMES, useTheme } from "@/themes";
import type { DashboardTheme, ThemeListEntry } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Compact theme picker mounted next to the language switcher in the header.
* Each dropdown row shows a 3-stop swatch (background / midground / warm
* glow) so users can preview the palette before committing. User-defined
* themes from `~/.hermes/dashboard-themes/*.yaml` use their API-provided
* definitions so they show real palette swatches just like built-ins.
*
* When placed at the bottom of a container (e.g. the sidebar rail), pass
* `dropUp` so the menu opens above the trigger instead of clipping below
* the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` so the picker is not clipped by
* the sidebar (same idea as a responsive Drawer).
*/
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
const { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
useEffect(() => {
if (!open || useMobileSheet) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (wrapperRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
close();
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open, close, useMobileSheet]);
const current = availableThemes.find((th) => th.name === themeName);
const label = current?.label ?? themeName;
const sheetTitle = t.theme?.title ?? "Theme";
return (
<div ref={wrapperRef} className="relative">
<Button
ghost
size={collapsed ? "icon" : undefined}
onClick={() => setOpen((o) => !o)}
className={cn(
collapsed
? "text-text-secondary hover:text-foreground hover:bg-transparent"
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
)}
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
{!collapsed && (
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
)}
</span>
</Button>
{useMobileSheet && (
<BottomSheet
backdropDismissLabel={t.common.close}
onClose={close}
open={open}
title={sheetTitle}
>
<div aria-label={sheetTitle} role="listbox">
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
</BottomSheet>
)}
{open && !useMobileSheet && (() => {
const rect = wrapperRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
function ThemeSwitcherOptions({
availableThemes,
close,
setTheme,
themeName,
}: ThemeSwitcherOptionsProps) {
return (
<>
{availableThemes.map((th) => {
const isActive = th.name === themeName;
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
return (
<ListItem
active={isActive}
aria-selected={isActive}
className="gap-3"
key={th.name}
onClick={() => {
setTheme(th.name);
close();
}}
role="option"
>
{paletteTheme ? (
<ThemeSwatch theme={paletteTheme} />
) : (
<PlaceholderSwatch />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-display text-xs tracking-wide"
>
{th.label}
</Typography>
{th.description && (
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
{th.description}
</Typography>
)}
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
);
})}
</>
);
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
</div>
);
}
function PlaceholderSwatch() {
return (
<div
aria-hidden
className="h-4 w-9 shrink-0 border border-dashed border-current/20"
/>
);
}
interface ThemeSwitcherOptionsProps {
availableThemes: ThemeListEntry[];
close: () => void;
setTheme: (name: string) => void;
themeName: string;
}
interface ThemeSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+228
View File
@@ -0,0 +1,228 @@
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
/**
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
*
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
* in between) as a single collapsible item in the transcript:
*
* ▸ ● read_file(path=/foo) 2.3s
*
* Click the header to reveal a preformatted body with context (args), the
* streaming preview (while running), and the final summary or error. Error
* rows auto-expand so failures aren't silently collapsed.
*/
export interface ToolEntry {
kind: "tool";
id: string;
tool_id: string;
name: string;
context?: string;
preview?: string;
summary?: string;
error?: string;
inline_diff?: string;
status: "running" | "done" | "error";
startedAt: number;
completedAt?: number;
}
const STATUS_TONE: Record<ToolEntry["status"], string> = {
running: "border-primary/40 bg-primary/[0.04]",
done: "border-border bg-muted/20",
error: "border-destructive/50 bg-destructive/[0.04]",
};
const BULLET_TONE: Record<ToolEntry["status"], string> = {
running: "text-primary",
done: "text-primary/80",
error: "text-destructive",
};
const TICK_MS = 500;
export function ToolCall({ tool }: { tool: ToolEntry }) {
// `open` is derived: errors default-expanded, everything else collapsed.
// `null` means "follow the default"; any explicit bool is the user's override.
// This lets a running tool flip to expanded automatically when it errors,
// without mirroring state in an effect.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const open = userOverride ?? tool.status === "error";
// Tick `now` while the tool is running so the elapsed label updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (tool.status !== "running") return;
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
return () => window.clearInterval(id);
}, [tool.status]);
// Historical tools (hydrated from session.resume) signal missing timestamps
// with `startedAt === 0`; we hide the elapsed badge for those rather than
// rendering a misleading "0ms".
const hasTimestamps = tool.startedAt > 0;
const elapsed = hasTimestamps
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
: null;
const hasBody = !!(
tool.context ||
tool.preview ||
tool.summary ||
tool.error ||
tool.inline_diff
);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
>
<ListItem
onClick={() => setUserOverride(!open)}
disabled={!hasBody}
aria-expanded={open}
className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default"
>
{hasBody ? (
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<span className="w-3 shrink-0" />
)}
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
<span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-text-secondary truncate min-w-0 flex-1">
{tool.context ?? ""}
</span>
{tool.status === "running" && (
<span
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
title="running"
/>
)}
{tool.status === "error" && (
<AlertCircle
className="h-3 w-3 shrink-0 text-destructive"
aria-label="error"
/>
)}
{tool.status === "done" && (
<Check
className="h-3 w-3 shrink-0 text-primary/80"
aria-label="done"
/>
)}
{elapsed && (
<span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
{elapsed}
</span>
)}
</ListItem>
{open && hasBody && (
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
{tool.context && <Section label="context">{tool.context}</Section>}
{tool.preview && tool.status === "running" && (
<Section label="streaming">
{tool.preview}
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
</Section>
)}
{tool.inline_diff && (
<Section label="diff">
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
{colorizeDiff(tool.inline_diff)}
</pre>
</Section>
)}
{tool.summary && (
<Section label="result">
<span className="text-foreground/90 whitespace-pre-wrap">
{tool.summary}
</span>
</Section>
)}
{tool.error && (
<Section label="error" tone="error">
<span className="text-destructive whitespace-pre-wrap">
{tool.error}
</span>
</Section>
)}
</div>
)}
</div>
);
}
function Section({
label,
children,
tone,
}: {
label: string;
children: React.ReactNode;
tone?: "error";
}) {
return (
<div className="flex gap-3">
<span
className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
tone === "error" ? "text-destructive" : "text-text-tertiary"
}`}
>
{label}
</span>
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
</div>
);
}
function fmtElapsed(ms: number): string {
const sec = Math.max(0, ms) / 1000;
if (sec < 1) return `${Math.round(ms)}ms`;
if (sec < 10) return `${sec.toFixed(1)}s`;
if (sec < 60) return `${Math.round(sec)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return s ? `${m}m ${s}s` : `${m}m`;
}
/** Colorize unified-diff lines for the inline diff section. */
function colorizeDiff(diff: string): React.ReactNode {
return diff.split("\n").map((line, i) => (
<div key={i} className={diffLineClass(line)}>
{line || "\u00A0"}
</div>
));
}
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
return "text-text-secondary";
}
+139
View File
@@ -0,0 +1,139 @@
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { PageHeaderContext } from "./page-header-context";
import { resolvePageTitle } from "@/lib/resolve-page-title";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function PageHeaderProvider({
children,
pluginTabs,
}: {
children: ReactNode;
pluginTabs: { path: string; label: string }[];
}) {
const { pathname } = useLocation();
const { t } = useI18n();
const [titleOverride, setTitleOverride] = useState<string | null>(null);
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
const [end, setEnd] = useState<ReactNode>(null);
// Clear any per-page title / toolbar slots when the path changes. Child routes
// re-fill these on mount via usePageHeader.
/* eslint-disable react-hooks/set-state-in-effect */
useLayoutEffect(() => {
setTitleOverride(null);
setAfterTitle(null);
setEnd(null);
}, [pathname]);
/* eslint-enable react-hooks/set-state-in-effect */
const defaultTitle = useMemo(
() => resolvePageTitle(pathname, t, pluginTabs),
[pathname, t, pluginTabs],
);
const displayTitle = titleOverride ?? defaultTitle;
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
const isEnvRoute =
pathname === "/env" || pathname.startsWith("/env/");
const value = useMemo(
() => ({
setAfterTitle,
setEnd,
setTitle: setTitleOverride,
}),
[],
);
return (
<PageHeaderContext.Provider value={value}>
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
<header
className={cn(
"z-1 w-full shrink-0",
"box-border border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm",
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
)}
role="banner"
>
<div
className={cn(
"flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
isChatRoute
? "flex-row items-center"
: "flex-col justify-center sm:flex-row sm:items-center",
)}
>
<div
className={cn(
"flex min-w-0 flex-1 gap-2 sm:gap-3",
afterTitle && isEnvRoute
? "flex-col items-start sm:flex-row sm:items-center"
: afterTitle
? "flex-row flex-wrap items-center"
: "flex-row items-center",
)}
>
<h1
className={cn(
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
afterTitle && isEnvRoute
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
: afterTitle
? "shrink truncate"
: "truncate",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{displayTitle}
</h1>
{afterTitle ? (
<div
className={cn(
"min-w-0 scrollbar-none",
isEnvRoute
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
: "shrink-0 overflow-visible",
)}
>
{afterTitle}
</div>
) : null}
</div>
{end ? (
<div
className={cn(
"flex min-w-0 sm:max-w-md sm:flex-1",
isChatRoute
? "w-auto shrink-0 justify-end"
: "w-full justify-start sm:justify-end",
)}
>
{end}
</div>
) : null}
</div>
</header>
<main
className={cn(
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
// Bottom inset for scrolled pages lives on the route outlet wrapper in
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
isChatRoute
? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
)}
>
{children}
</main>
</div>
</PageHeaderContext.Provider>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ActionStatusResponse } from "@/lib/api";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useI18n } from "@/i18n";
import {
SystemActionsContext,
type SystemAction,
} from "./system-actions-context";
const ACTION_NAMES: Record<SystemAction, string> = {
restart: "gateway-restart",
update: "hermes-update",
};
export function SystemActionsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const { t } = useI18n();
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!activeAction) return;
const name = ACTION_NAMES[activeAction];
let cancelled = false;
const poll = async () => {
try {
const resp = await api.getActionStatus(name);
if (cancelled) return;
setActionStatus(resp);
if (!resp.running) {
const ok = resp.exit_code === 0;
setToast({
type: ok ? "success" : "error",
message: ok
? t.status.actionFinished
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
});
return;
}
} catch {
// transient fetch error; keep polling
}
if (!cancelled) setTimeout(poll, 1500);
};
poll();
return () => {
cancelled = true;
};
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
const runAction = useCallback(
async (action: SystemAction) => {
setPendingAction(action);
setActionStatus(null);
try {
if (action === "restart") {
await api.restartGateway();
} else {
await api.updateHermes();
}
setActiveAction(action);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setToast({
type: "error",
message: `${t.status.actionFailed}: ${detail}`,
});
} finally {
setPendingAction(null);
}
},
[t.status.actionFailed],
);
const dismissLog = useCallback(() => {
setActiveAction(null);
setActionStatus(null);
}, []);
const isRunning = activeAction !== null && actionStatus?.running !== false;
const isBusy = pendingAction !== null || isRunning;
return (
<SystemActionsContext.Provider
value={{
actionStatus,
activeAction,
dismissLog,
isBusy,
isRunning,
pendingAction,
runAction,
}}
>
{children}
<Toast toast={toast} />
</SystemActionsContext.Provider>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
+12
View File
@@ -0,0 +1,12 @@
import { createContext } from "react";
import type { ReactNode } from "react";
export interface PageHeaderContextValue {
setAfterTitle: (node: ReactNode) => void;
setEnd: (node: ReactNode) => void;
setTitle: (title: string | null) => void;
}
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
null,
);
@@ -0,0 +1,18 @@
import { createContext } from "react";
import type { ActionStatusResponse } from "@/lib/api";
export const SystemActionsContext = createContext<SystemActionsState | null>(
null,
);
export type SystemAction = "restart" | "update";
export interface SystemActionsState {
actionStatus: ActionStatusResponse | null;
activeAction: SystemAction | null;
dismissLog: () => void;
isBusy: boolean;
isRunning: boolean;
pendingAction: SystemAction | null;
runAction: (action: SystemAction) => Promise<void>;
}
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
if (!ctx) {
throw new Error("usePageHeader must be used within a PageHeaderProvider");
}
return ctx;
}
+15
View File
@@ -0,0 +1,15 @@
import { useContext } from "react";
import {
SystemActionsContext,
type SystemActionsState,
} from "./system-actions-context";
export function useSystemActions(): SystemActionsState {
const ctx = useContext(SystemActionsContext);
if (!ctx) {
throw new Error(
"useSystemActions must be used within a SystemActionsProvider",
);
}
return ctx;
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef } from "react";
/**
* Hook that adds standard modal behaviors when `open` is true:
* - Escape key calls `onClose`
* - Body scroll is locked
* - Focus is restored to the previously focused element on close
*
* Returns a ref to attach to the modal container (for optional future focus trapping).
*/
export function useModalBehavior({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prevActive = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
prevActive?.focus?.();
};
}, [open, onClose]);
return containerRef;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
const POLL_MS = 10_000;
/**
* Light-weight status poll for the app shell (sidebar). The Status page uses
* its own faster interval; we keep this slower to avoid duplicate load.
*/
export function useSidebarStatus() {
const [status, setStatus] = useState<StatusResponse | null>(null);
useEffect(() => {
const load = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
};
load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, []);
return status;
}
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const af: Translations = {
common: {
save: "Stoor",
saving: "Besig om te stoor...",
cancel: "Kanselleer",
close: "Maak toe",
confirm: "Bevestig",
delete: "Skrap",
refresh: "Herlaai",
retry: "Probeer weer",
search: "Soek...",
loading: "Besig om te laai...",
create: "Skep",
creating: "Besig om te skep...",
set: "Stel",
replace: "Vervang",
clear: "Vee uit",
live: "Lewendig",
off: "Af",
enabled: "geaktiveer",
disabled: "gedeaktiveer",
active: "aktief",
inactive: "onaktief",
unknown: "onbekend",
untitled: "Sonder titel",
none: "Geen",
form: "Vorm",
noResults: "Geen resultate",
of: "van",
page: "Bladsy",
msgs: "boodskappe",
tools: "gereedskap",
match: "passing",
other: "Ander",
configured: "gekonfigureer",
removed: "verwyder",
failedToToggle: "Kon nie wissel nie",
failedToRemove: "Kon nie verwyder nie",
failedToReveal: "Kon nie openbaar nie",
collapse: "Vou in",
expand: "Vou uit",
general: "Algemeen",
messaging: "Boodskappe",
pluginLoadFailed:
"Kon nie hierdie inprop se skrip laai nie. Kontroleer die Netwerk-oortjie (dashboard-plugins/…) en die bediener se inprop-pad.",
pluginNotRegistered:
"Die inprop se skrip het nie register() geroep nie, of die skrip het 'n fout gegee. Maak die blaaier-konsole oop vir besonderhede.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Maak navigasie toe",
closeModelTools: "Maak model en gereedskap toe",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Aktiewe Sessies:",
gatewayStatusLabel: "Gateway-status:",
gatewayStrip: {
failed: "Begin het misluk",
off: "Af",
running: "Loop",
starting: "Begin",
stopped: "Gestop",
},
nav: {
analytics: "Analise",
chat: "Klets",
config: "Konfigurasie",
cron: "Cron",
documentation: "Dokumentasie",
keys: "Sleutels",
logs: "Logs",
models: "Modelle",
profiles: "profiele : multi-agente",
plugins: "Inproppe",
sessions: "Sessies",
skills: "Vaardighede",
},
modelToolsSheetSubtitle: "& gereedskap",
modelToolsSheetTitle: "Model",
navigation: "Navigasie",
openDocumentation: "Maak dokumentasie in 'n nuwe oortjie oop",
openNavigation: "Maak navigasie oop",
pluginNavSection: "Inproppe",
sessionsActiveCount: "{count} aktief",
statusOverview: "Statusoorsig",
system: "Stelsel",
webUi: "Web UI",
},
status: {
actionFailed: "Aksie het misluk",
actionFinished: "Voltooi",
actions: "Aksies",
agent: "Agent",
activeSessions: "Aktiewe Sessies",
connected: "Gekoppel",
connectedPlatforms: "Gekoppelde Platforms",
disconnected: "Ontkoppel",
error: "Fout",
failed: "Misluk",
gateway: "Gateway",
gatewayFailedToStart: "Gateway kon nie begin nie",
lastUpdate: "Laaste opdatering",
noneRunning: "Geen",
notRunning: "Loop nie",
pid: "PID",
platformDisconnected: "ontkoppel",
platformError: "fout",
recentSessions: "Onlangse Sessies",
restartGateway: "Herbegin Gateway",
restartingGateway: "Besig om gateway te herbegin…",
running: "Loop",
runningRemote: "Loop (afgeleë)",
startFailed: "Begin het misluk",
starting: "Begin",
startedInBackground: "Begin in agtergrond — kyk logs vir vordering",
stopped: "Gestop",
updateHermes: "Werk Hermes op",
updatingHermes: "Besig om Hermes op te werk…",
waitingForOutput: "Wag vir uitset…",
},
sessions: {
title: "Sessies",
history: "Geskiedenis",
overview: "Oorsig",
searchPlaceholder: "Soek boodskap-inhoud...",
noSessions: "Nog geen sessies nie",
noMatch: "Geen sessies stem ooreen met jou soektog nie",
startConversation: "Begin 'n gesprek om dit hier te sien",
noMessages: "Geen boodskappe",
untitledSession: "Sessie sonder titel",
deleteSession: "Skrap sessie",
confirmDeleteTitle: "Skrap sessie?",
confirmDeleteMessage:
"Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
sessionDeleted: "Sessie geskrap",
failedToDelete: "Kon nie sessie skrap nie",
resumeInChat: "Hervat in Klets",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
roles: {
user: "Gebruiker",
assistant: "Assistent",
system: "Stelsel",
tool: "Gereedskap",
},
},
analytics: {
period: "Tydperk:",
totalTokens: "Totale Tokens",
totalSessions: "Totale Sessies",
apiCalls: "API-oproepe",
dailyTokenUsage: "Daaglikse Tokengebruik",
dailyBreakdown: "Daaglikse Uiteensetting",
perModelBreakdown: "Per-Model Uiteensetting",
topSkills: "Top Vaardighede",
skill: "Vaardigheid",
loads: "Agent Gelaai",
edits: "Agent Bestuur",
lastUsed: "Laas Gebruik",
input: "Inset",
output: "Uitset",
total: "Totaal",
noUsageData: "Geen gebruiksdata vir hierdie tydperk nie",
startSession: "Begin 'n sessie om analise hier te sien",
date: "Datum",
model: "Model",
tokens: "Tokens",
perDayAvg: "/dag gem.",
acrossModels: "oor {count} modelle",
inOut: "{input} in / {output} uit",
},
models: {
modelsUsed: "Modelle Gebruik",
estimatedCost: "Geskatte Koste",
tokens: "tokens",
sessions: "sessies",
avgPerSession: "gem./sessie",
apiCalls: "API-oproepe",
toolCalls: "gereedskap-oproepe",
noModelsData: "Geen modelgebruiksdata vir hierdie tydperk nie",
startSession: "Begin 'n sessie om modeldata hier te sien",
},
logs: {
title: "Logs",
autoRefresh: "Outo-herlaai",
file: "Lêer",
level: "Vlak",
component: "Komponent",
lines: "Reëls",
noLogLines: "Geen logreëls gevind nie",
},
cron: {
confirmDeleteMessage:
"Dit verwyder die taak van die skedule. Dit kan nie ongedaan gemaak word nie.",
confirmDeleteTitle: "Skrap geskeduleerde taak?",
newJob: "Nuwe Cron-taak",
nameOptional: "Naam (opsioneel)",
namePlaceholder: "bv. Daaglikse opsomming",
prompt: "Opdrag",
promptPlaceholder: "Wat moet die agent met elke uitvoering doen?",
schedule: "Skedule (cron-uitdrukking)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Lewer aan",
scheduledJobs: "Geskeduleerde Take",
noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.",
last: "Laaste",
next: "Volgende",
pause: "Pouse",
resume: "Hervat",
triggerNow: "Voer nou uit",
delivery: {
local: "Plaaslik",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Nuwe Profiel",
name: "Naam",
namePlaceholder: "bv. coder, writer, ens.",
nameRequired: "Naam word vereis",
nameRule:
"Slegs kleinletters, syfers, _ en -; moet met 'n letter of syfer begin; tot 64 karakters.",
invalidName: "Ongeldige profielnaam",
cloneFromDefault: "Kloon konfigurasie vanaf verstekprofiel",
allProfiles: "Profiele",
noProfiles: "Geen profiele gevind nie.",
defaultBadge: "verstek",
hasEnv: "env",
model: "Model",
skills: "Vaardighede",
rename: "Hernoem",
editSoul: "Wysig SOUL.md",
soulSection: "SOUL.md (persoonlikheid / stelselopdrag)",
soulPlaceholder: "# Hoe hierdie agent moet optree…",
saveSoul: "Stoor SOUL",
soulSaved: "SOUL.md gestoor",
openInTerminal: "Kopieer CLI-opdrag",
commandCopied: "Na knipbord gekopieer",
copyFailed: "Kon nie kopieer nie",
confirmDeleteTitle: "Skrap profiel?",
confirmDeleteMessage:
"Dit skrap profiel '{name}' permanent — konfigurasie, sleutels, geheue, sessies, vaardighede, cron-take. Kan nie ongedaan gemaak word nie.",
created: "Geskep",
deleted: "Geskrap",
renamed: "Hernoem",
},
pluginsPage: {
contextEngineLabel: "Konteks-enjin",
dashboardSlots: "Dashboard-gleuwe",
disableRuntime: "Deaktiveer",
enableAfterInstall: "Aktiveer ná installasie",
enableRuntime: "Aktiveer",
forceReinstall: "Forseer herinstallasie (skrap eers bestaande gids)",
headline:
"Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).",
identifierLabel: "Git-URL of owner/repo",
inactive: "onaktief",
installBtn: "Installeer",
installHeading: "Installeer vanaf GitHub / Git-URL",
installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.",
memoryProviderLabel: "Geheueverskaffer",
missingEnvWarn: "Stel hierdie in Sleutels voordat die inprop kan loop:",
noDashboardTab: "Geen dashboard-oortjie",
openTab: "Maak oop",
orphanHeading: "Slegs-dashboard-uitbreidings (geen ooreenstemmende agent plugin.yaml nie)",
pluginListHeading: "Geïnstalleerde inproppe",
providerDefaults: "ingebou / verstek",
providersHeading: "Looptyd-verskafferinproppe",
providersHint:
"Skryf memory.provider (leeg = ingebou) en context.engine na config.yaml. Tree volgende sessie in werking.",
refreshDashboard: "Herskandeer dashboard-uitbreidings",
removeConfirm: "Verwyder hierdie inprop uit ~/.hermes/plugins/?",
removeHint: "Slegs gebruiker-geïnstalleerde inproppe onder ~/.hermes/plugins kan verwyder word.",
rescanHeading: "SPA-inprop-register",
rescanHint: "Herskandeer ná die byvoeg van lêers op skyf sodat die dashboard-sybalk nuwe manifeste optel.",
runtimeHeading: "Gateway-looptyd (YAML-inproppe)",
saveProviders: "Stoor verskaffer-instellings",
savedProviders: "Verskaffer-instellings gestoor.",
sourceBadge: "Bron",
authRequired: "Verifikasie vereis",
authRequiredHint: "Voer hierdie opdrag uit om te verifieer:",
updateGit: "Git pull",
versionBadge: "Weergawe",
showInSidebar: "Wys in sybalk",
hideFromSidebar: "Versteek van sybalk",
},
skills: {
title: "Vaardighede",
searchPlaceholder: "Soek vaardighede en gereedskapstelle...",
enabledOf: "{enabled}/{total} geaktiveer",
all: "Alles",
categories: "Kategorieë",
filters: "Filters",
noSkills: "Geen vaardighede gevind nie. Vaardighede word gelaai uit ~/.hermes/skills/",
noSkillsMatch: "Geen vaardighede stem ooreen met jou soektog of filter nie.",
skillCount: "{count} vaardighe{s}id",
resultCount: "{count} resulta{s}at",
noDescription: "Geen beskrywing beskikbaar nie.",
toolsets: "Gereedskapstelle",
toolsetLabel: "{name} gereedskapstel",
noToolsetsMatch: "Geen gereedskapstelle stem ooreen met die soektog nie.",
setupNeeded: "Opstelling nodig",
disabledForCli: "Gedeaktiveer vir CLI",
more: "+{count} meer",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filters",
sections: "Afdelings",
exportConfig: "Voer konfigurasie uit as JSON",
importConfig: "Voer konfigurasie in vanaf JSON",
resetDefaults: "Stel terug na verstek",
resetScopeTooltip: "Stel {scope} terug na verstek",
confirmResetScope: "Stel alle {scope}-instellings terug na hul verstek? Dit werk slegs die vorm op — veranderinge word nie na config.yaml geskryf voordat jy Stoor druk nie.",
resetScopeToast: "{scope} teruggestel na verstek — kontroleer en Stoor om te behou",
rawYaml: "Rou YAML-konfigurasie",
searchResults: "Soekresultate",
fields: "veld{s}",
noFieldsMatch: 'Geen velde stem ooreen met "{query}" nie',
configSaved: "Konfigurasie gestoor",
yamlConfigSaved: "YAML-konfigurasie gestoor",
failedToSave: "Kon nie stoor nie",
failedToSaveYaml: "Kon nie YAML stoor nie",
failedToLoadRaw: "Kon nie rou konfigurasie laai nie",
configImported: "Konfigurasie ingevoer — kontroleer en stoor",
invalidJson: "Ongeldige JSON-lêer",
categories: {
general: "Algemeen",
agent: "Agent",
terminal: "Terminaal",
display: "Vertoon",
delegation: "Delegasie",
memory: "Geheue",
compression: "Kompressie",
security: "Sekuriteit",
browser: "Blaaier",
voice: "Stem",
tts: "Teks-na-Spraak",
stt: "Spraak-na-Teks",
logging: "Aantekening",
discord: "Discord",
auxiliary: "Hulpmiddels",
},
},
env: {
changesNote: "Veranderinge word onmiddellik na skyf gestoor. Aktiewe sessies tel nuwe sleutels outomaties op.",
confirmClearMessage:
"Die gestoorde waarde vir hierdie veranderlike sal uit jou .env-lêer verwyder word. Dit kan nie vanaf die UI ongedaan gemaak word nie.",
confirmClearTitle: "Vee hierdie sleutel uit?",
description: "Bestuur API-sleutels en geheime gestoor in",
hideAdvanced: "Versteek Gevorderd",
showAdvanced: "Wys Gevorderd",
showLess: "Wys minder",
showMore: "Wys meer",
llmProviders: "LLM-verskaffers",
providersConfigured: "{configured} van {total} verskaffers gekonfigureer",
getKey: "Kry sleutel",
notConfigured: "{count} nie gekonfigureer nie",
notSet: "Nie gestel nie",
keysCount: "{count} sleutel{s}",
enterValue: "Voer waarde in...",
replaceCurrentValue: "Vervang huidige waarde ({preview})",
showValue: "Wys werklike waarde",
hideValue: "Versteek waarde",
},
oauth: {
title: "Verskaffer-aanmeldings (OAuth)",
providerLogins: "Verskaffer-aanmeldings (OAuth)",
description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.",
connected: "Gekoppel",
expired: "Verval",
notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.",
runInTerminal: "in 'n terminaal.",
noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.",
login: "Meld aan",
disconnect: "Ontkoppel",
managedExternally: "Ekstern bestuur",
copied: "Gekopieer ✓",
cli: "Kopieer",
copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
connect: "Koppel",
sessionExpires: "Sessie verval oor {time}",
initiatingLogin: "Aanmeldvloei word begin…",
exchangingCode: "Kode word vir tokens omgeruil…",
connectedClosing: "Gekoppel! Besig om toe te maak…",
loginFailed: "Aanmelding het misluk.",
sessionExpired: "Sessie het verval. Klik Probeer weer om 'n nuwe aanmelding te begin.",
reOpenAuth: "Heropen verifikasiebladsy",
reOpenVerification: "Heropen verifikasiebladsy",
submitCode: "Dien kode in",
pasteCode: "Plak magtigingskode (met #state agtervoegsel is in die haak)",
waitingAuth: "Wag vir jou om in die blaaier te magtig…",
enterCodePrompt: "'n Nuwe oortjie het oopgegaan. Voer hierdie kode in indien gevra:",
pkceStep1: "'n Nuwe oortjie het na claude.ai oopgegaan. Meld aan en klik Magtig.",
pkceStep2: "Kopieer die magtigingskode wat ná magtiging vertoon word.",
pkceStep3: "Plak dit hieronder en dien in.",
flowLabels: {
pkce: "Blaaier-aanmelding (PKCE)",
device_code: "Toestel-kode",
external: "Eksterne CLI",
},
expiresIn: "verval oor {time}",
},
language: {
switchTo: "Verander taal",
},
theme: {
title: "Tema",
switchTheme: "Wissel tema",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Versamelbare Hermes-kentekens wat verdien word uit werklike sessiegeskiedenis. Bekende, onvoltooide prestasies word as Ontdek vertoon; Geheime prestasies bly verborge totdat die eerste ooreenstemmende gedrag verskyn.",
scan_subtitle:
"Hermes-sessiegeskiedenis word geskandeer. Die eerste skandering kan 510 sekondes neem op groot geskiedenisse.",
},
actions: {
rescan: "Herskandeer",
},
stats: {
unlocked: "Ontsluit",
unlocked_hint: "verdiende kentekens",
discovered: "Ontdek",
discovered_hint: "bekend, nog nie verdien nie",
secrets: "Geheime",
secrets_hint: "verborge tot eerste sein",
highest_tier: "Hoogste vlak",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Jongste",
latest_hint_empty: "gebruik Hermes meer",
none_yet: "Nog geen",
},
state: {
unlocked: "Ontsluit",
discovered: "Ontdek",
secret: "Geheim",
},
tier: {
target: "Teiken {tier}",
hidden: "Verborge",
complete: "Voltooi",
objective: "Doelwit",
},
progress: {
hidden: "verborge",
},
scan: {
building_headline: "Prestasieprofiel word gebou…",
building_detail:
"Sessies, gereedskaproepe, modelmetadata en ontsluitstatus word gelees.",
starting_headline: "Prestasieskandering begin…",
progress_detail:
"{scanned} van {total} sessies geskandeer · {pct}%. Kentekens ontsluit soos meer geskiedenis instroom.",
idle_detail:
"Sessies, gereedskaproepe, modelmetadata en ontsluitstatus word gelees. Kentekens verskyn hier soos hulle ontsluit.",
},
guide: {
tiers_header: "Vlakke",
secret_header: "Geheime prestasies",
secret_body:
"Geheime hou hul presiese sneller verborge. Sodra Hermes 'n verwante sein sien, word die kaart Ontdek en wys sy vereiste.",
scan_status_header: "Skanderingstatus",
scan_status_body:
"Hermes skandeer plaaslike geskiedenis een keer, daarna verskyn kaarte outomaties. Niks is vasgevang as dit 'n paar sekondes neem nie.",
what_scanned_header: "Wat geskandeer word",
what_scanned_body:
"Sessies, gereedskaproepe, modelmetadata, foute, prestasies en plaaslike ontsluitstatus.",
},
card: {
share_title: "Deel hierdie prestasie",
share_label: "Deel {name}",
share_text: "Deel",
how_to_reveal: "Hoe om te onthul",
what_counts: "Wat tel",
evidence_label: "Bewys",
evidence_session_fallback: "sessie",
no_evidence: "Nog geen bewys nie",
},
latest: {
header: "Onlangse ontsluitings",
},
empty: {
no_secrets_header: "Geen verborge geheime in hierdie skandering oor nie.",
no_secrets_body:
"Wenk: geheime begin gewoonlik by ongewone mislukkings of magsgebruikerspatrone — poortbotsings, toestemmingsmure, ontbrekende env-veranderlikes, YAML-foute, Docker-botsings, terugrol/kontrolepunt-gebruik, kasterugslae of klein regstellings na baie rooi teks.",
},
filters: {
all_categories: "Alles",
visibility_all: "alles",
visibility_unlocked: "ontsluit",
visibility_discovered: "ontdek",
visibility_secret: "geheim",
},
share: {
dialog_label: "Deel prestasie",
header: "Deel: {name}",
close: "Maak toe",
rendering: "Lewer tans…",
card_alt: "{name} deelkaart",
error_generic: "Iets het verkeerd geloop.",
x_title: "Maak X oop met 'n vooraf-ingevulde plasing",
x_button: "Deel op X",
copy_title: "Kopieer die beeld om in jou plasing te plak",
copy_button: "Kopieer beeld",
copied: "Gekopieer ✓",
download_button: "Laai PNG af",
hint:
"Deel op X maak 'n vooraf-ingevulde plasing in 'n nuwe oortjie oop. Klik eers op Kopieer beeld as jy die 1200×630-kenteken aangeheg wil hê — X laat jou dit direk in die tweet-skrywer plak. Laai PNG af stoor die lêer om enige plek te gebruik.",
clipboard_unsupported:
"Beeldkopiëring na knipbord word nie in hierdie blaaier ondersteun nie — gebruik eerder Aflaai.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban-bord word gelaai…",
loadFailed: "Kon nie Kanban-bord laai nie: ",
loadFailedHint:
"Die agterkant skep kanban.db outomaties met die eerste lees. Indien hierdie probleem aanhou, raadpleeg die paneellogboeke.",
board: "Bord",
newBoard: "+ Nuwe bord",
newBoardTitle: "Nuwe bord",
newBoardDescription:
"Borde laat u toe om onverwante werkstrome te skei — een per projek, repositorium of domein. Werkers op een bord sien nooit 'n ander bord se take nie.",
slug: "Slug",
slugHint: "— kleinletters, koppeltekens, bv. atm10-server",
displayName: "Vertoonnaam",
displayNameHint: "(opsioneel)",
description: "Beskrywing",
descriptionHint: "(opsioneel)",
icon: "Ikoon",
iconHint: "(enkele karakter of emoji)",
switchAfterCreate: "Skakel oor na hierdie bord nadat dit geskep is",
cancel: "Kanselleer",
creating: "Word geskep…",
createBoard: "Skep bord",
search: "Soek",
filterCards: "Filter kaarte…",
tenant: "Huurder",
allTenants: "Alle huurders",
assignee: "Toegewysde",
allProfiles: "Alle profiele",
showArchived: "Wys gearchiveerde",
lanesByProfile: "Bane per profiel",
nudgeDispatcher: "Por versender aan",
refresh: "Verfris",
selected: "gekies",
complete: "Voltooi",
archive: "Argiveer",
apply: "Pas toe",
clear: "Maak skoon",
createTask: "Skep taak in hierdie kolom",
noTasks: "— geen take —",
unassigned: "nie toegewys nie",
untitled: "(sonder titel)",
loadingDetail: "Word gelaai…",
addComment: "Voeg 'n opmerking by… (Enter om in te dien)",
comment: "Opmerking",
status: "Status",
workspace: "Werkruimte",
skills: "Vaardighede",
createdBy: "Geskep deur",
result: "Resultaat",
comments: "Opmerkings",
events: "Gebeurtenisse",
runHistory: "Uitvoergeskiedenis",
workerLog: "Werker-log",
loadingLog: "Log word gelaai…",
noWorkerLog:
"— nog geen werker-log nie (taak is nog nie ontketen nie of die log is geroteer) —",
noDescription: "— geen beskrywing —",
noComments: "— geen opmerkings —",
edit: "redigeer",
save: "Stoor",
dependencies: "Afhanklikhede",
parents: "Ouers:",
children: "Kinders:",
none: "geen",
addParent: "— voeg ouer by —",
addChild: "— voeg kind by —",
removeDependency: "Verwyder afhanklikheid",
block: "Blokkeer",
unblock: "Deblokkeer",
notifyHomeChannels: "Stel tuiskanale in kennis",
diagnostics: "Diagnostiek",
hide: "Versteek",
show: "Wys",
attention: "Aandag",
tasksNeedAttention: "take benodig aandag",
taskNeedsAttention: "1 taak benodig aandag",
diagnostic: "diagnose",
open: "Maak oop",
close: "Sluit (Esc)",
reassignTo: "Hertoeken aan:",
copied: "Gekopieer",
copyCommand: "Kopieer opdrag na knipbord",
reclaim: "Heroor",
reassign: "Hertoeken",
renderingError: "Kanban-oortjie het 'n weergawefout teëgekom",
reloadView: "Herlaai aansig",
wsAuthFailed:
"WebSocket-verifikasie het misluk — herlaai die bladsy om die sessietoken te verfris.",
markDone: "Merk {n} take as klaar?",
markArchived: "Argiveer {n} take?",
warning: "Waarskuwing",
phantomIds: "Spook-ID's:",
active: "aktief",
ended: "geëindig",
noProfile: "(geen profiel)",
showAllAttempts: "Wys alle pogings",
sendingUpdates: "Stuur opdaterings na",
sendNotifications: "Stuur completed / blocked / gave_up kennisgewings na",
archiveBoardConfirm:
"Argiveer bord '{name}'? Dit sal na boards/_archived/ geskuif word sodat u dit later kan herstel. Take op hierdie bord sal nie meer in die UI verskyn nie.",
archiveBoardTitle: "Argiveer hierdie bord",
boardSwitcherHint: "Borde laat u toe om onverwante werkstrome te skei",
taskCreatedWarning: "Taak geskep, maar: ",
moveFailed: "Skuif het misluk: ",
bulkFailed: "Grootmaat: ",
completionBlockedHallucination: "⚠ Voltooiing geblokkeer — spook-kaart-ID's",
suspectedHallucinatedReferences: "⚠ Teks het na spook-kaart-ID's verwys",
pickProfileFirst: "Kies eers 'n profiel.",
unblockedMessage: "{id} gedeblokkeer. Taak is gereed vir die volgende tik.",
unblockFailed: "Deblokkering het misluk: ",
reclaimedMessage: "{id} heroor. Taak is terug op gereed.",
reclaimFailed: "Heroornaming het misluk: ",
reassignedMessage: "{id} hertoegeken aan {profile}.",
reassignFailed: "Hertoekenning het misluk: ",
selectForBulk: "Kies vir grootmaataksies",
clickToEdit: "Klik om te redigeer",
clickToEditAssignee: "Klik om toegewysde te redigeer",
emptyAssignee: "(leeg = ontbind toekenning)",
columnLabels: {
triage: "Triage",
todo: "Te doen",
scheduled: "Geskeduleerd",
ready: "Gereed",
running: "Aan die gang",
blocked: "Geblokkeer",
done: "Klaar",
archived: "Gearchiveer",
},
columnHelp: {
triage: "Rou idees — 'n spesifiseerder sal die spesifikasie uitwerk",
todo: "Wag op afhanklikhede of nie toegewys nie",
scheduled: "Wag op 'n bekende tydvertraging of geskeduleerde opvolg",
ready: "Afhanklikhede is bevredig; wys 'n profiel toe om te versend",
running: "Deur 'n werker geëis — in vlug",
blocked: "Werker het mensinvoer aangevra",
done: "Voltooi",
archived: "Gearchiveer",
},
confirmDone:
"Merk hierdie taak as klaar? Die werker se eis word vrygestel en afhanklike kinders word gereed.",
confirmArchive:
"Argiveer hierdie taak? Dit verdwyn uit die verstek-bordaansig.",
confirmBlocked:
"Merk hierdie taak as geblokkeer? Die werker se eis word vrygestel.",
completionSummary:
"Voltooiingsopsomming vir {label}. Dit word as die taak se result gestoor.",
completionSummaryRequired:
"'n Voltooiingsopsomming is verpligtend voordat 'n taak as klaar gemerk word.",
triagePlaceholder: "Rowwe idee — KI sal dit spesifiseer…",
taskTitlePlaceholder: "Nuwe taaktitel…",
specifier: "spesifiseerder",
assigneePlaceholder: "toegewysde",
priority: "Prioriteit",
skillsPlaceholder:
"vaardighede (opsioneel, kommageskei): translation, github-code-review",
noParent: "— geen ouer —",
workspacePathDir: "werkruimtepad (verpligtend, bv. ~/projects/my-app)",
workspacePathOptional:
"werkruimtepad (opsioneel, afgelei van toegewysde indien leeg)",
logTruncated: "(toon laaste 100 KB — volledige log by ",
logAt: ")",
},
};
+123
View File
@@ -0,0 +1,123 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import type { Locale, Translations } from "./types";
import { en } from "./en";
import { zh } from "./zh";
import { zhHant } from "./zh-hant";
import { ja } from "./ja";
import { de } from "./de";
import { es } from "./es";
import { fr } from "./fr";
import { tr } from "./tr";
import { uk } from "./uk";
import { af } from "./af";
import { ko } from "./ko";
import { it } from "./it";
import { ga } from "./ga";
import { pt } from "./pt";
import { ru } from "./ru";
import { hu } from "./hu";
const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh,
"zh-hant": zhHant,
ja,
de,
es,
fr,
tr,
uk,
af,
ko,
it,
ga,
pt,
ru,
hu,
};
// Display metadata for the language picker — endonym (native name) so users
// recognize their language even if they don't speak the current UI language.
// Exposed as a constant so the LanguageSwitcher and any future settings page
// can share the same list.
//
// We intentionally do NOT pair locales with country flags. Languages are not
// countries (English ≠ GB, Portuguese ≠ PT, Spanish ≠ ES, Chinese variants ≠
// any single jurisdiction). Endonyms are unambiguous and avoid the political
// mismapping that flag pairings inevitably create.
export const LOCALE_META: Record<Locale, { name: string }> = {
en: { name: "English" },
zh: { name: "简体中文" },
"zh-hant": { name: "繁體中文" },
ja: { name: "日本語" },
de: { name: "Deutsch" },
es: { name: "Español" },
fr: { name: "Français" },
tr: { name: "Türkçe" },
uk: { name: "Українська" },
af: { name: "Afrikaans" },
ko: { name: "한국어" },
it: { name: "Italiano" },
ga: { name: "Gaeilge" },
pt: { name: "Português" },
ru: { name: "Русский" },
hu: { name: "Magyar" },
};
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
const STORAGE_KEY = "hermes-locale";
function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as string[]).includes(value);
}
function getInitialLocale(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored)) 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);
}
+701
View File
@@ -0,0 +1,701 @@
import type { Translations } from "./types";
export const de: Translations = {
common: {
save: "Speichern",
saving: "Speichern...",
cancel: "Abbrechen",
close: "Schließen",
confirm: "Bestätigen",
delete: "Löschen",
refresh: "Aktualisieren",
retry: "Erneut versuchen",
search: "Suchen...",
loading: "Lädt...",
create: "Erstellen",
creating: "Erstellen...",
set: "Festlegen",
replace: "Ersetzen",
clear: "Leeren",
live: "Live",
off: "Aus",
enabled: "aktiviert",
disabled: "deaktiviert",
active: "aktiv",
inactive: "inaktiv",
unknown: "unbekannt",
untitled: "Ohne Titel",
none: "Keine",
form: "Formular",
noResults: "Keine Ergebnisse",
of: "von",
page: "Seite",
msgs: "Nachr.",
tools: "Werkzeuge",
match: "Treffer",
other: "Sonstige",
configured: "konfiguriert",
removed: "entfernt",
failedToToggle: "Umschalten fehlgeschlagen",
failedToRemove: "Entfernen fehlgeschlagen",
failedToReveal: "Anzeigen fehlgeschlagen",
collapse: "Einklappen",
expand: "Ausklappen",
general: "Allgemein",
messaging: "Messaging",
pluginLoadFailed:
"Das Skript dieses Plugins konnte nicht geladen werden. Prüfe den Netzwerk-Tab (dashboard-plugins/…) und den Plugin-Pfad des Servers.",
pluginNotRegistered:
"Das Skript des Plugins hat register() nicht aufgerufen oder ist fehlgeschlagen. Öffne die Browser-Konsole für Details.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Navigation schließen",
closeModelTools: "Modell und Werkzeuge schließen",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Aktive Sitzungen:",
gatewayStatusLabel: "Gateway-Status:",
gatewayStrip: {
failed: "Start fehlgeschlagen",
off: "Aus",
running: "Läuft",
starting: "Startet",
stopped: "Gestoppt",
},
nav: {
analytics: "Analyse",
chat: "Chat",
config: "Konfiguration",
cron: "Cron",
documentation: "Dokumentation",
keys: "Schlüssel",
logs: "Protokolle",
models: "Modelle",
profiles: "Profile : Multi-Agenten",
plugins: "Plugins",
sessions: "Sitzungen",
skills: "Skills",
},
modelToolsSheetSubtitle: "& Werkzeuge",
modelToolsSheetTitle: "Modell",
navigation: "Navigation",
openDocumentation: "Dokumentation in neuem Tab öffnen",
openNavigation: "Navigation öffnen",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} aktiv",
statusOverview: "Statusübersicht",
system: "System",
webUi: "Web UI",
},
status: {
actionFailed: "Aktion fehlgeschlagen",
actionFinished: "Abgeschlossen",
actions: "Aktionen",
agent: "Agent",
activeSessions: "Aktive Sitzungen",
connected: "Verbunden",
connectedPlatforms: "Verbundene Plattformen",
disconnected: "Getrennt",
error: "Fehler",
failed: "Fehlgeschlagen",
gateway: "Gateway",
gatewayFailedToStart: "Gateway konnte nicht gestartet werden",
lastUpdate: "Letzte Aktualisierung",
noneRunning: "Keine",
notRunning: "Läuft nicht",
pid: "PID",
platformDisconnected: "getrennt",
platformError: "Fehler",
recentSessions: "Letzte Sitzungen",
restartGateway: "Gateway neu starten",
restartingGateway: "Gateway wird neu gestartet…",
running: "Läuft",
runningRemote: "Läuft (remote)",
startFailed: "Start fehlgeschlagen",
starting: "Startet",
startedInBackground: "Im Hintergrund gestartet — siehe Protokolle für den Fortschritt",
stopped: "Gestoppt",
updateHermes: "Hermes aktualisieren",
updatingHermes: "Hermes wird aktualisiert…",
waitingForOutput: "Warte auf Ausgabe…",
},
sessions: {
title: "Sitzungen",
history: "Verlauf",
overview: "Übersicht",
searchPlaceholder: "Nachrichteninhalt suchen...",
noSessions: "Noch keine Sitzungen",
noMatch: "Keine Sitzungen entsprechen deiner Suche",
startConversation: "Starte eine Unterhaltung, um sie hier zu sehen",
noMessages: "Keine Nachrichten",
untitledSession: "Sitzung ohne Titel",
deleteSession: "Sitzung löschen",
confirmDeleteTitle: "Sitzung löschen?",
confirmDeleteMessage:
"Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
sessionDeleted: "Sitzung gelöscht",
failedToDelete: "Sitzung konnte nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
roles: {
user: "Benutzer",
assistant: "Assistent",
system: "System",
tool: "Werkzeug",
},
},
analytics: {
period: "Zeitraum:",
totalTokens: "Tokens gesamt",
totalSessions: "Sitzungen gesamt",
apiCalls: "API-Aufrufe",
dailyTokenUsage: "Tägliche Token-Nutzung",
dailyBreakdown: "Tagesaufschlüsselung",
perModelBreakdown: "Aufschlüsselung pro Modell",
topSkills: "Top-Skills",
skill: "Skill",
loads: "Agent geladen",
edits: "Agent verwaltet",
lastUsed: "Zuletzt verwendet",
input: "Eingabe",
output: "Ausgabe",
total: "Gesamt",
noUsageData: "Keine Nutzungsdaten für diesen Zeitraum",
startSession: "Starte eine Sitzung, um hier Analysen zu sehen",
date: "Datum",
model: "Modell",
tokens: "Tokens",
perDayAvg: "/Tag Ø",
acrossModels: "über {count} Modelle",
inOut: "{input} ein / {output} aus",
},
models: {
modelsUsed: "Verwendete Modelle",
estimatedCost: "Gesch. Kosten",
tokens: "Tokens",
sessions: "Sitzungen",
avgPerSession: "Ø/Sitzung",
apiCalls: "API-Aufrufe",
toolCalls: "Werkzeug-Aufrufe",
noModelsData: "Keine Modellnutzungsdaten für diesen Zeitraum",
startSession: "Starte eine Sitzung, um hier Modelldaten zu sehen",
},
logs: {
title: "Protokolle",
autoRefresh: "Auto-Aktualisierung",
file: "Datei",
level: "Stufe",
component: "Komponente",
lines: "Zeilen",
noLogLines: "Keine Protokollzeilen gefunden",
},
cron: {
confirmDeleteMessage:
"Damit wird die Aufgabe aus dem Zeitplan entfernt. Dies kann nicht rückgängig gemacht werden.",
confirmDeleteTitle: "Geplante Aufgabe löschen?",
newJob: "Neue Cron-Aufgabe",
nameOptional: "Name (optional)",
namePlaceholder: "z. B. Tägliche Zusammenfassung",
prompt: "Prompt",
promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?",
schedule: "Zeitplan (Cron-Ausdruck)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Zustellen an",
scheduledJobs: "Geplante Aufgaben",
noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.",
last: "Zuletzt",
next: "Nächste",
pause: "Pausieren",
resume: "Fortsetzen",
triggerNow: "Jetzt auslösen",
delivery: {
local: "Lokal",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Neues Profil",
name: "Name",
namePlaceholder: "z. B. coder, writer usw.",
nameRequired: "Name ist erforderlich",
nameRule:
"Nur Kleinbuchstaben, Ziffern, _ und -; muss mit einem Buchstaben oder einer Ziffer beginnen; maximal 64 Zeichen.",
invalidName: "Ungültiger Profilname",
cloneFromDefault: "Konfiguration vom Standardprofil klonen",
allProfiles: "Profile",
noProfiles: "Keine Profile gefunden.",
defaultBadge: "Standard",
hasEnv: "env",
model: "Modell",
skills: "Skills",
rename: "Umbenennen",
editSoul: "SOUL.md bearbeiten",
soulSection: "SOUL.md (Persönlichkeit / System-Prompt)",
soulPlaceholder: "# Wie sich dieser Agent verhalten soll…",
saveSoul: "SOUL speichern",
soulSaved: "SOUL.md gespeichert",
openInTerminal: "CLI-Befehl kopieren",
commandCopied: "In Zwischenablage kopiert",
copyFailed: "Kopieren fehlgeschlagen",
confirmDeleteTitle: "Profil löschen?",
confirmDeleteMessage:
"Damit wird das Profil '{name}' dauerhaft gelöscht — Konfiguration, Schlüssel, Erinnerungen, Sitzungen, Skills, Cron-Aufgaben. Kann nicht rückgängig gemacht werden.",
created: "Erstellt",
deleted: "Gelöscht",
renamed: "Umbenannt",
},
pluginsPage: {
contextEngineLabel: "Kontext-Engine",
dashboardSlots: "Dashboard-Slots",
disableRuntime: "Deaktivieren",
enableAfterInstall: "Nach Installation aktivieren",
enableRuntime: "Aktivieren",
forceReinstall: "Neuinstallation erzwingen (bestehenden Ordner zuerst löschen)",
headline:
"Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).",
identifierLabel: "Git-URL oder owner/repo",
inactive: "inaktiv",
installBtn: "Installieren",
installHeading: "Aus GitHub / Git-URL installieren",
installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.",
memoryProviderLabel: "Speicheranbieter",
missingEnvWarn: "Setze diese unter Schlüssel, bevor das Plugin laufen kann:",
noDashboardTab: "Kein Dashboard-Tab",
openTab: "Öffnen",
orphanHeading: "Nur-Dashboard-Erweiterungen (keine Übereinstimmung mit Agent plugin.yaml)",
pluginListHeading: "Installierte Plugins",
providerDefaults: "eingebaut / Standard",
providersHeading: "Laufzeit-Anbieter-Plugins",
providersHint:
"Schreibt memory.provider (leer = eingebaut) und context.engine in config.yaml. Wirkt sich auf die nächste Sitzung aus.",
refreshDashboard: "Dashboard-Erweiterungen erneut scannen",
removeConfirm: "Dieses Plugin aus ~/.hermes/plugins/ entfernen?",
removeHint: "Nur vom Benutzer installierte Plugins unter ~/.hermes/plugins können entfernt werden.",
rescanHeading: "SPA-Plugin-Registry",
rescanHint: "Nach dem Hinzufügen von Dateien auf dem Datenträger erneut scannen, damit die Sidebar neue Manifeste erkennt.",
runtimeHeading: "Gateway-Laufzeit (YAML-Plugins)",
saveProviders: "Anbieter-Einstellungen speichern",
savedProviders: "Anbieter-Einstellungen gespeichert.",
sourceBadge: "Quelle",
authRequired: "Authentifizierung erforderlich",
authRequiredHint: "Führe diesen Befehl aus, um dich zu authentifizieren:",
updateGit: "Git pull",
versionBadge: "Version",
showInSidebar: "In Sidebar anzeigen",
hideFromSidebar: "Aus Sidebar ausblenden",
},
skills: {
title: "Skills",
searchPlaceholder: "Skills und Toolsets suchen...",
enabledOf: "{enabled}/{total} aktiviert",
all: "Alle",
categories: "Kategorien",
filters: "Filter",
noSkills: "Keine Skills gefunden. Skills werden aus ~/.hermes/skills/ geladen",
noSkillsMatch: "Keine Skills entsprechen deiner Suche oder deinem Filter.",
skillCount: "{count} Skill{s}",
resultCount: "{count} Ergebnis{s}",
noDescription: "Keine Beschreibung verfügbar.",
toolsets: "Toolsets",
toolsetLabel: "{name} Toolset",
noToolsetsMatch: "Keine Toolsets entsprechen der Suche.",
setupNeeded: "Einrichtung erforderlich",
disabledForCli: "Für CLI deaktiviert",
more: "+{count} weitere",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filter",
sections: "Bereiche",
exportConfig: "Konfiguration als JSON exportieren",
importConfig: "Konfiguration aus JSON importieren",
resetDefaults: "Auf Standardwerte zurücksetzen",
resetScopeTooltip: "{scope} auf Standardwerte zurücksetzen",
confirmResetScope: "Alle {scope}-Einstellungen auf ihre Standardwerte zurücksetzen? Dies aktualisiert nur das Formular — Änderungen werden erst in config.yaml geschrieben, wenn du auf Speichern drückst.",
resetScopeToast: "{scope} auf Standardwerte zurückgesetzt — überprüfen und Speichern, um zu übernehmen",
rawYaml: "Rohe YAML-Konfiguration",
searchResults: "Suchergebnisse",
fields: "Feld{s}",
noFieldsMatch: 'Keine Felder entsprechen "{query}"',
configSaved: "Konfiguration gespeichert",
yamlConfigSaved: "YAML-Konfiguration gespeichert",
failedToSave: "Speichern fehlgeschlagen",
failedToSaveYaml: "YAML konnte nicht gespeichert werden",
failedToLoadRaw: "Rohe Konfiguration konnte nicht geladen werden",
configImported: "Konfiguration importiert — überprüfen und speichern",
invalidJson: "Ungültige JSON-Datei",
categories: {
general: "Allgemein",
agent: "Agent",
terminal: "Terminal",
display: "Anzeige",
delegation: "Delegation",
memory: "Speicher",
compression: "Komprimierung",
security: "Sicherheit",
browser: "Browser",
voice: "Stimme",
tts: "Text-zu-Sprache",
stt: "Sprache-zu-Text",
logging: "Protokollierung",
discord: "Discord",
auxiliary: "Hilfs",
},
},
env: {
changesNote: "Änderungen werden sofort auf der Festplatte gespeichert. Aktive Sitzungen übernehmen neue Schlüssel automatisch.",
confirmClearMessage:
"Der gespeicherte Wert für diese Variable wird aus deiner .env-Datei entfernt. Dies kann über die UI nicht rückgängig gemacht werden.",
confirmClearTitle: "Diesen Schlüssel löschen?",
description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind",
hideAdvanced: "Erweitert ausblenden",
showAdvanced: "Erweitert anzeigen",
showLess: "Weniger anzeigen",
showMore: "Mehr anzeigen",
llmProviders: "LLM-Anbieter",
providersConfigured: "{configured} von {total} Anbietern konfiguriert",
getKey: "Schlüssel holen",
notConfigured: "{count} nicht konfiguriert",
notSet: "Nicht gesetzt",
keysCount: "{count} Schlüssel",
enterValue: "Wert eingeben...",
replaceCurrentValue: "Aktuellen Wert ersetzen ({preview})",
showValue: "Echten Wert anzeigen",
hideValue: "Wert ausblenden",
},
oauth: {
title: "Anbieter-Logins (OAuth)",
providerLogins: "Anbieter-Logins (OAuth)",
description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.",
connected: "Verbunden",
expired: "Abgelaufen",
notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.",
runInTerminal: "in einem Terminal.",
noProviders: "Keine OAuth-fähigen Anbieter erkannt.",
login: "Anmelden",
disconnect: "Trennen",
managedExternally: "Extern verwaltet",
copied: "Kopiert ✓",
cli: "Kopieren",
copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
connect: "Verbinden",
sessionExpires: "Sitzung läuft in {time} ab",
initiatingLogin: "Login-Ablauf wird gestartet…",
exchangingCode: "Code wird gegen Tokens getauscht…",
connectedClosing: "Verbunden! Wird geschlossen…",
loginFailed: "Anmeldung fehlgeschlagen.",
sessionExpired: "Sitzung abgelaufen. Klicke auf Erneut versuchen, um eine neue Anmeldung zu starten.",
reOpenAuth: "Authentifizierungsseite erneut öffnen",
reOpenVerification: "Verifizierungsseite erneut öffnen",
submitCode: "Code einreichen",
pasteCode: "Autorisierungscode einfügen (mit #state-Suffix ist okay)",
waitingAuth: "Warte, bis du im Browser autorisierst…",
enterCodePrompt: "Ein neuer Tab wurde geöffnet. Gib bei Aufforderung diesen Code ein:",
pkceStep1: "Ein neuer Tab wurde zu claude.ai geöffnet. Melde dich an und klicke auf Autorisieren.",
pkceStep2: "Kopiere den Autorisierungscode, der nach der Autorisierung angezeigt wird.",
pkceStep3: "Füge ihn unten ein und sende ab.",
flowLabels: {
pkce: "Browser-Login (PKCE)",
device_code: "Gerätecode",
external: "Externe CLI",
},
expiresIn: "läuft in {time} ab",
},
language: {
switchTo: "Sprache wechseln",
},
theme: {
title: "Design",
switchTheme: "Design wechseln",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Sammelbare Hermes-Abzeichen, verdient durch echten Sitzungsverlauf. Bekannte, noch nicht abgeschlossene Achievements werden als Entdeckt angezeigt; geheime Achievements bleiben verborgen, bis das erste passende Verhalten auftritt.",
scan_subtitle:
"Hermes-Sitzungsverlauf wird gescannt. Der erste Scan kann bei umfangreichem Verlauf 510 Sekunden dauern.",
},
actions: {
rescan: "Neu scannen",
},
stats: {
unlocked: "Freigeschaltet",
unlocked_hint: "verdiente Abzeichen",
discovered: "Entdeckt",
discovered_hint: "bekannt, noch nicht verdient",
secrets: "Geheimnisse",
secrets_hint: "verborgen bis zum ersten Signal",
highest_tier: "Höchste Stufe",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Neueste",
latest_hint_empty: "nutze Hermes mehr",
none_yet: "Noch keine",
},
state: {
unlocked: "Freigeschaltet",
discovered: "Entdeckt",
secret: "Geheim",
},
tier: {
target: "Ziel {tier}",
hidden: "Verborgen",
complete: "Abgeschlossen",
objective: "Ziel",
},
progress: {
hidden: "verborgen",
},
scan: {
building_headline: "Achievement-Profil wird erstellt…",
building_detail:
"Sitzungen, Tool-Aufrufe, Modell-Metadaten und Freischaltstatus werden gelesen.",
starting_headline: "Achievement-Scan wird gestartet…",
progress_detail:
"{scanned} von {total} Sitzungen gescannt · {pct}%. Abzeichen werden freigeschaltet, sobald mehr Verlauf eingelesen wird.",
idle_detail:
"Sitzungen, Tool-Aufrufe, Modell-Metadaten und Freischaltstatus werden gelesen. Abzeichen erscheinen hier, sobald sie freigeschaltet werden.",
},
guide: {
tiers_header: "Stufen",
secret_header: "Geheime Achievements",
secret_body:
"Geheimnisse verbergen ihren genauen Auslöser. Sobald Hermes ein verwandtes Signal erkennt, wird die Karte zu Entdeckt und zeigt ihre Anforderung an.",
scan_status_header: "Scan-Status",
scan_status_body:
"Hermes scannt den lokalen Verlauf einmalig, danach erscheinen die Karten automatisch. Es ist nichts hängengeblieben, wenn dies ein paar Sekunden dauert.",
what_scanned_header: "Was gescannt wird",
what_scanned_body:
"Sitzungen, Tool-Aufrufe, Modell-Metadaten, Fehler, Achievements und lokaler Freischaltstatus.",
},
card: {
share_title: "Dieses Achievement teilen",
share_label: "{name} teilen",
share_text: "Teilen",
how_to_reveal: "Wie aufdecken",
what_counts: "Was zählt",
evidence_label: "Beleg",
evidence_session_fallback: "Sitzung",
no_evidence: "Noch kein Beleg",
},
latest: {
header: "Letzte Freischaltungen",
},
empty: {
no_secrets_header: "Keine verborgenen Geheimnisse mehr in diesem Scan.",
no_secrets_body:
"Hinweis: Geheimnisse beginnen meist bei ungewöhnlichen Fehlern oder Power-User-Mustern Port-Konflikten, Berechtigungswänden, fehlenden Umgebungsvariablen, YAML-Fehlern, Docker-Kollisionen, Rollback-/Checkpoint-Nutzung, Cache-Treffern oder kleinen Fixes nach viel rotem Text.",
},
filters: {
all_categories: "Alle",
visibility_all: "alle",
visibility_unlocked: "freigeschaltet",
visibility_discovered: "entdeckt",
visibility_secret: "geheim",
},
share: {
dialog_label: "Achievement teilen",
header: "Teilen: {name}",
close: "Schließen",
rendering: "Wird gerendert…",
card_alt: "{name} Share-Karte",
error_generic: "Etwas ist schiefgelaufen.",
x_title: "Öffnet X mit einem vorgefertigten Post",
x_button: "Auf X teilen",
copy_title: "Bild kopieren, um es in deinen Post einzufügen",
copy_button: "Bild kopieren",
copied: "Kopiert ✓",
download_button: "PNG herunterladen",
hint:
"Auf X teilen öffnet einen vorgefertigten Post in einem neuen Tab. Klicke zuerst auf Bild kopieren, wenn du das 1200×630-Abzeichen anhängen möchtest X lässt dich es direkt in den Tweet-Editor einfügen. PNG herunterladen speichert die Datei zur Nutzung an beliebiger Stelle.",
clipboard_unsupported:
"Bildkopie über die Zwischenablage wird in diesem Browser nicht unterstützt nutze stattdessen Herunterladen.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban-Board wird geladen…",
loadFailed: "Laden des Kanban-Boards fehlgeschlagen: ",
loadFailedHint:
"Das Backend erstellt kanban.db beim ersten Lesen automatisch. Wenn das Problem bestehen bleibt, prüfe die Dashboard-Logs.",
board: "Board",
newBoard: "+ Neues Board",
newBoardTitle: "Neues Board",
newBoardDescription:
"Mit Boards kannst du voneinander unabhängige Arbeitsabläufe trennen — eines pro Projekt, Repository oder Domäne. Worker auf einem Board sehen niemals die Aufgaben eines anderen Boards.",
slug: "Slug",
slugHint: "— Kleinbuchstaben, Bindestriche, z. B. atm10-server",
displayName: "Anzeigename",
displayNameHint: "(optional)",
description: "Beschreibung",
descriptionHint: "(optional)",
icon: "Symbol",
iconHint: "(einzelnes Zeichen oder Emoji)",
switchAfterCreate: "Nach dem Erstellen zu diesem Board wechseln",
cancel: "Abbrechen",
creating: "Wird erstellt…",
createBoard: "Board erstellen",
search: "Suchen",
filterCards: "Karten filtern…",
tenant: "Tenant",
allTenants: "Alle Tenants",
assignee: "Zuständige Person",
allProfiles: "Alle Profile",
showArchived: "Archivierte anzeigen",
lanesByProfile: "Spuren nach Profil",
nudgeDispatcher: "Dispatcher anstoßen",
refresh: "Aktualisieren",
selected: "ausgewählt",
complete: "Abschließen",
archive: "Archivieren",
apply: "Anwenden",
clear: "Zurücksetzen",
createTask: "Aufgabe in dieser Spalte erstellen",
noTasks: "— keine Aufgaben —",
unassigned: "nicht zugewiesen",
untitled: "(ohne Titel)",
loadingDetail: "Wird geladen…",
addComment: "Kommentar hinzufügen… (Enter zum Senden)",
comment: "Kommentar",
status: "Status",
workspace: "Arbeitsbereich",
skills: "Fähigkeiten",
createdBy: "Erstellt von",
result: "Ergebnis",
comments: "Kommentare",
events: "Ereignisse",
runHistory: "Ausführungsverlauf",
workerLog: "Worker-Log",
loadingLog: "Log wird geladen…",
noWorkerLog:
"— noch kein Worker-Log (Aufgabe wurde nicht gestartet oder Log wurde rotiert) —",
noDescription: "— keine Beschreibung —",
noComments: "— keine Kommentare —",
edit: "bearbeiten",
save: "Speichern",
dependencies: "Abhängigkeiten",
parents: "Übergeordnet:",
children: "Untergeordnet:",
none: "keine",
addParent: "— übergeordnete Aufgabe hinzufügen —",
addChild: "— untergeordnete Aufgabe hinzufügen —",
removeDependency: "Abhängigkeit entfernen",
block: "Blockieren",
unblock: "Freigeben",
notifyHomeChannels: "Home-Kanäle benachrichtigen",
diagnostics: "Diagnose",
hide: "Ausblenden",
show: "Anzeigen",
attention: "Achtung",
tasksNeedAttention: "Aufgaben benötigen Aufmerksamkeit",
taskNeedsAttention: "1 Aufgabe benötigt Aufmerksamkeit",
diagnostic: "Diagnose",
open: "Öffnen",
close: "Schließen (Esc)",
reassignTo: "Neu zuweisen an:",
copied: "Kopiert",
copyCommand: "Befehl in die Zwischenablage kopieren",
reclaim: "Zurückholen",
reassign: "Neu zuweisen",
renderingError: "Im Kanban-Tab ist ein Renderfehler aufgetreten",
reloadView: "Ansicht neu laden",
wsAuthFailed:
"WebSocket-Authentifizierung fehlgeschlagen — lade die Seite neu, um das Sitzungs-Token zu aktualisieren.",
markDone: "{n} Aufgabe(n) als erledigt markieren?",
markArchived: "{n} Aufgabe(n) archivieren?",
warning: "Warnung",
phantomIds: "Phantom-IDs:",
active: "aktiv",
ended: "beendet",
noProfile: "(kein Profil)",
showAllAttempts: "Alle Versuche anzeigen",
sendingUpdates: "Aktualisierungen werden gesendet an ",
sendNotifications: "Benachrichtigungen für Abgeschlossen / Blockiert / Aufgegeben senden an",
archiveBoardConfirm:
"Board „{name}“ archivieren? Es wird nach boards/_archived/ verschoben, sodass du es später wiederherstellen kannst. Aufgaben auf diesem Board erscheinen nirgendwo mehr in der UI.",
archiveBoardTitle: "Dieses Board archivieren",
boardSwitcherHint: "Mit Boards kannst du voneinander unabhängige Arbeitsabläufe trennen",
taskCreatedWarning: "Aufgabe erstellt, aber: ",
moveFailed: "Verschieben fehlgeschlagen: ",
bulkFailed: "Bulk: ",
completionBlockedHallucination: "⚠ Abschluss blockiert — Phantom-Karten-IDs",
suspectedHallucinatedReferences: "⚠ Text verweist auf Phantom-Karten-IDs",
pickProfileFirst: "Wähle zuerst ein Profil aus.",
unblockedMessage: "{id} freigegeben. Aufgabe ist bereit für den nächsten Tick.",
unblockFailed: "Freigeben fehlgeschlagen: ",
reclaimedMessage: "{id} zurückgeholt. Aufgabe ist wieder auf ready.",
reclaimFailed: "Zurückholen fehlgeschlagen: ",
reassignedMessage: "{id} an {profile} neu zugewiesen.",
reassignFailed: "Neu zuweisen fehlgeschlagen: ",
selectForBulk: "Für Bulk-Aktionen auswählen",
clickToEdit: "Zum Bearbeiten klicken",
clickToEditAssignee: "Klicken, um zuständige Person zu bearbeiten",
emptyAssignee: "(leer = Zuweisung aufheben)",
columnLabels: {
triage: "Triage",
todo: "Zu erledigen",
scheduled: "Geplant",
ready: "Bereit",
running: "In Bearbeitung",
blocked: "Blockiert",
done: "Erledigt",
archived: "Archiviert",
},
columnHelp: {
triage: "Rohe Ideen — ein Specifier wird die Spezifikation ausarbeiten",
todo: "Wartet auf Abhängigkeiten oder ist nicht zugewiesen",
scheduled: "Wartet auf eine bekannte Verzögerung oder eine geplante Nachverfolgung",
ready: "Abhängigkeiten erfüllt; Profil zum Dispatch zuweisen",
running: "Von einem Worker übernommen — in Bearbeitung",
blocked: "Worker hat um menschliche Eingabe gebeten",
done: "Abgeschlossen",
archived: "Archiviert",
},
confirmDone:
"Diese Aufgabe als erledigt markieren? Der Anspruch des Workers wird freigegeben und abhängige untergeordnete Aufgaben werden bereit.",
confirmArchive:
"Diese Aufgabe archivieren? Sie verschwindet aus der Standard-Board-Ansicht.",
confirmBlocked:
"Diese Aufgabe als blockiert markieren? Der Anspruch des Workers wird freigegeben.",
completionSummary:
"Abschluss-Zusammenfassung für {label}. Diese wird als Ergebnis der Aufgabe gespeichert.",
completionSummaryRequired:
"Eine Abschluss-Zusammenfassung ist erforderlich, bevor eine Aufgabe als erledigt markiert werden kann.",
triagePlaceholder: "Grobe Idee — die KI wird die Spezifikation erstellen…",
taskTitlePlaceholder: "Titel der neuen Aufgabe…",
specifier: "Specifier",
assigneePlaceholder: "Zuständige Person",
priority: "Priorität",
skillsPlaceholder:
"Fähigkeiten (optional, kommagetrennt): translation, github-code-review",
noParent: "— keine übergeordnete Aufgabe —",
workspacePathDir: "Arbeitsbereichs-Pfad (erforderlich, z. B. ~/projects/my-app)",
workspacePathOptional:
"Arbeitsbereichs-Pfad (optional, wird aus zuständiger Person abgeleitet, wenn leer)",
logTruncated: "(zeige die letzten 100 KB — vollständiges Log unter ",
logAt: ")",
},
};
+708
View File
@@ -0,0 +1,708 @@
import type { Translations } from "./types";
export const en: Translations = {
common: {
save: "Save",
saving: "Saving...",
cancel: "Cancel",
close: "Close",
confirm: "Confirm",
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",
pluginLoadFailed:
"Could not load this plugins script. Check the Network tab (dashboard-plugins/…) and the servers plugin path.",
pluginNotRegistered:
"The plugins script did not call register(), or the script errored. Open the browser console for details.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Close navigation",
closeModelTools: "Close model and tools",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Active Sessions:",
gatewayStatusLabel: "Gateway Status:",
gatewayStrip: {
failed: "Start failed",
off: "Off",
running: "Running",
starting: "Starting",
stopped: "Stopped",
},
nav: {
analytics: "Analytics",
chat: "Chat",
config: "Config",
cron: "Cron",
documentation: "Documentation",
keys: "Keys",
logs: "Logs",
models: "Models",
profiles: "Profiles",
plugins: "Plugins",
sessions: "Sessions",
skills: "Skills",
},
modelToolsSheetSubtitle: "& tools",
modelToolsSheetTitle: "Model",
navigation: "Navigation",
openDocumentation: "Open documentation in a new tab",
openNavigation: "Open navigation",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} active",
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
},
status: {
actionFailed: "Action failed",
actionFinished: "Finished",
actions: "Actions",
agent: "Agent",
activeSessions: "Active Sessions",
connected: "Connected",
connectedPlatforms: "Connected Platforms",
disconnected: "Disconnected",
error: "Error",
failed: "Failed",
gateway: "Gateway",
gatewayFailedToStart: "Gateway failed to start",
lastUpdate: "Last update",
noneRunning: "None",
notRunning: "Not running",
pid: "PID",
platformDisconnected: "disconnected",
platformError: "error",
recentSessions: "Recent Sessions",
restartGateway: "Restart Gateway",
restartingGateway: "Restarting gateway…",
running: "Running",
runningRemote: "Running (remote)",
startFailed: "Start failed",
starting: "Starting",
startedInBackground: "Started in background — check logs for progress",
stopped: "Stopped",
updateHermes: "Update Hermes",
updatingHermes: "Updating Hermes…",
waitingForOutput: "Waiting for output…",
},
sessions: {
title: "Sessions",
history: "History",
overview: "Overview",
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",
confirmDeleteTitle: "Delete session?",
confirmDeleteMessage:
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
resumeInChat: "Resume in Chat",
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",
topSkills: "Top Skills",
skill: "Skill",
loads: "Agent Loaded",
edits: "Agent Managed",
lastUsed: "Last Used",
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",
},
models: {
modelsUsed: "Models Used",
estimatedCost: "Est. Cost",
tokens: "tokens",
sessions: "sessions",
avgPerSession: "avg/session",
apiCalls: "API calls",
toolCalls: "tool calls",
noModelsData: "No model usage data for this period",
startSession: "Start a session to see model data here",
},
logs: {
title: "Logs",
autoRefresh: "Auto-refresh",
file: "File",
level: "Level",
component: "Component",
lines: "Lines",
noLogLines: "No log lines found",
},
cron: {
confirmDeleteMessage:
"This removes the job from the schedule. This cannot be undone.",
confirmDeleteTitle: "Delete scheduled job?",
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",
},
},
profiles: {
newProfile: "New Profile",
name: "Name",
namePlaceholder: "e.g. coder, writer, etc.",
nameRequired: "Name is required",
nameRule:
"Lowercase letters, digits, _ and - only; must start with a letter or digit; up to 64 characters.",
invalidName: "Invalid profile name",
cloneFromDefault: "Clone config from default profile",
allProfiles: "Profiles",
noProfiles: "No profiles found.",
defaultBadge: "default",
hasEnv: "env",
model: "Model",
skills: "Skills",
rename: "Rename",
editSoul: "Edit SOUL.md",
soulSection: "SOUL.md (personality / system prompt)",
soulPlaceholder: "# How this agent should behave…",
saveSoul: "Save SOUL",
soulSaved: "SOUL.md saved",
openInTerminal: "Copy CLI command",
commandCopied: "Copied to clipboard",
copyFailed: "Could not copy",
confirmDeleteTitle: "Delete profile?",
confirmDeleteMessage:
"This permanently deletes profile '{name}' — config, keys, memories, sessions, skills, cron jobs. Cannot be undone.",
created: "Created",
deleted: "Deleted",
renamed: "Renamed",
},
pluginsPage: {
contextEngineLabel: "Context engine",
dashboardSlots: "Dashboard slots",
disableRuntime: "Disable",
enableAfterInstall: "Enable after install",
enableRuntime: "Enable",
forceReinstall: "Force reinstall (delete existing folder first)",
headline:
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo",
inactive: "inactive",
installBtn: "Install",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider",
missingEnvWarn: "Set these in Keys before the plugin can run:",
noDashboardTab: "No dashboard tab",
openTab: "Open",
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
pluginListHeading: "Installed plugins",
providerDefaults: "built-in / default",
providersHeading: "Runtime provider plugins",
providersHint:
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
refreshDashboard: "Rescan dashboard extensions",
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
rescanHeading: "SPA plugin registry",
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
runtimeHeading: "Gateway runtime (YAML plugins)",
saveProviders: "Save provider settings",
savedProviders: "Provider settings saved.",
sourceBadge: "Source",
authRequired: "Auth required",
authRequiredHint: "Run this command to authenticate:",
updateGit: "Git pull",
versionBadge: "Version",
showInSidebar: "Show in sidebar",
hideFromSidebar: "Hide from sidebar",
},
skills: {
title: "Skills",
searchPlaceholder: "Search skills and toolsets...",
enabledOf: "{enabled}/{total} enabled",
all: "All",
categories: "Categories",
filters: "Filters",
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
noSkillsMatch: "No skills match your search or filter.",
skillCount: "{count} skill{s}",
resultCount: "{count} result{s}",
noDescription: "No description available.",
toolsets: "Toolsets",
toolsetLabel: "{name} toolset",
noToolsetsMatch: "No toolsets match the search.",
setupNeeded: "Setup needed",
disabledForCli: "Disabled for CLI",
more: "+{count} more",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filters",
sections: "Sections",
exportConfig: "Export config as JSON",
importConfig: "Import config from JSON",
resetDefaults: "Reset to defaults",
resetScopeTooltip: "Reset {scope} to defaults",
confirmResetScope: "Reset all {scope} settings to their defaults? This only updates the form — changes aren't written to config.yaml until you press Save.",
resetScopeToast: "{scope} reset to defaults — review and Save to persist",
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: {
changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.",
confirmClearMessage:
"The stored value for this variable will be removed from your .env file. This cannot be undone from the UI.",
confirmClearTitle: "Clear this key?",
description: "Manage API keys and secrets stored in",
hideAdvanced: "Hide Advanced",
showAdvanced: "Show Advanced",
showLess: "Show less",
showMore: "Show more",
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: "Copy",
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 language",
},
theme: {
title: "Theme",
switchTheme: "Switch theme",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Collectible Hermes badges earned from real session history. Known unfinished achievements are shown as Discovered; Secret achievements stay hidden until the first matching behavior appears.",
scan_subtitle:
"Scanning Hermes session history. First scan can take 510 seconds on large histories.",
},
actions: {
rescan: "Rescan",
},
stats: {
unlocked: "Unlocked",
unlocked_hint: "earned badges",
discovered: "Discovered",
discovered_hint: "known, not earned yet",
secrets: "Secrets",
secrets_hint: "hidden until first signal",
highest_tier: "Highest tier",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Latest",
latest_hint_empty: "run Hermes more",
none_yet: "None yet",
},
state: {
unlocked: "Unlocked",
discovered: "Discovered",
secret: "Secret",
},
tier: {
target: "Target {tier}",
hidden: "Hidden",
complete: "Complete",
objective: "Objective",
},
progress: {
hidden: "hidden",
},
scan: {
building_headline: "Building achievement profile…",
building_detail:
"Reading sessions, tool calls, model metadata, and unlock state.",
starting_headline: "Starting achievement scan…",
progress_detail:
"Scanned {scanned} of {total} sessions · {pct}%. Badges unlock as more history streams in.",
idle_detail:
"Reading sessions, tool calls, model metadata, and unlock state. Badges appear here as they unlock.",
},
guide: {
tiers_header: "Tiers",
secret_header: "Secret achievements",
secret_body:
"Secrets hide their exact trigger. Once Hermes sees a related signal, the card becomes Discovered and shows its requirement.",
scan_status_header: "Scan status",
scan_status_body:
"Hermes is scanning local history once, then cards will appear automatically. Nothing is stuck if this takes a few seconds.",
what_scanned_header: "What is scanned",
what_scanned_body:
"Sessions, tool calls, model metadata, errors, achievements, and local unlock state.",
},
card: {
share_title: "Share this achievement",
share_label: "Share {name}",
share_text: "Share",
how_to_reveal: "How to reveal",
what_counts: "What counts",
evidence_label: "Evidence",
evidence_session_fallback: "session",
no_evidence: "No evidence yet",
},
latest: {
header: "Recent unlocks",
},
empty: {
no_secrets_header: "No hidden secrets left in this scan.",
no_secrets_body:
"Clue: secrets usually start from unusual failure or power-user patterns — port conflicts, permission walls, missing env vars, YAML mistakes, Docker collisions, rollback/checkpoint use, cache hits, or tiny fixes after lots of red text.",
},
filters: {
all_categories: "All",
visibility_all: "all",
visibility_unlocked: "unlocked",
visibility_discovered: "discovered",
visibility_secret: "secret",
},
share: {
dialog_label: "Share achievement",
header: "Share: {name}",
close: "Close",
rendering: "Rendering…",
card_alt: "{name} share card",
error_generic: "Something went wrong.",
x_title: "Opens X with a pre-filled post",
x_button: "Share on X",
copy_title: "Copy the image to paste into your post",
copy_button: "Copy image",
copied: "Copied ✓",
download_button: "Download PNG",
hint:
"Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere.",
clipboard_unsupported:
"Clipboard image copy not supported in this browser — use Download instead.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Loading Kanban board…",
loadFailed: "Failed to load Kanban board: ",
loadFailedHint:
"The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs.",
board: "Board",
newBoard: "+ New board",
newBoardTitle: "New board",
newBoardDescription:
"Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks.",
slug: "Slug",
slugHint: "— lowercase, hyphens, e.g. atm10-server",
displayName: "Display name",
displayNameHint: "(optional)",
description: "Description",
descriptionHint: "(optional)",
icon: "Icon",
iconHint: "(single character or emoji)",
switchAfterCreate: "Switch to this board after creating it",
cancel: "Cancel",
creating: "Creating…",
createBoard: "Create board",
search: "Search",
filterCards: "Filter cards…",
tenant: "Tenant",
allTenants: "All tenants",
assignee: "Assignee",
allProfiles: "All profiles",
showArchived: "Show archived",
lanesByProfile: "Lanes by profile",
nudgeDispatcher: "Nudge dispatcher",
refresh: "Refresh",
selected: "selected",
complete: "Complete",
archive: "Archive",
apply: "Apply",
clear: "Clear",
createTask: "Create task in this column",
noTasks: "— no tasks —",
unassigned: "unassigned",
needsAssignee: "Needs assignee",
needsAssigneeHint:
"Dependencies are satisfied, but the dispatcher skips this task until you assign a profile.",
untitled: "(untitled)",
loadingDetail: "Loading…",
addComment: "Add a comment… (Enter to submit)",
comment: "Comment",
status: "Status",
workspace: "Workspace",
skills: "Skills",
createdBy: "Created by",
result: "Result",
comments: "Comments",
events: "Events",
runHistory: "Run history",
workerLog: "Worker log",
loadingLog: "Loading log…",
noWorkerLog:
"— no worker log yet (task hasn't spawned or log was rotated away) —",
noDescription: "— no description —",
noComments: "— no comments —",
edit: "edit",
save: "Save",
dependencies: "Dependencies",
parents: "Parents:",
children: "Children:",
none: "none",
addParent: "— add parent —",
addChild: "— add child —",
removeDependency: "Remove dependency",
block: "Block",
unblock: "Unblock",
notifyHomeChannels: "Notify home channels",
diagnostics: "Diagnostics",
hide: "Hide",
show: "Show",
attention: "Attention",
tasksNeedAttention: "tasks need attention",
taskNeedsAttention: "1 task needs attention",
diagnostic: "diagnostic",
open: "Open",
close: "Close (Esc)",
reassignTo: "Reassign to:",
copied: "Copied",
copyCommand: "Copy command to clipboard",
reclaim: "Reclaim",
reassign: "Reassign",
renderingError: "Kanban tab hit a rendering error",
reloadView: "Reload view",
wsAuthFailed:
"WebSocket auth failed — reload the page to refresh the session token.",
markDone: "Mark {n} task(s) as done?",
markArchived: "Archive {n} task(s)?",
warning: "Warning",
phantomIds: "Phantom ids:",
active: "active",
ended: "ended",
noProfile: "(no profile)",
showAllAttempts: "Show all attempts",
sendingUpdates: "Sending updates to",
sendNotifications: "Send completed / blocked / gave_up notifications to",
archiveBoardConfirm:
"Archive board '{name}'? It will be moved to boards/_archived/ so you can recover it later. Tasks on this board will no longer appear anywhere in the UI.",
archiveBoardTitle: "Archive this board",
boardSwitcherHint: "Boards let you separate unrelated streams of work",
taskCreatedWarning: "Task created, but: ",
moveFailed: "Move failed: ",
bulkFailed: "Bulk: ",
completionBlockedHallucination: "⚠ Completion blocked — phantom card ids",
suspectedHallucinatedReferences: "⚠ Prose referenced phantom card ids",
pickProfileFirst: "Pick a profile first.",
unblockedMessage: "Unblocked {id}. Task is ready for the next tick.",
unblockFailed: "Unblock failed: ",
reclaimedMessage: "Reclaimed {id}. Task is back to ready.",
reclaimFailed: "Reclaim failed: ",
reassignedMessage: "Reassigned {id} to {profile}.",
reassignFailed: "Reassign failed: ",
selectForBulk: "Select for bulk actions",
clickToEdit: "Click to edit",
clickToEditAssignee: "Click to edit assignee",
emptyAssignee: "(empty = unassign)",
columnLabels: {
triage: "Triage",
todo: "Todo",
scheduled: "Scheduled",
ready: "Ready",
running: "In Progress",
blocked: "Blocked",
done: "Done",
archived: "Archived",
},
columnHelp: {
triage: "Raw ideas — a specifier will flesh out the spec",
todo: "Waiting on dependencies or unassigned",
scheduled: "Waiting on a known time delay or scheduled follow-up",
ready: "Dependencies satisfied; assign a profile to dispatch",
running: "Claimed by a worker — in-flight",
blocked: "Worker asked for human input",
done: "Completed",
archived: "Archived",
},
confirmDone:
"Mark this task as done? The worker's claim is released and dependent children become ready.",
confirmArchive:
"Archive this task? It disappears from the default board view.",
confirmBlocked:
"Mark this task as blocked? The worker's claim is released.",
confirmScheduled:
"Move this task to Scheduled? Use this for known time delays rather than human blockers.",
completionSummary:
"Completion summary for {label}. This is stored as the task result.",
completionSummaryRequired:
"Completion summary is required before marking a task done.",
triagePlaceholder: "Rough idea — AI will spec it…",
taskTitlePlaceholder: "New task title…",
specifier: "specifier",
assigneePlaceholder: "assignee",
priority: "Priority",
skillsPlaceholder:
"skills (optional, comma-separated): translation, github-code-review",
noParent: "— no parent —",
workspacePathDir: "workspace path (required, e.g. ~/projects/my-app)",
workspacePathOptional:
"workspace path (optional, derived from assignee if blank)",
logTruncated: "(showing last 100 KB — full log at ",
logAt: ")",
},
};
+701
View File
@@ -0,0 +1,701 @@
import type { Translations } from "./types";
export const es: Translations = {
common: {
save: "Guardar",
saving: "Guardando...",
cancel: "Cancelar",
close: "Cerrar",
confirm: "Confirmar",
delete: "Eliminar",
refresh: "Actualizar",
retry: "Reintentar",
search: "Buscar...",
loading: "Cargando...",
create: "Crear",
creating: "Creando...",
set: "Establecer",
replace: "Reemplazar",
clear: "Limpiar",
live: "En vivo",
off: "Apagado",
enabled: "habilitado",
disabled: "deshabilitado",
active: "activo",
inactive: "inactivo",
unknown: "desconocido",
untitled: "Sin título",
none: "Ninguno",
form: "Formulario",
noResults: "Sin resultados",
of: "de",
page: "Página",
msgs: "msjs",
tools: "herramientas",
match: "coincidencia",
other: "Otros",
configured: "configurado",
removed: "eliminado",
failedToToggle: "No se pudo alternar",
failedToRemove: "No se pudo eliminar",
failedToReveal: "No se pudo mostrar",
collapse: "Contraer",
expand: "Expandir",
general: "General",
messaging: "Mensajería",
pluginLoadFailed:
"No se pudo cargar el script de este complemento. Revisa la pestaña Network (dashboard-plugins/…) y la ruta del complemento del servidor.",
pluginNotRegistered:
"El script del complemento no llamó a register(), o falló. Abre la consola del navegador para más detalles.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Cerrar navegación",
closeModelTools: "Cerrar modelo y herramientas",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Sesiones activas:",
gatewayStatusLabel: "Estado del Gateway:",
gatewayStrip: {
failed: "Inicio fallido",
off: "Apagado",
running: "En ejecución",
starting: "Iniciando",
stopped: "Detenido",
},
nav: {
analytics: "Analíticas",
chat: "Chat",
config: "Configuración",
cron: "Cron",
documentation: "Documentación",
keys: "Claves",
logs: "Registros",
models: "Modelos",
profiles: "perfiles : multi agentes",
plugins: "Complementos",
sessions: "Sesiones",
skills: "Habilidades",
},
modelToolsSheetSubtitle: "y herramientas",
modelToolsSheetTitle: "Modelo",
navigation: "Navegación",
openDocumentation: "Abrir documentación en una nueva pestaña",
openNavigation: "Abrir navegación",
pluginNavSection: "Complementos",
sessionsActiveCount: "{count} activas",
statusOverview: "Resumen de estado",
system: "Sistema",
webUi: "Web UI",
},
status: {
actionFailed: "Acción fallida",
actionFinished: "Finalizado",
actions: "Acciones",
agent: "Agente",
activeSessions: "Sesiones activas",
connected: "Conectado",
connectedPlatforms: "Plataformas conectadas",
disconnected: "Desconectado",
error: "Error",
failed: "Fallido",
gateway: "Gateway",
gatewayFailedToStart: "El Gateway no pudo iniciarse",
lastUpdate: "Última actualización",
noneRunning: "Ninguno",
notRunning: "No en ejecución",
pid: "PID",
platformDisconnected: "desconectado",
platformError: "error",
recentSessions: "Sesiones recientes",
restartGateway: "Reiniciar Gateway",
restartingGateway: "Reiniciando gateway…",
running: "En ejecución",
runningRemote: "En ejecución (remoto)",
startFailed: "Inicio fallido",
starting: "Iniciando",
startedInBackground: "Iniciado en segundo plano — revisa los registros para ver el progreso",
stopped: "Detenido",
updateHermes: "Actualizar Hermes",
updatingHermes: "Actualizando Hermes…",
waitingForOutput: "Esperando salida…",
},
sessions: {
title: "Sesiones",
history: "Historial",
overview: "Resumen",
searchPlaceholder: "Buscar contenido de mensajes...",
noSessions: "Aún no hay sesiones",
noMatch: "Ninguna sesión coincide con tu búsqueda",
startConversation: "Inicia una conversación para verla aquí",
noMessages: "Sin mensajes",
untitledSession: "Sesión sin título",
deleteSession: "Eliminar sesión",
confirmDeleteTitle: "¿Eliminar sesión?",
confirmDeleteMessage:
"Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.",
sessionDeleted: "Sesión eliminada",
failedToDelete: "No se pudo eliminar la sesión",
resumeInChat: "Reanudar en el chat",
previousPage: "Página anterior",
nextPage: "Página siguiente",
roles: {
user: "Usuario",
assistant: "Asistente",
system: "Sistema",
tool: "Herramienta",
},
},
analytics: {
period: "Período:",
totalTokens: "Tokens totales",
totalSessions: "Sesiones totales",
apiCalls: "Llamadas API",
dailyTokenUsage: "Uso diario de tokens",
dailyBreakdown: "Desglose diario",
perModelBreakdown: "Desglose por modelo",
topSkills: "Habilidades principales",
skill: "Habilidad",
loads: "Agente cargó",
edits: "Agente gestionó",
lastUsed: "Último uso",
input: "Entrada",
output: "Salida",
total: "Total",
noUsageData: "No hay datos de uso para este período",
startSession: "Inicia una sesión para ver analíticas aquí",
date: "Fecha",
model: "Modelo",
tokens: "Tokens",
perDayAvg: "/día prom.",
acrossModels: "en {count} modelos",
inOut: "{input} entrada / {output} salida",
},
models: {
modelsUsed: "Modelos utilizados",
estimatedCost: "Coste est.",
tokens: "tokens",
sessions: "sesiones",
avgPerSession: "prom./sesión",
apiCalls: "llamadas API",
toolCalls: "llamadas de herramientas",
noModelsData: "No hay datos de uso de modelos para este período",
startSession: "Inicia una sesión para ver datos de modelos aquí",
},
logs: {
title: "Registros",
autoRefresh: "Actualización automática",
file: "Archivo",
level: "Nivel",
component: "Componente",
lines: "Líneas",
noLogLines: "No se encontraron líneas de registro",
},
cron: {
confirmDeleteMessage:
"Esto elimina la tarea de la programación. No se puede deshacer.",
confirmDeleteTitle: "¿Eliminar tarea programada?",
newJob: "Nueva tarea Cron",
nameOptional: "Nombre (opcional)",
namePlaceholder: "p. ej. Resumen diario",
prompt: "Prompt",
promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?",
schedule: "Programación (expresión cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Entregar a",
scheduledJobs: "Tareas programadas",
noJobs: "No hay tareas cron configuradas. Crea una arriba.",
last: "Última",
next: "Próxima",
pause: "Pausar",
resume: "Reanudar",
triggerNow: "Ejecutar ahora",
delivery: {
local: "Local",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Nuevo perfil",
name: "Nombre",
namePlaceholder: "p. ej. coder, writer, etc.",
nameRequired: "El nombre es obligatorio",
nameRule:
"Solo letras minúsculas, dígitos, _ y -; debe comenzar con una letra o dígito; hasta 64 caracteres.",
invalidName: "Nombre de perfil no válido",
cloneFromDefault: "Clonar configuración del perfil predeterminado",
allProfiles: "Perfiles",
noProfiles: "No se encontraron perfiles.",
defaultBadge: "predeterminado",
hasEnv: "env",
model: "Modelo",
skills: "Habilidades",
rename: "Renombrar",
editSoul: "Editar SOUL.md",
soulSection: "SOUL.md (personalidad / prompt del sistema)",
soulPlaceholder: "# Cómo debe comportarse este agente…",
saveSoul: "Guardar SOUL",
soulSaved: "SOUL.md guardado",
openInTerminal: "Copiar comando CLI",
commandCopied: "Copiado al portapapeles",
copyFailed: "No se pudo copiar",
confirmDeleteTitle: "¿Eliminar perfil?",
confirmDeleteMessage:
"Esto elimina permanentemente el perfil '{name}' — configuración, claves, memorias, sesiones, habilidades, tareas cron. No se puede deshacer.",
created: "Creado",
deleted: "Eliminado",
renamed: "Renombrado",
},
pluginsPage: {
contextEngineLabel: "Motor de contexto",
dashboardSlots: "Slots del panel",
disableRuntime: "Deshabilitar",
enableAfterInstall: "Habilitar tras instalar",
enableRuntime: "Habilitar",
forceReinstall: "Forzar reinstalación (eliminar carpeta existente primero)",
headline:
"Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).",
identifierLabel: "URL de Git u owner/repo",
inactive: "inactivo",
installBtn: "Instalar",
installHeading: "Instalar desde GitHub / URL de Git",
installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.",
memoryProviderLabel: "Proveedor de memoria",
missingEnvWarn: "Configura estos en Claves antes de que el complemento pueda ejecutarse:",
noDashboardTab: "Sin pestaña de panel",
openTab: "Abrir",
orphanHeading: "Extensiones solo del panel (sin coincidencia de plugin.yaml del agente)",
pluginListHeading: "Complementos instalados",
providerDefaults: "incorporado / predeterminado",
providersHeading: "Complementos de proveedor en tiempo de ejecución",
providersHint:
"Escribe memory.provider (vacío = incorporado) y context.engine en config.yaml. Surte efecto en la próxima sesión.",
refreshDashboard: "Volver a escanear extensiones del panel",
removeConfirm: "¿Eliminar este complemento de ~/.hermes/plugins/?",
removeHint: "Solo se pueden eliminar complementos instalados por el usuario en ~/.hermes/plugins.",
rescanHeading: "Registro de complementos SPA",
rescanHint: "Vuelve a escanear tras añadir archivos en disco para que la barra lateral del panel detecte nuevos manifiestos.",
runtimeHeading: "Tiempo de ejecución del Gateway (complementos YAML)",
saveProviders: "Guardar configuración del proveedor",
savedProviders: "Configuración del proveedor guardada.",
sourceBadge: "Fuente",
authRequired: "Autenticación requerida",
authRequiredHint: "Ejecuta este comando para autenticarte:",
updateGit: "Git pull",
versionBadge: "Versión",
showInSidebar: "Mostrar en barra lateral",
hideFromSidebar: "Ocultar de la barra lateral",
},
skills: {
title: "Habilidades",
searchPlaceholder: "Buscar habilidades y conjuntos de herramientas...",
enabledOf: "{enabled}/{total} habilitados",
all: "Todas",
categories: "Categorías",
filters: "Filtros",
noSkills: "No se encontraron habilidades. Las habilidades se cargan desde ~/.hermes/skills/",
noSkillsMatch: "Ninguna habilidad coincide con tu búsqueda o filtro.",
skillCount: "{count} habilidad{s}",
resultCount: "{count} resultado{s}",
noDescription: "No hay descripción disponible.",
toolsets: "Conjuntos de herramientas",
toolsetLabel: "conjunto de herramientas {name}",
noToolsetsMatch: "Ningún conjunto de herramientas coincide con la búsqueda.",
setupNeeded: "Configuración necesaria",
disabledForCli: "Deshabilitado para CLI",
more: "+{count} más",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filtros",
sections: "Secciones",
exportConfig: "Exportar configuración como JSON",
importConfig: "Importar configuración desde JSON",
resetDefaults: "Restablecer valores predeterminados",
resetScopeTooltip: "Restablecer {scope} a los valores predeterminados",
confirmResetScope: "¿Restablecer todos los ajustes de {scope} a sus valores predeterminados? Esto solo actualiza el formulario — los cambios no se escriben en config.yaml hasta que pulses Guardar.",
resetScopeToast: "{scope} restablecido a los valores predeterminados — revisa y guarda para que persista",
rawYaml: "Configuración YAML en bruto",
searchResults: "Resultados de búsqueda",
fields: "campo{s}",
noFieldsMatch: 'Ningún campo coincide con "{query}"',
configSaved: "Configuración guardada",
yamlConfigSaved: "Configuración YAML guardada",
failedToSave: "No se pudo guardar",
failedToSaveYaml: "No se pudo guardar YAML",
failedToLoadRaw: "No se pudo cargar la configuración en bruto",
configImported: "Configuración importada — revisa y guarda",
invalidJson: "Archivo JSON no válido",
categories: {
general: "General",
agent: "Agente",
terminal: "Terminal",
display: "Pantalla",
delegation: "Delegación",
memory: "Memoria",
compression: "Compresión",
security: "Seguridad",
browser: "Navegador",
voice: "Voz",
tts: "Texto a voz",
stt: "Voz a texto",
logging: "Registro",
discord: "Discord",
auxiliary: "Auxiliar",
},
},
env: {
changesNote: "Los cambios se guardan en disco inmediatamente. Las sesiones activas adoptan las nuevas claves automáticamente.",
confirmClearMessage:
"El valor almacenado para esta variable se eliminará de tu archivo .env. Esto no se puede deshacer desde la UI.",
confirmClearTitle: "¿Limpiar esta clave?",
description: "Gestiona claves API y secretos almacenados en",
hideAdvanced: "Ocultar avanzado",
showAdvanced: "Mostrar avanzado",
showLess: "Mostrar menos",
showMore: "Mostrar más",
llmProviders: "Proveedores LLM",
providersConfigured: "{configured} de {total} proveedores configurados",
getKey: "Obtener clave",
notConfigured: "{count} no configurados",
notSet: "No establecido",
keysCount: "{count} clave{s}",
enterValue: "Introduce un valor...",
replaceCurrentValue: "Reemplazar valor actual ({preview})",
showValue: "Mostrar valor real",
hideValue: "Ocultar valor",
},
oauth: {
title: "Inicios de sesión de proveedores (OAuth)",
providerLogins: "Inicios de sesión de proveedores (OAuth)",
description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.",
connected: "Conectado",
expired: "Caducado",
notConnected: "No conectado. Ejecuta {command} en una terminal.",
runInTerminal: "en una terminal.",
noProviders: "No se han detectado proveedores compatibles con OAuth.",
login: "Iniciar sesión",
disconnect: "Desconectar",
managedExternally: "Gestionado externamente",
copied: "Copiado ✓",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
connect: "Conectar",
sessionExpires: "La sesión caduca en {time}",
initiatingLogin: "Iniciando flujo de inicio de sesión…",
exchangingCode: "Intercambiando código por tokens…",
connectedClosing: "¡Conectado! Cerrando…",
loginFailed: "Inicio de sesión fallido.",
sessionExpired: "Sesión caducada. Haz clic en Reintentar para iniciar un nuevo inicio de sesión.",
reOpenAuth: "Reabrir página de autenticación",
reOpenVerification: "Reabrir página de verificación",
submitCode: "Enviar código",
pasteCode: "Pega el código de autorización (con el sufijo #state está bien)",
waitingAuth: "Esperando que autorices en el navegador…",
enterCodePrompt: "Se abrió una nueva pestaña. Introduce este código si se solicita:",
pkceStep1: "Se abrió una nueva pestaña en claude.ai. Inicia sesión y haz clic en Autorizar.",
pkceStep2: "Copia el código de autorización mostrado tras autorizar.",
pkceStep3: "Pégalo abajo y envía.",
flowLabels: {
pkce: "Inicio de sesión por navegador (PKCE)",
device_code: "Código de dispositivo",
external: "CLI externa",
},
expiresIn: "caduca en {time}",
},
language: {
switchTo: "Cambiar idioma",
},
theme: {
title: "Tema",
switchTheme: "Cambiar tema",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Insignias coleccionables de Hermes ganadas a partir del historial real de sesiones. Los logros conocidos no completados se muestran como Descubiertos; los logros secretos permanecen ocultos hasta que aparece el primer comportamiento coincidente.",
scan_subtitle:
"Escaneando el historial de sesiones de Hermes. El primer escaneo puede tardar 510 segundos en historiales grandes.",
},
actions: {
rescan: "Volver a escanear",
},
stats: {
unlocked: "Desbloqueados",
unlocked_hint: "insignias ganadas",
discovered: "Descubiertos",
discovered_hint: "conocidos, aún no ganados",
secrets: "Secretos",
secrets_hint: "ocultos hasta la primera señal",
highest_tier: "Nivel más alto",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Más reciente",
latest_hint_empty: "usa Hermes más",
none_yet: "Ninguno aún",
},
state: {
unlocked: "Desbloqueado",
discovered: "Descubierto",
secret: "Secreto",
},
tier: {
target: "Objetivo {tier}",
hidden: "Oculto",
complete: "Completo",
objective: "Objetivo",
},
progress: {
hidden: "oculto",
},
scan: {
building_headline: "Construyendo perfil de logros…",
building_detail:
"Leyendo sesiones, llamadas a herramientas, metadatos del modelo y estado de desbloqueo.",
starting_headline: "Iniciando escaneo de logros…",
progress_detail:
"Escaneadas {scanned} de {total} sesiones · {pct}%. Las insignias se desbloquean a medida que se procesa más historial.",
idle_detail:
"Leyendo sesiones, llamadas a herramientas, metadatos del modelo y estado de desbloqueo. Las insignias aparecerán aquí a medida que se desbloqueen.",
},
guide: {
tiers_header: "Niveles",
secret_header: "Logros secretos",
secret_body:
"Los secretos ocultan su disparador exacto. Una vez que Hermes detecta una señal relacionada, la tarjeta pasa a Descubierto y muestra su requisito.",
scan_status_header: "Estado del escaneo",
scan_status_body:
"Hermes está escaneando el historial local una vez, después las tarjetas aparecerán automáticamente. No hay nada bloqueado si tarda unos segundos.",
what_scanned_header: "Qué se escanea",
what_scanned_body:
"Sesiones, llamadas a herramientas, metadatos del modelo, errores, logros y estado de desbloqueo local.",
},
card: {
share_title: "Compartir este logro",
share_label: "Compartir {name}",
share_text: "Compartir",
how_to_reveal: "Cómo revelarlo",
what_counts: "Qué cuenta",
evidence_label: "Evidencia",
evidence_session_fallback: "sesión",
no_evidence: "Aún sin evidencia",
},
latest: {
header: "Desbloqueos recientes",
},
empty: {
no_secrets_header: "No quedan secretos ocultos en este escaneo.",
no_secrets_body:
"Pista: los secretos suelen comenzar a partir de fallos inusuales o patrones de usuario avanzado: conflictos de puertos, muros de permisos, variables de entorno faltantes, errores de YAML, colisiones de Docker, uso de rollback/checkpoint, aciertos de caché o pequeñas correcciones tras mucho texto rojo.",
},
filters: {
all_categories: "Todos",
visibility_all: "todos",
visibility_unlocked: "desbloqueados",
visibility_discovered: "descubiertos",
visibility_secret: "secretos",
},
share: {
dialog_label: "Compartir logro",
header: "Compartir: {name}",
close: "Cerrar",
rendering: "Renderizando…",
card_alt: "Tarjeta para compartir de {name}",
error_generic: "Algo salió mal.",
x_title: "Abre X con una publicación predefinida",
x_button: "Compartir en X",
copy_title: "Copia la imagen para pegarla en tu publicación",
copy_button: "Copiar imagen",
copied: "Copiado ✓",
download_button: "Descargar PNG",
hint:
"Compartir en X abre una publicación predefinida en una nueva pestaña. Haz clic primero en Copiar imagen si quieres adjuntar la insignia 1200×630: X te permite pegarla directamente en el redactor del tuit. Descargar PNG guarda el archivo para usarlo en cualquier lugar.",
clipboard_unsupported:
"Este navegador no admite copiar imágenes al portapapeles: usa Descargar en su lugar.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Cargando tablero Kanban…",
loadFailed: "Error al cargar el tablero Kanban: ",
loadFailedHint:
"El backend crea automáticamente kanban.db en la primera lectura. Si el problema persiste, revisa los registros del panel.",
board: "Tablero",
newBoard: "+ Nuevo tablero",
newBoardTitle: "Nuevo tablero",
newBoardDescription:
"Los tableros te permiten separar flujos de trabajo no relacionados — uno por proyecto, repositorio o dominio. Los workers de un tablero nunca ven las tareas de otro.",
slug: "Slug",
slugHint: "— minúsculas, guiones, p. ej. atm10-server",
displayName: "Nombre visible",
displayNameHint: "(opcional)",
description: "Descripción",
descriptionHint: "(opcional)",
icon: "Icono",
iconHint: "(un solo carácter o emoji)",
switchAfterCreate: "Cambiar a este tablero tras crearlo",
cancel: "Cancelar",
creating: "Creando…",
createBoard: "Crear tablero",
search: "Buscar",
filterCards: "Filtrar tarjetas…",
tenant: "Tenant",
allTenants: "Todos los tenants",
assignee: "Asignado a",
allProfiles: "Todos los perfiles",
showArchived: "Mostrar archivados",
lanesByProfile: "Carriles por perfil",
nudgeDispatcher: "Avisar al dispatcher",
refresh: "Actualizar",
selected: "seleccionado(s)",
complete: "Completar",
archive: "Archivar",
apply: "Aplicar",
clear: "Limpiar",
createTask: "Crear tarea en esta columna",
noTasks: "— sin tareas —",
unassigned: "sin asignar",
untitled: "(sin título)",
loadingDetail: "Cargando…",
addComment: "Añadir un comentario… (Enter para enviar)",
comment: "Comentario",
status: "Estado",
workspace: "Workspace",
skills: "Habilidades",
createdBy: "Creado por",
result: "Result",
comments: "Comentarios",
events: "Eventos",
runHistory: "Historial de ejecuciones",
workerLog: "Registro del worker",
loadingLog: "Cargando registro…",
noWorkerLog:
"— aún no hay registro del worker (la tarea no se ha lanzado o el registro fue rotado) —",
noDescription: "— sin descripción —",
noComments: "— sin comentarios —",
edit: "editar",
save: "Guardar",
dependencies: "Dependencias",
parents: "Padres:",
children: "Hijos:",
none: "ninguno",
addParent: "— añadir padre —",
addChild: "— añadir hijo —",
removeDependency: "Eliminar dependencia",
block: "Bloquear",
unblock: "Desbloquear",
notifyHomeChannels: "Notificar a los canales de inicio",
diagnostics: "Diagnósticos",
hide: "Ocultar",
show: "Mostrar",
attention: "Atención",
tasksNeedAttention: "tareas requieren atención",
taskNeedsAttention: "1 tarea requiere atención",
diagnostic: "diagnóstico",
open: "Abrir",
close: "Cerrar (Esc)",
reassignTo: "Reasignar a:",
copied: "Copiado",
copyCommand: "Copiar comando al portapapeles",
reclaim: "Recuperar",
reassign: "Reasignar",
renderingError: "La pestaña Kanban tuvo un error de renderizado",
reloadView: "Recargar vista",
wsAuthFailed:
"Error de autenticación de WebSocket — recarga la página para refrescar el token de sesión.",
markDone: "¿Marcar {n} tarea(s) como hechas?",
markArchived: "¿Archivar {n} tarea(s)?",
warning: "Advertencia",
phantomIds: "IDs fantasma:",
active: "activo",
ended: "finalizado",
noProfile: "(sin perfil)",
showAllAttempts: "Mostrar todos los intentos",
sendingUpdates: "Enviando actualizaciones a",
sendNotifications: "Enviar notificaciones de completed / blocked / gave_up a",
archiveBoardConfirm:
"¿Archivar el tablero '{name}'? Se moverá a boards/_archived/ para que puedas recuperarlo más tarde. Las tareas de este tablero ya no aparecerán en ninguna parte de la UI.",
archiveBoardTitle: "Archivar este tablero",
boardSwitcherHint: "Los tableros te permiten separar flujos de trabajo no relacionados",
taskCreatedWarning: "Tarea creada, pero: ",
moveFailed: "Error al mover: ",
bulkFailed: "Lote: ",
completionBlockedHallucination: "⚠ Completado bloqueado — IDs de tarjeta fantasma",
suspectedHallucinatedReferences: "⚠ El texto referenció IDs de tarjeta fantasma",
pickProfileFirst: "Elige primero un perfil.",
unblockedMessage: "Desbloqueado {id}. La tarea está lista para el próximo tick.",
unblockFailed: "Error al desbloquear: ",
reclaimedMessage: "Recuperado {id}. La tarea vuelve a estar lista.",
reclaimFailed: "Error al recuperar: ",
reassignedMessage: "Reasignado {id} a {profile}.",
reassignFailed: "Error al reasignar: ",
selectForBulk: "Seleccionar para acciones por lotes",
clickToEdit: "Haz clic para editar",
clickToEditAssignee: "Haz clic para editar el asignado",
emptyAssignee: "(vacío = sin asignar)",
columnLabels: {
triage: "Clasificación",
todo: "Por hacer",
scheduled: "Programado",
ready: "Listo",
running: "En curso",
blocked: "Bloqueado",
done: "Hecho",
archived: "Archivado",
},
columnHelp: {
triage: "Ideas en bruto — un specifier desarrollará la especificación",
todo: "Esperando dependencias o sin asignar",
scheduled: "Esperando un retraso conocido o un seguimiento programado",
ready: "Dependencias satisfechas; asigna un perfil para despachar",
running: "Reclamado por un worker — en ejecución",
blocked: "El worker pidió intervención humana",
done: "Completado",
archived: "Archivado",
},
confirmDone:
"¿Marcar esta tarea como hecha? Se libera el reclamo del worker y los hijos dependientes pasan a estar listos.",
confirmArchive:
"¿Archivar esta tarea? Desaparecerá de la vista por defecto del tablero.",
confirmBlocked:
"¿Marcar esta tarea como bloqueada? Se libera el reclamo del worker.",
completionSummary:
"Resumen de finalización para {label}. Se almacena como el result de la tarea.",
completionSummaryRequired:
"El resumen de finalización es obligatorio antes de marcar una tarea como hecha.",
triagePlaceholder: "Idea aproximada — la IA la especificará…",
taskTitlePlaceholder: "Título de la nueva tarea…",
specifier: "specifier",
assigneePlaceholder: "asignado",
priority: "Prioridad",
skillsPlaceholder:
"habilidades (opcional, separadas por comas): translation, github-code-review",
noParent: "— sin padre —",
workspacePathDir: "ruta del workspace (obligatoria, p. ej. ~/projects/my-app)",
workspacePathOptional:
"ruta del workspace (opcional, derivada del asignado si está vacía)",
logTruncated: "(mostrando los últimos 100 KB — registro completo en ",
logAt: ")",
},
};
+701
View File
@@ -0,0 +1,701 @@
import type { Translations } from "./types";
export const fr: Translations = {
common: {
save: "Enregistrer",
saving: "Enregistrement...",
cancel: "Annuler",
close: "Fermer",
confirm: "Confirmer",
delete: "Supprimer",
refresh: "Actualiser",
retry: "Réessayer",
search: "Rechercher...",
loading: "Chargement...",
create: "Créer",
creating: "Création...",
set: "Définir",
replace: "Remplacer",
clear: "Effacer",
live: "En direct",
off: "Désactivé",
enabled: "activé",
disabled: "désactivé",
active: "actif",
inactive: "inactif",
unknown: "inconnu",
untitled: "Sans titre",
none: "Aucun",
form: "Formulaire",
noResults: "Aucun résultat",
of: "sur",
page: "Page",
msgs: "msgs",
tools: "outils",
match: "correspondance",
other: "Autre",
configured: "configuré",
removed: "supprimé",
failedToToggle: "Échec du basculement",
failedToRemove: "Échec de la suppression",
failedToReveal: "Échec de l'affichage",
collapse: "Réduire",
expand: "Développer",
general: "Général",
messaging: "Messagerie",
pluginLoadFailed:
"Impossible de charger le script de ce plugin. Vérifiez l'onglet Réseau (dashboard-plugins/…) et le chemin des plugins du serveur.",
pluginNotRegistered:
"Le script du plugin n'a pas appelé register(), ou le script a échoué. Ouvrez la console du navigateur pour plus de détails.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Fermer la navigation",
closeModelTools: "Fermer modèle et outils",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Sessions actives:",
gatewayStatusLabel: "État de la passerelle:",
gatewayStrip: {
failed: "Échec du démarrage",
off: "Désactivé",
running: "En cours",
starting: "Démarrage",
stopped: "Arrêté",
},
nav: {
analytics: "Analyses",
chat: "Chat",
config: "Configuration",
cron: "Cron",
documentation: "Documentation",
keys: "Clés",
logs: "Journaux",
models: "Modèles",
profiles: "profils : multi agents",
plugins: "Plugins",
sessions: "Sessions",
skills: "Compétences",
},
modelToolsSheetSubtitle: "& outils",
modelToolsSheetTitle: "Modèle",
navigation: "Navigation",
openDocumentation: "Ouvrir la documentation dans un nouvel onglet",
openNavigation: "Ouvrir la navigation",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} actives",
statusOverview: "Aperçu de l'état",
system: "Système",
webUi: "Web UI",
},
status: {
actionFailed: "Action échouée",
actionFinished: "Terminé",
actions: "Actions",
agent: "Agent",
activeSessions: "Sessions actives",
connected: "Connecté",
connectedPlatforms: "Plateformes connectées",
disconnected: "Déconnecté",
error: "Erreur",
failed: "Échec",
gateway: "Passerelle",
gatewayFailedToStart: "Le démarrage de la passerelle a échoué",
lastUpdate: "Dernière mise à jour",
noneRunning: "Aucun",
notRunning: "Non lancé",
pid: "PID",
platformDisconnected: "déconnecté",
platformError: "erreur",
recentSessions: "Sessions récentes",
restartGateway: "Redémarrer la passerelle",
restartingGateway: "Redémarrage de la passerelle…",
running: "En cours",
runningRemote: "En cours (distant)",
startFailed: "Échec du démarrage",
starting: "Démarrage",
startedInBackground: "Démarré en arrière-plan — consultez les journaux pour la progression",
stopped: "Arrêté",
updateHermes: "Mettre à jour Hermes",
updatingHermes: "Mise à jour de Hermes…",
waitingForOutput: "En attente de la sortie…",
},
sessions: {
title: "Sessions",
history: "Historique",
overview: "Aperçu",
searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour l'instant",
noMatch: "Aucune session ne correspond à votre recherche",
startConversation: "Démarrez une conversation pour la voir ici",
noMessages: "Aucun message",
untitledSession: "Session sans titre",
deleteSession: "Supprimer la session",
confirmDeleteTitle: "Supprimer la session ?",
confirmDeleteMessage:
"Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.",
sessionDeleted: "Session supprimée",
failedToDelete: "Échec de la suppression de la session",
resumeInChat: "Reprendre dans le chat",
previousPage: "Page précédente",
nextPage: "Page suivante",
roles: {
user: "Utilisateur",
assistant: "Assistant",
system: "Système",
tool: "Outil",
},
},
analytics: {
period: "Période:",
totalTokens: "Tokens totaux",
totalSessions: "Sessions totales",
apiCalls: "Appels API",
dailyTokenUsage: "Utilisation quotidienne des tokens",
dailyBreakdown: "Détail quotidien",
perModelBreakdown: "Détail par modèle",
topSkills: "Compétences les plus utilisées",
skill: "Compétence",
loads: "Agent chargé",
edits: "Agent géré",
lastUsed: "Dernière utilisation",
input: "Entrée",
output: "Sortie",
total: "Total",
noUsageData: "Aucune donnée d'utilisation pour cette période",
startSession: "Démarrez une session pour voir les analyses ici",
date: "Date",
model: "Modèle",
tokens: "Tokens",
perDayAvg: "/jour moy",
acrossModels: "sur {count} modèles",
inOut: "{input} entrée / {output} sortie",
},
models: {
modelsUsed: "Modèles utilisés",
estimatedCost: "Coût est.",
tokens: "tokens",
sessions: "sessions",
avgPerSession: "moy/session",
apiCalls: "appels API",
toolCalls: "appels d'outil",
noModelsData: "Aucune donnée de modèle pour cette période",
startSession: "Démarrez une session pour voir les données de modèle ici",
},
logs: {
title: "Journaux",
autoRefresh: "Actualisation auto",
file: "Fichier",
level: "Niveau",
component: "Composant",
lines: "Lignes",
noLogLines: "Aucune ligne de journal trouvée",
},
cron: {
confirmDeleteMessage:
"Cela supprime la tâche du planning. Cette action est irréversible.",
confirmDeleteTitle: "Supprimer la tâche planifiée ?",
newJob: "Nouvelle tâche cron",
nameOptional: "Nom (facultatif)",
namePlaceholder: "ex. Résumé quotidien",
prompt: "Invite",
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planning (expression cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",
last: "Dernière",
next: "Prochaine",
pause: "Pause",
resume: "Reprendre",
triggerNow: "Déclencher maintenant",
delivery: {
local: "Local",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Nouveau profil",
name: "Nom",
namePlaceholder: "ex. coder, writer, etc.",
nameRequired: "Le nom est requis",
nameRule:
"Lettres minuscules, chiffres, _ et - uniquement ; doit commencer par une lettre ou un chiffre ; jusqu'à 64 caractères.",
invalidName: "Nom de profil invalide",
cloneFromDefault: "Cloner la configuration du profil par défaut",
allProfiles: "Profils",
noProfiles: "Aucun profil trouvé.",
defaultBadge: "défaut",
hasEnv: "env",
model: "Modèle",
skills: "Compétences",
rename: "Renommer",
editSoul: "Modifier SOUL.md",
soulSection: "SOUL.md (personnalité / invite système)",
soulPlaceholder: "# Comment cet agent doit se comporter…",
saveSoul: "Enregistrer SOUL",
soulSaved: "SOUL.md enregistré",
openInTerminal: "Copier la commande CLI",
commandCopied: "Copié dans le presse-papiers",
copyFailed: "Impossible de copier",
confirmDeleteTitle: "Supprimer le profil ?",
confirmDeleteMessage:
"Cela supprime définitivement le profil '{name}' — configuration, clés, mémoires, sessions, compétences, tâches cron. Action irréversible.",
created: "Créé",
deleted: "Supprimé",
renamed: "Renommé",
},
pluginsPage: {
contextEngineLabel: "Moteur de contexte",
dashboardSlots: "Emplacements du tableau de bord",
disableRuntime: "Désactiver",
enableAfterInstall: "Activer après l'installation",
enableRuntime: "Activer",
forceReinstall: "Forcer la réinstallation (supprimer d'abord le dossier existant)",
headline:
"Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo",
inactive: "inactif",
installBtn: "Installer",
installHeading: "Installer depuis GitHub / URL Git",
installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.",
memoryProviderLabel: "Fournisseur de mémoire",
missingEnvWarn: "Définissez ces variables dans Clés avant que le plugin puisse s'exécuter:",
noDashboardTab: "Aucun onglet de tableau de bord",
openTab: "Ouvrir",
orphanHeading: "Extensions du tableau de bord uniquement (aucune correspondance plugin.yaml d'agent)",
pluginListHeading: "Plugins installés",
providerDefaults: "intégré / par défaut",
providersHeading: "Plugins fournisseurs d'exécution",
providersHint:
"Écrit memory.provider (vide = intégré) et context.engine dans config.yaml. Prend effet à la prochaine session.",
refreshDashboard: "Re-scanner les extensions du tableau de bord",
removeConfirm: "Retirer ce plugin de ~/.hermes/plugins/ ?",
removeHint: "Seuls les plugins installés par l'utilisateur sous ~/.hermes/plugins peuvent être supprimés.",
rescanHeading: "Registre des plugins SPA",
rescanHint: "Re-scannez après avoir ajouté des fichiers sur le disque pour que la barre latérale prenne en compte les nouveaux manifestes.",
runtimeHeading: "Exécution de la passerelle (plugins YAML)",
saveProviders: "Enregistrer les paramètres de fournisseur",
savedProviders: "Paramètres de fournisseur enregistrés.",
sourceBadge: "Source",
authRequired: "Authentification requise",
authRequiredHint: "Exécutez cette commande pour vous authentifier:",
updateGit: "Git pull",
versionBadge: "Version",
showInSidebar: "Afficher dans la barre latérale",
hideFromSidebar: "Masquer de la barre latérale",
},
skills: {
title: "Compétences",
searchPlaceholder: "Rechercher des compétences et des outils...",
enabledOf: "{enabled}/{total} activées",
all: "Toutes",
categories: "Catégories",
filters: "Filtres",
noSkills: "Aucune compétence trouvée. Les compétences sont chargées depuis ~/.hermes/skills/",
noSkillsMatch: "Aucune compétence ne correspond à votre recherche ou filtre.",
skillCount: "{count} compétence{s}",
resultCount: "{count} résultat{s}",
noDescription: "Aucune description disponible.",
toolsets: "Ensembles d'outils",
toolsetLabel: "Ensemble d'outils {name}",
noToolsetsMatch: "Aucun ensemble d'outils ne correspond à la recherche.",
setupNeeded: "Configuration nécessaire",
disabledForCli: "Désactivé pour CLI",
more: "+{count} de plus",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filtres",
sections: "Sections",
exportConfig: "Exporter la configuration en JSON",
importConfig: "Importer la configuration depuis JSON",
resetDefaults: "Réinitialiser aux valeurs par défaut",
resetScopeTooltip: "Réinitialiser {scope} aux valeurs par défaut",
confirmResetScope: "Réinitialiser tous les paramètres de {scope} aux valeurs par défaut ? Cela ne met à jour que le formulaire — les modifications ne sont écrites dans config.yaml qu'après avoir appuyé sur Enregistrer.",
resetScopeToast: "{scope} réinitialisé aux valeurs par défaut — vérifiez et enregistrez pour conserver",
rawYaml: "Configuration YAML brute",
searchResults: "Résultats de recherche",
fields: "champ{s}",
noFieldsMatch: 'Aucun champ ne correspond à "{query}"',
configSaved: "Configuration enregistrée",
yamlConfigSaved: "Configuration YAML enregistrée",
failedToSave: "Échec de l'enregistrement",
failedToSaveYaml: "Échec de l'enregistrement YAML",
failedToLoadRaw: "Échec du chargement de la configuration brute",
configImported: "Configuration importée — vérifiez et enregistrez",
invalidJson: "Fichier JSON invalide",
categories: {
general: "Général",
agent: "Agent",
terminal: "Terminal",
display: "Affichage",
delegation: "Délégation",
memory: "Mémoire",
compression: "Compression",
security: "Sécurité",
browser: "Navigateur",
voice: "Voix",
tts: "Synthèse vocale",
stt: "Reconnaissance vocale",
logging: "Journalisation",
discord: "Discord",
auxiliary: "Auxiliaire",
},
},
env: {
changesNote: "Les modifications sont enregistrées sur le disque immédiatement. Les sessions actives récupèrent les nouvelles clés automatiquement.",
confirmClearMessage:
"La valeur stockée pour cette variable sera supprimée de votre fichier .env. Cette action ne peut pas être annulée depuis l'interface.",
confirmClearTitle: "Effacer cette clé ?",
description: "Gérer les clés API et les secrets stockés dans",
hideAdvanced: "Masquer les options avancées",
showAdvanced: "Afficher les options avancées",
showLess: "Afficher moins",
showMore: "Afficher plus",
llmProviders: "Fournisseurs LLM",
providersConfigured: "{configured} sur {total} fournisseurs configurés",
getKey: "Obtenir la clé",
notConfigured: "{count} non configuré",
notSet: "Non défini",
keysCount: "{count} clé{s}",
enterValue: "Saisir une valeur...",
replaceCurrentValue: "Remplacer la valeur actuelle ({preview})",
showValue: "Afficher la valeur réelle",
hideValue: "Masquer la valeur",
},
oauth: {
title: "Connexions fournisseurs (OAuth)",
providerLogins: "Connexions fournisseurs (OAuth)",
description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.",
connected: "Connecté",
expired: "Expiré",
notConnected: "Non connecté. Exécutez {command} dans un terminal.",
runInTerminal: "dans un terminal.",
noProviders: "Aucun fournisseur compatible OAuth détecté.",
login: "Connexion",
disconnect: "Déconnecter",
managedExternally: "Géré en externe",
copied: "Copié ✓",
cli: "Copier",
copyCliCommand: "Copier la commande CLI (pour externe / repli)",
connect: "Connecter",
sessionExpires: "La session expire dans {time}",
initiatingLogin: "Lancement du flux de connexion…",
exchangingCode: "Échange du code contre des jetons…",
connectedClosing: "Connecté ! Fermeture…",
loginFailed: "Échec de la connexion.",
sessionExpired: "Session expirée. Cliquez sur Réessayer pour démarrer une nouvelle connexion.",
reOpenAuth: "Rouvrir la page d'authentification",
reOpenVerification: "Rouvrir la page de vérification",
submitCode: "Soumettre le code",
pasteCode: "Collez le code d'autorisation (avec suffixe #state accepté)",
waitingAuth: "En attente de votre autorisation dans le navigateur…",
enterCodePrompt: "Un nouvel onglet s'est ouvert. Saisissez ce code si demandé:",
pkceStep1: "Un nouvel onglet s'est ouvert vers claude.ai. Connectez-vous et cliquez sur Autoriser.",
pkceStep2: "Copiez le code d'autorisation affiché après autorisation.",
pkceStep3: "Collez-le ci-dessous et soumettez.",
flowLabels: {
pkce: "Connexion navigateur (PKCE)",
device_code: "Code d'appareil",
external: "CLI externe",
},
expiresIn: "expire dans {time}",
},
language: {
switchTo: "Changer de langue",
},
theme: {
title: "Thème",
switchTheme: "Changer de thème",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Badges Hermes à collectionner, gagnés à partir de l'historique réel des sessions. Les succès connus non terminés sont affichés comme Découverts ; les succès secrets restent cachés jusqu'à l'apparition du premier comportement correspondant.",
scan_subtitle:
"Analyse de l'historique des sessions Hermes en cours. Le premier scan peut prendre 5 à 10 secondes sur les historiques volumineux.",
},
actions: {
rescan: "Relancer le scan",
},
stats: {
unlocked: "Débloqués",
unlocked_hint: "badges obtenus",
discovered: "Découverts",
discovered_hint: "connus, pas encore obtenus",
secrets: "Secrets",
secrets_hint: "cachés jusqu'au premier signal",
highest_tier: "Niveau le plus élevé",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Dernier",
latest_hint_empty: "utilisez Hermes davantage",
none_yet: "Aucun pour l'instant",
},
state: {
unlocked: "Débloqué",
discovered: "Découvert",
secret: "Secret",
},
tier: {
target: "Cible {tier}",
hidden: "Caché",
complete: "Terminé",
objective: "Objectif",
},
progress: {
hidden: "caché",
},
scan: {
building_headline: "Création du profil de succès…",
building_detail:
"Lecture des sessions, des appels d'outils, des métadonnées du modèle et de l'état de déblocage.",
starting_headline: "Démarrage du scan des succès…",
progress_detail:
"{scanned} sessions analysées sur {total} · {pct}%. Les badges se débloquent à mesure que l'historique est traité.",
idle_detail:
"Lecture des sessions, des appels d'outils, des métadonnées du modèle et de l'état de déblocage. Les badges apparaissent ici à mesure qu'ils se débloquent.",
},
guide: {
tiers_header: "Niveaux",
secret_header: "Succès secrets",
secret_body:
"Les secrets cachent leur déclencheur exact. Dès qu'Hermes détecte un signal lié, la carte passe à Découvert et affiche son exigence.",
scan_status_header: "État du scan",
scan_status_body:
"Hermes analyse l'historique local une seule fois, puis les cartes apparaîtront automatiquement. Rien n'est bloqué si cela prend quelques secondes.",
what_scanned_header: "Ce qui est analysé",
what_scanned_body:
"Sessions, appels d'outils, métadonnées du modèle, erreurs, succès et état de déblocage local.",
},
card: {
share_title: "Partager ce succès",
share_label: "Partager {name}",
share_text: "Partager",
how_to_reveal: "Comment le révéler",
what_counts: "Ce qui compte",
evidence_label: "Preuve",
evidence_session_fallback: "session",
no_evidence: "Pas encore de preuve",
},
latest: {
header: "Déblocages récents",
},
empty: {
no_secrets_header: "Plus aucun secret caché dans ce scan.",
no_secrets_body:
"Indice: les secrets démarrent généralement à partir d'échecs inhabituels ou de schémas d'utilisateurs avancés — conflits de ports, murs de permissions, variables d'environnement manquantes, erreurs YAML, collisions Docker, utilisation de rollback/checkpoint, succès de cache ou petits correctifs après beaucoup de texte rouge.",
},
filters: {
all_categories: "Tous",
visibility_all: "tous",
visibility_unlocked: "débloqués",
visibility_discovered: "découverts",
visibility_secret: "secrets",
},
share: {
dialog_label: "Partager le succès",
header: "Partager: {name}",
close: "Fermer",
rendering: "Rendu en cours…",
card_alt: "Carte de partage {name}",
error_generic: "Une erreur s'est produite.",
x_title: "Ouvre X avec une publication préremplie",
x_button: "Partager sur X",
copy_title: "Copiez l'image pour la coller dans votre publication",
copy_button: "Copier l'image",
copied: "Copié ✓",
download_button: "Télécharger le PNG",
hint:
"Partager sur X ouvre une publication préremplie dans un nouvel onglet. Cliquez d'abord sur Copier l'image si vous voulez joindre le badge 1200×630 — X vous laisse le coller directement dans l'éditeur de tweet. Télécharger le PNG enregistre le fichier pour l'utiliser n'importe où.",
clipboard_unsupported:
"La copie d'image dans le presse-papiers n'est pas prise en charge par ce navigateur — utilisez Télécharger à la place.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Chargement du tableau Kanban…",
loadFailed: "Échec du chargement du tableau Kanban: ",
loadFailedHint:
"Le backend crée automatiquement kanban.db à la première lecture. Si le problème persiste, consultez les logs du dashboard.",
board: "Tableau",
newBoard: "+ Nouveau tableau",
newBoardTitle: "Nouveau tableau",
newBoardDescription:
"Les tableaux vous permettent de séparer des flux de travail indépendants — un par projet, dépôt ou domaine. Les workers d'un tableau ne voient jamais les tâches d'un autre.",
slug: "Slug",
slugHint: "— minuscules, tirets, par ex. atm10-server",
displayName: "Nom affiché",
displayNameHint: "(facultatif)",
description: "Description",
descriptionHint: "(facultatif)",
icon: "Icône",
iconHint: "(un seul caractère ou emoji)",
switchAfterCreate: "Basculer sur ce tableau après l'avoir créé",
cancel: "Annuler",
creating: "Création…",
createBoard: "Créer le tableau",
search: "Rechercher",
filterCards: "Filtrer les cartes…",
tenant: "Tenant",
allTenants: "Tous les tenants",
assignee: "Assigné à",
allProfiles: "Tous les profils",
showArchived: "Afficher les archivés",
lanesByProfile: "Couloirs par profil",
nudgeDispatcher: "Solliciter le dispatcher",
refresh: "Rafraîchir",
selected: "sélectionné(s)",
complete: "Terminer",
archive: "Archiver",
apply: "Appliquer",
clear: "Effacer",
createTask: "Créer une tâche dans cette colonne",
noTasks: "— aucune tâche —",
unassigned: "non assigné",
untitled: "(sans titre)",
loadingDetail: "Chargement…",
addComment: "Ajouter un commentaire… (Enter pour envoyer)",
comment: "Commentaire",
status: "Statut",
workspace: "Workspace",
skills: "Compétences",
createdBy: "Créé par",
result: "Result",
comments: "Commentaires",
events: "Événements",
runHistory: "Historique d'exécution",
workerLog: "Log du worker",
loadingLog: "Chargement du log…",
noWorkerLog:
"— pas encore de log du worker (la tâche n'a pas démarré ou le log a été effacé par rotation) —",
noDescription: "— aucune description —",
noComments: "— aucun commentaire —",
edit: "modifier",
save: "Enregistrer",
dependencies: "Dépendances",
parents: "Parents:",
children: "Enfants:",
none: "aucun",
addParent: "— ajouter un parent —",
addChild: "— ajouter un enfant —",
removeDependency: "Supprimer la dépendance",
block: "Bloquer",
unblock: "Débloquer",
notifyHomeChannels: "Notifier les canaux home",
diagnostics: "Diagnostics",
hide: "Masquer",
show: "Afficher",
attention: "Attention",
tasksNeedAttention: "tâches nécessitent une attention",
taskNeedsAttention: "1 tâche nécessite une attention",
diagnostic: "diagnostic",
open: "Ouvrir",
close: "Fermer (Esc)",
reassignTo: "Réassigner à:",
copied: "Copié",
copyCommand: "Copier la commande dans le presse-papiers",
reclaim: "Récupérer",
reassign: "Réassigner",
renderingError: "L'onglet Kanban a rencontré une erreur de rendu",
reloadView: "Recharger la vue",
wsAuthFailed:
"Échec d'authentification WebSocket — rechargez la page pour rafraîchir le jeton de session.",
markDone: "Marquer {n} tâche(s) comme terminée(s) ?",
markArchived: "Archiver {n} tâche(s) ?",
warning: "Avertissement",
phantomIds: "IDs fantômes:",
active: "actif",
ended: "terminé",
noProfile: "(aucun profil)",
showAllAttempts: "Afficher toutes les tentatives",
sendingUpdates: "Envoi des mises à jour à",
sendNotifications: "Envoyer les notifications completed / blocked / gave_up à",
archiveBoardConfirm:
"Archiver le tableau '{name}' ? Il sera déplacé vers boards/_archived/ pour pouvoir être récupéré plus tard. Les tâches de ce tableau n'apparaîtront plus nulle part dans l'UI.",
archiveBoardTitle: "Archiver ce tableau",
boardSwitcherHint: "Les tableaux vous permettent de séparer des flux de travail indépendants",
taskCreatedWarning: "Tâche créée, mais: ",
moveFailed: "Échec du déplacement: ",
bulkFailed: "Lot: ",
completionBlockedHallucination: "⚠ Achèvement bloqué — IDs de carte fantômes",
suspectedHallucinatedReferences: "⚠ Le texte a référencé des IDs de carte fantômes",
pickProfileFirst: "Choisissez d'abord un profil.",
unblockedMessage: "Débloqué {id}. La tâche est prête pour le prochain tick.",
unblockFailed: "Échec du déblocage: ",
reclaimedMessage: "Récupéré {id}. La tâche est de nouveau prête.",
reclaimFailed: "Échec de la récupération: ",
reassignedMessage: "Réassigné {id} à {profile}.",
reassignFailed: "Échec de la réassignation: ",
selectForBulk: "Sélectionner pour des actions groupées",
clickToEdit: "Cliquez pour modifier",
clickToEditAssignee: "Cliquez pour modifier l'assigné",
emptyAssignee: "(vide = désassigner)",
columnLabels: {
triage: "Triage",
todo: "À faire",
scheduled: "Planifié",
ready: "Prêt",
running: "En cours",
blocked: "Bloqué",
done: "Terminé",
archived: "Archivé",
},
columnHelp: {
triage: "Idées brutes — un specifier rédigera la spécification",
todo: "En attente de dépendances ou non assigné",
scheduled: "En attente d'un délai connu ou d'un suivi planifié",
ready: "Dépendances satisfaites ; assignez un profil pour dispatch",
running: "Réclamé par un worker — en cours d'exécution",
blocked: "Le worker a demandé une intervention humaine",
done: "Terminé",
archived: "Archivé",
},
confirmDone:
"Marquer cette tâche comme terminée ? La revendication du worker est libérée et les enfants dépendants deviennent prêts.",
confirmArchive:
"Archiver cette tâche ? Elle disparaîtra de la vue par défaut du tableau.",
confirmBlocked:
"Marquer cette tâche comme bloquée ? La revendication du worker est libérée.",
completionSummary:
"Résumé d'achèvement pour {label}. Stocké comme result de la tâche.",
completionSummaryRequired:
"Un résumé d'achèvement est requis avant de marquer une tâche comme terminée.",
triagePlaceholder: "Idée approximative — l'IA la spécifiera…",
taskTitlePlaceholder: "Titre de la nouvelle tâche…",
specifier: "specifier",
assigneePlaceholder: "assigné",
priority: "Priorité",
skillsPlaceholder:
"compétences (facultatif, séparées par virgules): translation, github-code-review",
noParent: "— aucun parent —",
workspacePathDir: "chemin du workspace (requis, par ex. ~/projects/my-app)",
workspacePathOptional:
"chemin du workspace (facultatif, dérivé de l'assigné si vide)",
logTruncated: "(affichage des derniers 100 KB — log complet à ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const ga: Translations = {
common: {
save: "Sábháil",
saving: "Á shábháil...",
cancel: "Cealaigh",
close: "Dún",
confirm: "Deimhnigh",
delete: "Scrios",
refresh: "Athnuaigh",
retry: "Bain triail eile as",
search: "Cuardaigh...",
loading: "Á luchtú...",
create: "Cruthaigh",
creating: "Á chruthú...",
set: "Socraigh",
replace: "Athchuir",
clear: "Glan",
live: "Beo",
off: "As",
enabled: "cumasaithe",
disabled: "díchumasaithe",
active: "gníomhach",
inactive: "neamhghníomhach",
unknown: "anaithnid",
untitled: "Gan teideal",
none: "Aon cheann",
form: "Foirm",
noResults: "Aon toradh",
of: "as",
page: "Leathanach",
msgs: "tcht",
tools: "uirlisí",
match: "meaitseáil",
other: "Eile",
configured: "cumraithe",
removed: "bainte",
failedToToggle: "Theip ar an scoránú",
failedToRemove: "Theip ar an mbaint",
failedToReveal: "Theip ar an taispeáint",
collapse: "Laghdaigh",
expand: "Leathnaigh",
general: "Ginearálta",
messaging: "Teachtaireachtaí",
pluginLoadFailed:
"Níorbh fhéidir script an plugin seo a luchtú. Seiceáil an cluaisín Network (dashboard-plugins/…) agus conair plugin an fhreastalaí.",
pluginNotRegistered:
"Níor ghlaoigh script an plugin ar register(), nó tharla earráid sa script. Oscail consól an bhrabhsálaí le haghaidh sonraí.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Dún an nascleanúint",
closeModelTools: "Dún an samhail agus na huirlisí",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Seisiúin gníomhacha:",
gatewayStatusLabel: "Stádas an gateway:",
gatewayStrip: {
failed: "Theip ar an tús",
off: "As",
running: "Ag rith",
starting: "Ag tosú",
stopped: "Stoptha",
},
nav: {
analytics: "Anailís",
chat: "Comhrá",
config: "Cumraíocht",
cron: "Cron",
documentation: "Doiciméadú",
keys: "Eochracha",
logs: "Logaí",
models: "Samhlacha",
profiles: "próifílí : il-agents",
plugins: "Plugins",
sessions: "Seisiúin",
skills: "Scileanna",
},
modelToolsSheetSubtitle: "agus uirlisí",
modelToolsSheetTitle: "Samhail",
navigation: "Nascleanúint",
openDocumentation: "Oscail an doiciméadú i gcluaisín nua",
openNavigation: "Oscail an nascleanúint",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} gníomhach",
statusOverview: "Forbhreathnú stádais",
system: "Córas",
webUi: "Web UI",
},
status: {
actionFailed: "Theip ar an ngníomh",
actionFinished: "Críochnaithe",
actions: "Gníomhartha",
agent: "Agent",
activeSessions: "Seisiúin ghníomhacha",
connected: "Ceangailte",
connectedPlatforms: "Ardáin cheangailte",
disconnected: "Dícheangailte",
error: "Earráid",
failed: "Theip",
gateway: "Gateway",
gatewayFailedToStart: "Theip ar an gateway tosú",
lastUpdate: "Nuashonrú deireanach",
noneRunning: "Aon cheann",
notRunning: "Níl ag rith",
pid: "PID",
platformDisconnected: "dícheangailte",
platformError: "earráid",
recentSessions: "Seisiúin le déanaí",
restartGateway: "Atosaigh an gateway",
restartingGateway: "Ag atosú an gateway…",
running: "Ag rith",
runningRemote: "Ag rith (cianda)",
startFailed: "Theip ar an tús",
starting: "Ag tosú",
startedInBackground: "Tosaithe sa chúlra — seiceáil na logaí le haghaidh dul chun cinn",
stopped: "Stoptha",
updateHermes: "Nuashonraigh Hermes",
updatingHermes: "Ag nuashonrú Hermes…",
waitingForOutput: "Ag fanacht le haschur…",
},
sessions: {
title: "Seisiúin",
history: "Stair",
overview: "Forbhreathnú",
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
noSessions: "Gan seisiúin go fóill",
noMatch: "Níl seisiún ar bith ag teacht le do chuardach",
startConversation: "Tosaigh comhrá chun é a fheiceáil anseo",
noMessages: "Gan teachtaireachtaí",
untitledSession: "Seisiún gan teideal",
deleteSession: "Scrios an seisiún",
confirmDeleteTitle: "Scrios an seisiún?",
confirmDeleteMessage:
"Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.",
sessionDeleted: "Seisiún scriosta",
failedToDelete: "Theip ar scriosadh an tseisiúin",
resumeInChat: "Lean ar aghaidh sa chomhrá",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
roles: {
user: "Úsáideoir",
assistant: "Cúntóir",
system: "Córas",
tool: "Uirlis",
},
},
analytics: {
period: "Tréimhse:",
totalTokens: "Tokens iomlána",
totalSessions: "Seisiúin iomlána",
apiCalls: "Glaonna API",
dailyTokenUsage: "Úsáid laethúil tokens",
dailyBreakdown: "Miondealú laethúil",
perModelBreakdown: "Miondealú de réir samhla",
topSkills: "Príomhscileanna",
skill: "Scil",
loads: "Luchtaithe ag an Agent",
edits: "Bainistithe ag an Agent",
lastUsed: "Úsáidte go deireanach",
input: "Ionchur",
output: "Aschur",
total: "Iomlán",
noUsageData: "Gan sonraí úsáide don tréimhse seo",
startSession: "Tosaigh seisiún chun anailís a fheiceáil anseo",
date: "Dáta",
model: "Samhail",
tokens: "Tokens",
perDayAvg: "/lá meán",
acrossModels: "thar {count} samhail",
inOut: "{input} isteach / {output} amach",
},
models: {
modelsUsed: "Samhlacha úsáidte",
estimatedCost: "Costas measta",
tokens: "tokens",
sessions: "seisiúin",
avgPerSession: "meán/seisiún",
apiCalls: "glaonna API",
toolCalls: "glaonna uirlise",
noModelsData: "Gan sonraí úsáide samhla don tréimhse seo",
startSession: "Tosaigh seisiún chun sonraí samhla a fheiceáil anseo",
},
logs: {
title: "Logaí",
autoRefresh: "Athnuachan uathoibríoch",
file: "Comhad",
level: "Leibhéal",
component: "Comhpháirt",
lines: "Línte",
noLogLines: "Níor aimsíodh línte loga",
},
cron: {
confirmDeleteMessage:
"Baineann sé seo an post ón sceideal. Ní féidir é seo a chealú.",
confirmDeleteTitle: "Scrios an post sceidealta?",
newJob: "Post Cron Nua",
nameOptional: "Ainm (roghnach)",
namePlaceholder: "m.sh. Achoimre laethúil",
prompt: "Prompt",
promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?",
schedule: "Sceideal (slonn cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Seachadadh chuig",
scheduledJobs: "Poist sceidealta",
noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.",
last: "Deireanach",
next: "Ar aghaidh",
pause: "Sos",
resume: "Lean ar aghaidh",
triggerNow: "Spreag anois",
delivery: {
local: "Áitiúil",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Próifíl Nua",
name: "Ainm",
namePlaceholder: "m.sh. coder, writer, srl.",
nameRequired: "Tá ainm riachtanach",
nameRule:
"Litreacha cás íochtair, digití, _ agus - amháin; caithfidh tús a chur le litir nó digit; suas le 64 carachtar.",
invalidName: "Ainm próifíle neamhbhailí",
cloneFromDefault: "Clónáil cumraíocht ón bpróifíl réamhshocraithe",
allProfiles: "Próifílí",
noProfiles: "Níor aimsíodh próifílí.",
defaultBadge: "réamhshocraithe",
hasEnv: "env",
model: "Samhail",
skills: "Scileanna",
rename: "Athainmnigh",
editSoul: "Cuir SOUL.md in eagar",
soulSection: "SOUL.md (pearsantacht / prompt córais)",
soulPlaceholder: "# Conas ba chóir don agent seo iompar…",
saveSoul: "Sábháil SOUL",
soulSaved: "SOUL.md sábháilte",
openInTerminal: "Cóipeáil ordú CLI",
commandCopied: "Cóipeáilte chuig an ngearrthaisce",
copyFailed: "Níorbh fhéidir cóipeáil",
confirmDeleteTitle: "Scrios an phróifíl?",
confirmDeleteMessage:
"Scriosann sé seo an phróifíl '{name}' go buan — cumraíocht, eochracha, cuimhní, seisiúin, scileanna, poist cron. Ní féidir é a chealú.",
created: "Cruthaithe",
deleted: "Scriosta",
renamed: "Athainmnithe",
},
pluginsPage: {
contextEngineLabel: "Inneall comhthéacs",
dashboardSlots: "Slots an dashboard",
disableRuntime: "Díchumasaigh",
enableAfterInstall: "Cumasaigh tar éis suiteála",
enableRuntime: "Cumasaigh",
forceReinstall: "Cuir iallach ar athshuiteáil (scrios an fillteán atá ann ar dtús)",
headline:
"Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).",
identifierLabel: "URL Git nó owner/repo",
inactive: "neamhghníomhach",
installBtn: "Suiteáil",
installHeading: "Suiteáil ó GitHub / URL Git",
installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.",
memoryProviderLabel: "Soláthraí cuimhne",
missingEnvWarn: "Socraigh iad seo in Eochracha sular féidir leis an plugin rith:",
noDashboardTab: "Gan cluaisín dashboard",
openTab: "Oscail",
orphanHeading: "Síntí dashboard amháin (gan meaitseáil le agent plugin.yaml)",
pluginListHeading: "Plugins suiteáilte",
providerDefaults: "ionsuite / réamhshocraithe",
providersHeading: "Plugins soláthraí runtime",
providersHint:
"Scríobhann memory.provider (folamh = ionsuite) agus context.engine chuig config.yaml. Beidh éifeacht aige sa chéad seisiún eile.",
refreshDashboard: "Athscan síntí an dashboard",
removeConfirm: "Bain an plugin seo ó ~/.hermes/plugins/?",
removeHint: "Ní féidir ach plugins atá suiteáilte ag an úsáideoir faoi ~/.hermes/plugins a bhaint.",
rescanHeading: "Clár plugin SPA",
rescanHint: "Athscan tar éis comhaid a chur leis an diosca ionas go n-aimseoidh barra taoibh an dashboard manifests nua.",
runtimeHeading: "Runtime gateway (plugins YAML)",
saveProviders: "Sábháil socruithe an tsoláthraí",
savedProviders: "Socruithe an tsoláthraí sábháilte.",
sourceBadge: "Foinse",
authRequired: "Fíordheimhniú riachtanach",
authRequiredHint: "Rith an t-ordú seo chun fíordheimhniú a dhéanamh:",
updateGit: "Git pull",
versionBadge: "Leagan",
showInSidebar: "Taispeáin sa bharra taoibh",
hideFromSidebar: "Folaigh ón mbarra taoibh",
},
skills: {
title: "Scileanna",
searchPlaceholder: "Cuardaigh scileanna agus toolsets...",
enabledOf: "{enabled}/{total} cumasaithe",
all: "Gach ceann",
categories: "Catagóirí",
filters: "Scagairí",
noSkills: "Níor aimsíodh scileanna. Luchtaítear scileanna ó ~/.hermes/skills/",
noSkillsMatch: "Níl scil ar bith ag teacht le do chuardach nó scagaire.",
skillCount: "{count} scil{s}",
resultCount: "{count} torad{s}",
noDescription: "Gan cur síos ar fáil.",
toolsets: "Toolsets",
toolsetLabel: "toolset {name}",
noToolsetsMatch: "Níl toolset ar bith ag teacht leis an gcuardach.",
setupNeeded: "Socrú ag teastáil",
disabledForCli: "Díchumasaithe don CLI",
more: "+{count} eile",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Scagairí",
sections: "Ranna",
exportConfig: "Easpórtáil cumraíocht mar JSON",
importConfig: "Iompórtáil cumraíocht ó JSON",
resetDefaults: "Athshocraigh chuig réamhshocruithe",
resetScopeTooltip: "Athshocraigh {scope} chuig réamhshocruithe",
confirmResetScope: "Athshocraigh socruithe uile {scope} chuig a réamhshocruithe? Nuashonraíonn sé seo an fhoirm amháin — ní scríobhfar athruithe chuig config.yaml go dtí go mbrúnn tú Sábháil.",
resetScopeToast: "{scope} athshocraithe chuig réamhshocruithe — athbhreithnigh agus Sábháil chun é a choinneáil",
rawYaml: "Cumraíocht YAML amh",
searchResults: "Torthaí cuardaigh",
fields: "réims{s}",
noFieldsMatch: 'Níl aon réimsí ag teacht le "{query}"',
configSaved: "Cumraíocht sábháilte",
yamlConfigSaved: "Cumraíocht YAML sábháilte",
failedToSave: "Theip ar shábháil",
failedToSaveYaml: "Theip ar shábháil an YAML",
failedToLoadRaw: "Theip ar luchtú na cumraíochta amh",
configImported: "Cumraíocht iompórtáilte — athbhreithnigh agus sábháil",
invalidJson: "Comhad JSON neamhbhailí",
categories: {
general: "Ginearálta",
agent: "Agent",
terminal: "Teirminéal",
display: "Taispeáint",
delegation: "Tarmligean",
memory: "Cuimhne",
compression: "Comhbhrú",
security: "Slándáil",
browser: "Brabhsálaí",
voice: "Guth",
tts: "Téacs go Caint",
stt: "Caint go Téacs",
logging: "Logáil",
discord: "Discord",
auxiliary: "Cúntach",
},
},
env: {
changesNote: "Sábháiltear athruithe chuig an diosca láithreach. Aimsíonn seisiúin ghníomhacha eochracha nua go huathoibríoch.",
confirmClearMessage:
"Bainfear an luach stóráilte don athróg seo ó do chomhad .env. Ní féidir é seo a chealú ón UI.",
confirmClearTitle: "Glan an eochair seo?",
description: "Bainistigh eochracha API agus rúin atá stóráilte i",
hideAdvanced: "Folaigh Ardroghanna",
showAdvanced: "Taispeáin Ardroghanna",
showLess: "Taispeáin níos lú",
showMore: "Taispeáin tuilleadh",
llmProviders: "Soláthraithe LLM",
providersConfigured: "{configured} as {total} soláthraí cumraithe",
getKey: "Faigh eochair",
notConfigured: "{count} gan cumrú",
notSet: "Gan socrú",
keysCount: "{count} eochai{s}",
enterValue: "Cuir luach isteach...",
replaceCurrentValue: "Athchuir an luach reatha ({preview})",
showValue: "Taispeáin an fíorluach",
hideValue: "Folaigh an luach",
},
oauth: {
title: "Logálacha isteach soláthraí (OAuth)",
providerLogins: "Logálacha isteach soláthraí (OAuth)",
description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.",
connected: "Ceangailte",
expired: "As feidhm",
notConnected: "Gan cheangal. Rith {command} i dteirminéal.",
runInTerminal: "i dteirminéal.",
noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.",
login: "Logáil isteach",
disconnect: "Dícheangail",
managedExternally: "Bainistithe go seachtrach",
copied: "Cóipeáilte ✓",
cli: "Cóipeáil",
copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
connect: "Ceangail",
sessionExpires: "Téann an seisiún as feidhm i {time}",
initiatingLogin: "Ag tosú an tsreabha logála isteach…",
exchangingCode: "Ag malartú an chóid ar tokens…",
connectedClosing: "Ceangailte! Á dhúnadh…",
loginFailed: "Theip ar an logáil isteach.",
sessionExpired: "Seisiún as feidhm. Cliceáil Bain triail eile as chun logáil isteach nua a thosú.",
reOpenAuth: "Athoscail an leathanach údaraithe",
reOpenVerification: "Athoscail an leathanach fíoraithe",
submitCode: "Cuir an cód isteach",
pasteCode: "Greamaigh an cód údaraithe (tá iarmhír #state ceart go leor)",
waitingAuth: "Ag fanacht leat údarú a dhéanamh sa bhrabhsálaí…",
enterCodePrompt: "D'oscail cluaisín nua. Cuir an cód seo isteach má iarrtar ort:",
pkceStep1: "D'oscail cluaisín nua chuig claude.ai. Logáil isteach agus cliceáil Údaraigh.",
pkceStep2: "Cóipeáil an cód údaraithe a thaispeántar tar éis údaraithe.",
pkceStep3: "Greamaigh thíos é agus cuir isteach é.",
flowLabels: {
pkce: "Logáil isteach brabhsálaí (PKCE)",
device_code: "Cód gléis",
external: "CLI seachtrach",
},
expiresIn: "as feidhm i {time}",
},
language: {
switchTo: "Athraigh teanga",
},
theme: {
title: "Téama",
switchTheme: "Athraigh téama",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Suaitheantais Hermes inbhailithe a thuilltear ó stair fíor-session. Léirítear gnóthachtálacha aitheanta neamhchríochnaithe mar Discovered; fanann gnóthachtálacha Secret i bhfolach go dtí go bhfeictear an chéad iompar comhoiriúnach.",
scan_subtitle:
"Stair session Hermes á scanadh. Is féidir leis an gcéad scan 510 soicind a thógáil ar staireanna móra.",
},
actions: {
rescan: "Athscan",
},
stats: {
unlocked: "Díghlasáilte",
unlocked_hint: "suaitheantais tuillte",
discovered: "Aimsithe",
discovered_hint: "ar eolas, gan tuilleamh fós",
secrets: "Rúin",
secrets_hint: "i bhfolach go dtí an chéad chomhartha",
highest_tier: "An leibhéal is airde",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "An ceann is déanaí",
latest_hint_empty: "rith Hermes níos mó",
none_yet: "Aon cheann fós",
},
state: {
unlocked: "Díghlasáilte",
discovered: "Aimsithe",
secret: "Rún",
},
tier: {
target: "Sprioc {tier}",
hidden: "I bhfolach",
complete: "Críochnaithe",
objective: "Cuspóir",
},
progress: {
hidden: "i bhfolach",
},
scan: {
building_headline: "Próifíl ghnóthachtála á tógáil…",
building_detail:
"Sessions, glaonna ar uirlisí, meiteashonraí samhla agus staid díghlasála á léamh.",
starting_headline: "Scan ghnóthachtála á thosú…",
progress_detail:
"{scanned} as {total} session scanta · {pct}%. Díghlasáiltear suaitheantais de réir mar a shníonn níos mó staire isteach.",
idle_detail:
"Sessions, glaonna ar uirlisí, meiteashonraí samhla agus staid díghlasála á léamh. Feicfear suaitheantais anseo de réir mar a dhíghlasáiltear iad.",
},
guide: {
tiers_header: "Leibhéil",
secret_header: "Gnóthachtálacha rúnda",
secret_body:
"Coinníonn rúin a dtruicear cruinn faoi cheilt. Nuair a fheiceann Hermes comhartha gaolmhar, athraíonn an cárta go Aimsithe agus taispeánann sé a riachtanas.",
scan_status_header: "Stádas an scanta",
scan_status_body:
"Scanann Hermes an stair logánta uair amháin, ansin feicfear cártaí go huathoibríoch. Níl aon rud sáinnithe má thógann sé cúpla soicind.",
what_scanned_header: "Cad a scantar",
what_scanned_body:
"Sessions, glaonna ar uirlisí, meiteashonraí samhla, earráidí, gnóthachtálacha agus staid díghlasála logánta.",
},
card: {
share_title: "Comhroinn an gnóthachtáil seo",
share_label: "Comhroinn {name}",
share_text: "Comhroinn",
how_to_reveal: "Conas é a nochtadh",
what_counts: "Cad a chomhairtear",
evidence_label: "Fianaise",
evidence_session_fallback: "session",
no_evidence: "Níl fianaise ann fós",
},
latest: {
header: "Díghlasálacha le déanaí",
},
empty: {
no_secrets_header: "Níl aon rúin fhalaithe fágtha sa scan seo.",
no_secrets_body:
"Leid: tosaíonn rúin de ghnáth le patrúin teipe neamhghnácha nó patrúin power-user — coinbhleachtaí poirt, ballaí ceadanna, athróga env in easnamh, botúin YAML, imbhuailtí Docker, úsáid rollback/checkpoint, amais cache, nó mionchóirithe tar éis go leor téacs dheirg.",
},
filters: {
all_categories: "Gach rud",
visibility_all: "uile",
visibility_unlocked: "díghlasáilte",
visibility_discovered: "aimsithe",
visibility_secret: "rún",
},
share: {
dialog_label: "Comhroinn gnóthachtáil",
header: "Comhroinn: {name}",
close: "Dún",
rendering: "Á rindreáil…",
card_alt: "Cárta comhroinnte {name}",
error_generic: "Chuaigh rud éigin amú.",
x_title: "Osclaíonn X le post réamhlíonta",
x_button: "Comhroinn ar X",
copy_title: "Cóipeáil an íomhá le greamú isteach i do phost",
copy_button: "Cóipeáil íomhá",
copied: "Cóipeáilte ✓",
download_button: "Íoslódáil PNG",
hint:
"Osclaíonn Comhroinn ar X post réamhlíonta i gcluaisín nua. Cliceáil Cóipeáil íomhá ar dtús más mian leat an suaitheantas 1200×630 a bheith ceangailte — ligeann X duit é a ghreamú díreach isteach i scríbhneoir an tweet. Sábhálann Íoslódáil PNG an comhad le húsáid áit ar bith.",
clipboard_unsupported:
"Ní thacaítear le cóipeáil íomhá chuig an ngearrthaisce sa bhrabhsálaí seo — úsáid Íoslódáil ina ionad sin.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Clár Kanban á luchtú…",
loadFailed: "Theip ar luchtú an chláir Kanban: ",
loadFailedHint:
"Cruthaíonn an cúl-inneall kanban.db go huathoibríoch ar an gcéad léamh. Má leanann sé seo, féach logaí an dashboard.",
board: "Clár",
newBoard: "+ Clár nua",
newBoardTitle: "Clár nua",
newBoardDescription:
"Ligeann boards duit sruthanna oibre neamhghaolmhara a scaradh — ceann amháin in aghaidh an tionscadail, an repo nó an fhearainn. Ní fheiceann workers ar bhord amháin tascanna board eile riamh.",
slug: "Slug",
slugHint: "— litreacha beaga, fleiscíní, m.sh. atm10-server",
displayName: "Ainm taispeána",
displayNameHint: "(roghnach)",
description: "Cur síos",
descriptionHint: "(roghnach)",
icon: "Deilbhín",
iconHint: "(carachtar amháin nó emoji)",
switchAfterCreate: "Athraigh chuig an gclár seo tar éis a chruthaithe",
cancel: "Cealaigh",
creating: "Á chruthú…",
createBoard: "Cruthaigh clár",
search: "Cuardaigh",
filterCards: "Scag cártaí…",
tenant: "Tenant",
allTenants: "Gach tenant",
assignee: "Sannaí",
allProfiles: "Gach profile",
showArchived: "Taispeáin cinn cartlannaithe",
lanesByProfile: "Lánaí de réir profile",
nudgeDispatcher: "Spreag an dispatcher",
refresh: "Athnuaigh",
selected: "roghnaithe",
complete: "Cuir i gcrích",
archive: "Cartlannaigh",
apply: "Cuir i bhfeidhm",
clear: "Glan",
createTask: "Cruthaigh tasc sa cholún seo",
noTasks: "— gan tascanna —",
unassigned: "gan sannadh",
untitled: "(gan teideal)",
loadingDetail: "Á luchtú…",
addComment: "Cuir nóta tráchta… (Enter chun seoladh)",
comment: "Nóta tráchta",
status: "Stádas",
workspace: "Workspace",
skills: "Scileanna",
createdBy: "Cruthaithe ag",
result: "Toradh",
comments: "Nótaí tráchta",
events: "Imeachtaí",
runHistory: "Stair na rití",
workerLog: "Loga an worker",
loadingLog: "Loga á luchtú…",
noWorkerLog:
"— níl loga worker ann fós (níor sheol an tasc nó rinneadh an loga a rothlú) —",
noDescription: "— gan cur síos —",
noComments: "— gan nótaí tráchta —",
edit: "cuir in eagar",
save: "Sábháil",
dependencies: "Spleáchais",
parents: "Tuismitheoirí:",
children: "Leanaí:",
none: "ceann ar bith",
addParent: "— cuir tuismitheoir leis —",
addChild: "— cuir leanbh leis —",
removeDependency: "Bain spleáchas",
block: "Bac",
unblock: "Díbhac",
notifyHomeChannels: "Cuir cainéil bhaile ar an eolas",
diagnostics: "Diagnóisic",
hide: "Folaigh",
show: "Taispeáin",
attention: "Aird",
tasksNeedAttention: "tasc ag teastáil aird",
taskNeedsAttention: "Tá aird ag teastáil ó 1 thasc",
diagnostic: "diagnóis",
open: "Oscail",
close: "Dún (Esc)",
reassignTo: "Athshann chuig:",
copied: "Cóipeáilte",
copyCommand: "Cóipeáil ordú chuig an ngearrthaisce",
reclaim: "Athéiligh",
reassign: "Athshann",
renderingError: "Bhuail earráid rindreála an chluaisín Kanban",
reloadView: "Athluchtaigh an radharc",
wsAuthFailed:
"Theip ar fhíordheimhniú WebSocket — athluchtaigh an leathanach chun an comhartha seisiúin a athnuachan.",
markDone: "Marcáil {n} tasc mar críochnaithe?",
markArchived: "Cartlannaigh {n} tasc?",
warning: "Rabhadh",
phantomIds: "ID-anna taibhse:",
active: "gníomhach",
ended: "críochnaithe",
noProfile: "(gan profile)",
showAllAttempts: "Taispeáin gach iarracht",
sendingUpdates: "Nuashonruithe á seoladh chuig",
sendNotifications: "Seol fógraí completed / blocked / gave_up chuig",
archiveBoardConfirm:
"Cartlannaigh an clár '{name}'? Bogfar é go boards/_archived/ ionas gur féidir é a aisghabháil níos déanaí. Ní bheidh tascanna an chláir seo le feiceáil aon áit san UI a thuilleadh.",
archiveBoardTitle: "Cartlannaigh an clár seo",
boardSwitcherHint: "Ligeann boards duit sruthanna oibre neamhghaolmhara a scaradh",
taskCreatedWarning: "Cruthaíodh an tasc, ach: ",
moveFailed: "Theip ar an mbogadh: ",
bulkFailed: "Cnuasach: ",
completionBlockedHallucination: "⚠ Cuireadh bac ar chríochnú — ID-anna taibhse na gcártaí",
suspectedHallucinatedReferences: "⚠ Tagairt sa téacs do ID-anna taibhse na gcártaí",
pickProfileFirst: "Roghnaigh profile ar dtús.",
unblockedMessage: "Díbhacadh {id}. Tá an tasc réidh don chéad tic eile.",
unblockFailed: "Theip ar an díbhacadh: ",
reclaimedMessage: "Athéilíodh {id}. Tá an tasc ar ais ag ready.",
reclaimFailed: "Theip ar an athéileamh: ",
reassignedMessage: "Athshannadh {id} chuig {profile}.",
reassignFailed: "Theip ar an athshannadh: ",
selectForBulk: "Roghnaigh do ghníomhartha cnuasaigh",
clickToEdit: "Cliceáil chun eagarthóireacht a dhéanamh",
clickToEditAssignee: "Cliceáil chun an sannaí a chur in eagar",
emptyAssignee: "(folamh = bain an sannadh)",
columnLabels: {
triage: "Triáiseáil",
todo: "Le déanamh",
scheduled: "Sceidealta",
ready: "Réidh",
running: "Ar siúl",
blocked: "Bactha",
done: "Críochnaithe",
archived: "Cartlannaithe",
},
columnHelp: {
triage: "Smaointe amha — déanfaidh specifier an spec a chur i bhfeidhm",
todo: "Ag fanacht ar spleáchais nó gan sannadh",
scheduled: "Ag fanacht ar mhoill ama atá ar eolas nó ar leanúint sceidealta",
ready: "Tá na spleáchais sásaithe; sann próifíl le dispatch a dhéanamh",
running: "Éilithe ag worker — ar siúl",
blocked: "D'iarr an worker ionchur duine",
done: "Críochnaithe",
archived: "Cartlannaithe",
},
confirmDone:
"Marcáil an tasc seo mar críochnaithe? Scaoiltear éileamh an worker agus éiríonn leanaí spleácha ready.",
confirmArchive:
"Cartlannaigh an tasc seo? Imíonn sé as an réamhradharc cláir.",
confirmBlocked:
"Marcáil an tasc seo mar bactha? Scaoiltear éileamh an worker.",
completionSummary:
"Achoimre chríochnaithe ar {label}. Stóráiltear é seo mar result an taisc.",
completionSummaryRequired:
"Tá achoimre chríochnaithe riachtanach sula marcáiltear tasc mar críochnaithe.",
triagePlaceholder: "Smaoineamh garbh — déanfaidh AI an spec…",
taskTitlePlaceholder: "Teideal taisc nua…",
specifier: "specifier",
assigneePlaceholder: "sannaí",
priority: "Tosaíocht",
skillsPlaceholder:
"scileanna (roghnach, scartha le camóga): translation, github-code-review",
noParent: "— gan tuismitheoir —",
workspacePathDir: "conair workspace (riachtanach, m.sh. ~/projects/my-app)",
workspacePathOptional:
"conair workspace (roghnach, díorthaithe ón sannaí má tá sé folamh)",
logTruncated: "(taispeántar an 100 KB deireanach — loga iomlán ag ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const hu: Translations = {
common: {
save: "Mentés",
saving: "Mentés...",
cancel: "Mégse",
close: "Bezárás",
confirm: "Megerősítés",
delete: "Törlés",
refresh: "Frissítés",
retry: "Újra",
search: "Keresés...",
loading: "Betöltés...",
create: "Létrehozás",
creating: "Létrehozás...",
set: "Beállítás",
replace: "Csere",
clear: "Törlés",
live: "Élő",
off: "Ki",
enabled: "engedélyezve",
disabled: "letiltva",
active: "aktív",
inactive: "inaktív",
unknown: "ismeretlen",
untitled: "Névtelen",
none: "Nincs",
form: "Űrlap",
noResults: "Nincs találat",
of: "/",
page: "Oldal",
msgs: "üzenet",
tools: "eszközök",
match: "egyezés",
other: "Egyéb",
configured: "beállítva",
removed: "eltávolítva",
failedToToggle: "Nem sikerült átváltani",
failedToRemove: "Nem sikerült eltávolítani",
failedToReveal: "Nem sikerült megjeleníteni",
collapse: "Összecsukás",
expand: "Kibontás",
general: "Általános",
messaging: "Üzenetküldés",
pluginLoadFailed:
"Nem sikerült betölteni a bővítmény szkriptjét. Ellenőrizze a Network fület (dashboard-plugins/…) és a kiszolgáló bővítmény-elérési útját.",
pluginNotRegistered:
"A bővítmény szkriptje nem hívta meg a register() függvényt, vagy hibára futott. A részletekért nyissa meg a böngésző konzolját.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Navigáció bezárása",
closeModelTools: "Modell és eszközök bezárása",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Aktív munkamenetek:",
gatewayStatusLabel: "Átjáró állapota:",
gatewayStrip: {
failed: "Indítás sikertelen",
off: "Ki",
running: "Fut",
starting: "Indul",
stopped: "Leállítva",
},
nav: {
analytics: "Analitika",
chat: "Csevegés",
config: "Beállítások",
cron: "Cron",
documentation: "Dokumentáció",
keys: "Kulcsok",
logs: "Naplók",
models: "Modellek",
profiles: "profilok: több ügynök",
plugins: "Bővítmények",
sessions: "Munkamenetek",
skills: "Készségek",
},
modelToolsSheetSubtitle: "és eszközök",
modelToolsSheetTitle: "Modell",
navigation: "Navigáció",
openDocumentation: "Dokumentáció megnyitása új lapon",
openNavigation: "Navigáció megnyitása",
pluginNavSection: "Bővítmények",
sessionsActiveCount: "{count} aktív",
statusOverview: "Állapot áttekintése",
system: "Rendszer",
webUi: "Web UI",
},
status: {
actionFailed: "Művelet sikertelen",
actionFinished: "Befejezve",
actions: "Műveletek",
agent: "Ügynök",
activeSessions: "Aktív munkamenetek",
connected: "Csatlakoztatva",
connectedPlatforms: "Csatlakoztatott platformok",
disconnected: "Lekapcsolva",
error: "Hiba",
failed: "Sikertelen",
gateway: "Átjáró",
gatewayFailedToStart: "Az átjáró nem indult el",
lastUpdate: "Utolsó frissítés",
noneRunning: "Nincs",
notRunning: "Nem fut",
pid: "PID",
platformDisconnected: "lekapcsolva",
platformError: "hiba",
recentSessions: "Legutóbbi munkamenetek",
restartGateway: "Átjáró újraindítása",
restartingGateway: "Átjáró újraindítása…",
running: "Fut",
runningRemote: "Fut (távoli)",
startFailed: "Indítás sikertelen",
starting: "Indul",
startedInBackground: "Háttérben elindítva — kövesse a naplókat a folyamathoz",
stopped: "Leállítva",
updateHermes: "Hermes frissítése",
updatingHermes: "Hermes frissítése…",
waitingForOutput: "Várakozás a kimenetre…",
},
sessions: {
title: "Munkamenetek",
history: "Előzmények",
overview: "Áttekintés",
searchPlaceholder: "Keresés üzenettartalomban...",
noSessions: "Még nincsenek munkamenetek",
noMatch: "Nincs a keresésnek megfelelő munkamenet",
startConversation: "Indítson egy beszélgetést, hogy itt megjelenjen",
noMessages: "Nincsenek üzenetek",
untitledSession: "Névtelen munkamenet",
deleteSession: "Munkamenet törlése",
confirmDeleteTitle: "Törli a munkamenetet?",
confirmDeleteMessage:
"Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.",
sessionDeleted: "Munkamenet törölve",
failedToDelete: "Nem sikerült törölni a munkamenetet",
resumeInChat: "Folytatás a csevegésben",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
roles: {
user: "Felhasználó",
assistant: "Asszisztens",
system: "Rendszer",
tool: "Eszköz",
},
},
analytics: {
period: "Időszak:",
totalTokens: "Összes token",
totalSessions: "Összes munkamenet",
apiCalls: "API-hívások",
dailyTokenUsage: "Napi tokenhasználat",
dailyBreakdown: "Napi bontás",
perModelBreakdown: "Modellek szerinti bontás",
topSkills: "Legnépszerűbb készségek",
skill: "Készség",
loads: "Ügynök által betöltve",
edits: "Ügynök által kezelve",
lastUsed: "Utoljára használva",
input: "Bemenet",
output: "Kimenet",
total: "Összesen",
noUsageData: "Nincs használati adat erre az időszakra",
startSession: "Indítson munkamenetet az analitika megtekintéséhez",
date: "Dátum",
model: "Modell",
tokens: "Tokenek",
perDayAvg: "/nap átlag",
acrossModels: "{count} modellen át",
inOut: "{input} be / {output} ki",
},
models: {
modelsUsed: "Használt modellek",
estimatedCost: "Becsült költség",
tokens: "tokenek",
sessions: "munkamenetek",
avgPerSession: "átlag/munkamenet",
apiCalls: "API-hívások",
toolCalls: "eszközhívások",
noModelsData: "Nincs modellhasználati adat erre az időszakra",
startSession: "Indítson munkamenetet a modelladatok megtekintéséhez",
},
logs: {
title: "Naplók",
autoRefresh: "Automatikus frissítés",
file: "Fájl",
level: "Szint",
component: "Komponens",
lines: "Sorok",
noLogLines: "Nem található naplóbejegyzés",
},
cron: {
confirmDeleteMessage:
"Ez eltávolítja a feladatot az ütemezésből. A művelet nem vonható vissza.",
confirmDeleteTitle: "Törli az ütemezett feladatot?",
newJob: "Új Cron-feladat",
nameOptional: "Név (opcionális)",
namePlaceholder: "pl. Napi összegzés",
prompt: "Prompt",
promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?",
schedule: "Ütemezés (cron-kifejezés)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Kézbesítés ide",
scheduledJobs: "Ütemezett feladatok",
noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.",
last: "Utolsó",
next: "Következő",
pause: "Szüneteltetés",
resume: "Folytatás",
triggerNow: "Indítás most",
delivery: {
local: "Helyi",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Új profil",
name: "Név",
namePlaceholder: "pl. coder, writer stb.",
nameRequired: "A név kötelező",
nameRule:
"Csak kisbetűk, számjegyek, _ és - karakterek; betűvel vagy számjeggyel kell kezdődnie; legfeljebb 64 karakter.",
invalidName: "Érvénytelen profilnév",
cloneFromDefault: "Konfiguráció klónozása az alapértelmezett profilból",
allProfiles: "Profilok",
noProfiles: "Nem található profil.",
defaultBadge: "alapértelmezett",
hasEnv: "env",
model: "Modell",
skills: "Készségek",
rename: "Átnevezés",
editSoul: "SOUL.md szerkesztése",
soulSection: "SOUL.md (személyiség / rendszerprompt)",
soulPlaceholder: "# Hogyan viselkedjen ez az ügynök…",
saveSoul: "SOUL mentése",
soulSaved: "SOUL.md mentve",
openInTerminal: "CLI-parancs másolása",
commandCopied: "Vágólapra másolva",
copyFailed: "Nem sikerült másolni",
confirmDeleteTitle: "Törli a profilt?",
confirmDeleteMessage:
"Ez véglegesen törli a(z) '{name}' profilt — konfigurációt, kulcsokat, emlékeket, munkameneteket, készségeket, cron-feladatokat. A művelet nem vonható vissza.",
created: "Létrehozva",
deleted: "Törölve",
renamed: "Átnevezve",
},
pluginsPage: {
contextEngineLabel: "Kontextusmotor",
dashboardSlots: "Vezérlőpult-slotok",
disableRuntime: "Letiltás",
enableAfterInstall: "Engedélyezés a telepítés után",
enableRuntime: "Engedélyezés",
forceReinstall: "Kényszerített újratelepítés (a meglévő mappa előbb törlődik)",
headline:
"Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).",
identifierLabel: "Git URL vagy owner/repo",
inactive: "inaktív",
installBtn: "Telepítés",
installHeading: "Telepítés GitHubról / Git URL-ről",
installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.",
memoryProviderLabel: "Memória-szolgáltató",
missingEnvWarn: "Állítsa be ezeket a Kulcsok között, mielőtt a bővítmény futhatna:",
noDashboardTab: "Nincs vezérlőpult-fül",
openTab: "Megnyitás",
orphanHeading: "Csak vezérlőpult-bővítmények (nincs egyező agent plugin.yaml)",
pluginListHeading: "Telepített bővítmények",
providerDefaults: "beépített / alapértelmezett",
providersHeading: "Futási idejű szolgáltató-bővítmények",
providersHint:
"A memory.provider (üres = beépített) és a context.engine értékét írja a config.yaml fájlba. A következő munkamenetben lép életbe.",
refreshDashboard: "Vezérlőpult-bővítmények újraolvasása",
removeConfirm: "Eltávolítja ezt a bővítményt a ~/.hermes/plugins/ mappából?",
removeHint: "Csak a felhasználó által a ~/.hermes/plugins alá telepített bővítmények távolíthatók el.",
rescanHeading: "SPA-bővítményregiszter",
rescanHint: "Olvassa újra a fájlokat a lemezen történő hozzáadás után, hogy az oldalsáv felvegye az új manifesteket.",
runtimeHeading: "Átjáró-futási idő (YAML-bővítmények)",
saveProviders: "Szolgáltatóbeállítások mentése",
savedProviders: "Szolgáltatóbeállítások mentve.",
sourceBadge: "Forrás",
authRequired: "Hitelesítés szükséges",
authRequiredHint: "Futtassa ezt a parancsot a hitelesítéshez:",
updateGit: "Git pull",
versionBadge: "Verzió",
showInSidebar: "Megjelenítés az oldalsávon",
hideFromSidebar: "Elrejtés az oldalsávról",
},
skills: {
title: "Készségek",
searchPlaceholder: "Készségek és eszközkészletek keresése...",
enabledOf: "{enabled}/{total} engedélyezve",
all: "Összes",
categories: "Kategóriák",
filters: "Szűrők",
noSkills: "Nem található készség. A készségek a ~/.hermes/skills/ mappából töltődnek be",
noSkillsMatch: "Nincs a keresésnek vagy szűrőnek megfelelő készség.",
skillCount: "{count} készség{s}",
resultCount: "{count} találat{s}",
noDescription: "Nincs elérhető leírás.",
toolsets: "Eszközkészletek",
toolsetLabel: "{name} eszközkészlet",
noToolsetsMatch: "Nincs a keresésnek megfelelő eszközkészlet.",
setupNeeded: "Beállítás szükséges",
disabledForCli: "CLI-hez letiltva",
more: "+{count} további",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Szűrők",
sections: "Szakaszok",
exportConfig: "Konfiguráció exportálása JSON-ba",
importConfig: "Konfiguráció importálása JSON-ból",
resetDefaults: "Visszaállítás alapértelmezettre",
resetScopeTooltip: "{scope} visszaállítása alapértelmezettre",
confirmResetScope: "Visszaállítja az összes {scope} beállítást alapértelmezettre? Ez csak az űrlapot frissíti — a változások nem íródnak be a config.yaml fájlba, amíg meg nem nyomja a Mentés gombot.",
resetScopeToast: "{scope} visszaállítva alapértelmezettre — ellenőrizze és mentse a megőrzéshez",
rawYaml: "Nyers YAML-konfiguráció",
searchResults: "Keresési eredmények",
fields: "mező{s}",
noFieldsMatch: 'Nincs a(z) "{query}" keresésnek megfelelő mező',
configSaved: "Konfiguráció mentve",
yamlConfigSaved: "YAML-konfiguráció mentve",
failedToSave: "Mentés sikertelen",
failedToSaveYaml: "YAML mentése sikertelen",
failedToLoadRaw: "Nem sikerült betölteni a nyers konfigurációt",
configImported: "Konfiguráció importálva — ellenőrizze és mentse",
invalidJson: "Érvénytelen JSON-fájl",
categories: {
general: "Általános",
agent: "Ügynök",
terminal: "Terminál",
display: "Megjelenítés",
delegation: "Delegálás",
memory: "Memória",
compression: "Tömörítés",
security: "Biztonság",
browser: "Böngésző",
voice: "Hang",
tts: "Szövegfelolvasás",
stt: "Beszédfelismerés",
logging: "Naplózás",
discord: "Discord",
auxiliary: "Kiegészítő",
},
},
env: {
changesNote: "A változások azonnal mentésre kerülnek a lemezre. Az aktív munkamenetek automatikusan átveszik az új kulcsokat.",
confirmClearMessage:
"A változó tárolt értéke törlődik a .env fájlból. Ez a felületről nem vonható vissza.",
confirmClearTitle: "Törli ezt a kulcsot?",
description: "API-kulcsok és titkok kezelése a következő helyen:",
hideAdvanced: "Speciális elrejtése",
showAdvanced: "Speciális megjelenítése",
showLess: "Kevesebb",
showMore: "Több",
llmProviders: "LLM-szolgáltatók",
providersConfigured: "{configured} / {total} szolgáltató beállítva",
getKey: "Kulcs lekérése",
notConfigured: "{count} nincs beállítva",
notSet: "Nincs beállítva",
keysCount: "{count} kulcs{s}",
enterValue: "Adjon meg értéket...",
replaceCurrentValue: "Jelenlegi érték cseréje ({preview})",
showValue: "Tényleges érték megjelenítése",
hideValue: "Érték elrejtése",
},
oauth: {
title: "Szolgáltatói bejelentkezések (OAuth)",
providerLogins: "Szolgáltatói bejelentkezések (OAuth)",
description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.",
connected: "Csatlakoztatva",
expired: "Lejárt",
notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.",
runInTerminal: "egy terminálban.",
noProviders: "Nem észlelhető OAuth-képes szolgáltató.",
login: "Bejelentkezés",
disconnect: "Lecsatlakozás",
managedExternally: "Külsőleg kezelt",
copied: "Másolva ✓",
cli: "Másolás",
copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
connect: "Csatlakozás",
sessionExpires: "A munkamenet {time} múlva lejár",
initiatingLogin: "Bejelentkezési folyamat indítása…",
exchangingCode: "Kód cseréje tokenekre…",
connectedClosing: "Csatlakoztatva! Bezárás…",
loginFailed: "A bejelentkezés sikertelen.",
sessionExpired: "A munkamenet lejárt. Kattintson az Újra gombra új bejelentkezéshez.",
reOpenAuth: "Hitelesítési oldal újranyitása",
reOpenVerification: "Ellenőrzési oldal újranyitása",
submitCode: "Kód beküldése",
pasteCode: "Illessze be a hitelesítési kódot (a #state utótaggal együtt is megfelel)",
waitingAuth: "Várakozás a böngészőben történő engedélyezésre…",
enterCodePrompt: "Új lap nyílt meg. Adja meg ezt a kódot, ha kéri:",
pkceStep1: "Új lap nyílt meg a claude.ai oldalra. Jelentkezzen be, és kattintson az Authorize gombra.",
pkceStep2: "Másolja ki az engedélyezés után megjelenő hitelesítési kódot.",
pkceStep3: "Illessze be alább, és küldje be.",
flowLabels: {
pkce: "Bejelentkezés böngészőből (PKCE)",
device_code: "Eszközkód",
external: "Külső CLI",
},
expiresIn: "lejár {time} múlva",
},
language: {
switchTo: "Nyelv váltása",
},
theme: {
title: "Téma",
switchTheme: "Téma váltása",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Gyűjthető Hermes-jelvények, valós munkamenet-előzmények alapján szerezve. Az ismert, de még nem szerzett teljesítmények Felfedezettként jelennek meg; a Titkos teljesítmények rejtve maradnak az első egyező viselkedésig.",
scan_subtitle:
"Hermes munkamenet-előzmények vizsgálata. Az első vizsgálat 510 másodpercig is eltarthat nagy előzmények esetén.",
},
actions: {
rescan: "Újravizsgálat",
},
stats: {
unlocked: "Feloldva",
unlocked_hint: "megszerzett jelvények",
discovered: "Felfedezve",
discovered_hint: "ismert, még nem szerzett",
secrets: "Titkok",
secrets_hint: "rejtve az első jelzésig",
highest_tier: "Legmagasabb szint",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Legutóbbi",
latest_hint_empty: "futtasd többet a Hermest",
none_yet: "Még semmi",
},
state: {
unlocked: "Feloldva",
discovered: "Felfedezve",
secret: "Titkos",
},
tier: {
target: "Cél: {tier}",
hidden: "Rejtett",
complete: "Kész",
objective: "Cél",
},
progress: {
hidden: "rejtett",
},
scan: {
building_headline: "Teljesítményprofil építése…",
building_detail:
"Munkamenetek, eszközhívások, modell-metaadatok és feloldási állapot olvasása.",
starting_headline: "Teljesítmény-vizsgálat indítása…",
progress_detail:
"{scanned} / {total} munkamenet vizsgálva · {pct}%. A jelvények a további előzmények beolvasásával oldódnak fel.",
idle_detail:
"Munkamenetek, eszközhívások, modell-metaadatok és feloldási állapot olvasása. A jelvények itt jelennek meg, ahogy feloldódnak.",
},
guide: {
tiers_header: "Szintek",
secret_header: "Titkos teljesítmények",
secret_body:
"A titkos teljesítmények elrejtik a pontos kiváltó eseményt. Amint a Hermes kapcsolódó jelet észlel, a kártya Felfedezettre vált, és megjeleníti a követelményt.",
scan_status_header: "Vizsgálat állapota",
scan_status_body:
"A Hermes egyszer átvizsgálja a helyi előzményeket, majd a kártyák automatikusan megjelennek. Semmi sem akadt el, ha ez néhány másodpercig tart.",
what_scanned_header: "Mit vizsgálunk",
what_scanned_body:
"Munkamenetek, eszközhívások, modell-metaadatok, hibák, teljesítmények és helyi feloldási állapot.",
},
card: {
share_title: "Teljesítmény megosztása",
share_label: "{name} megosztása",
share_text: "Megosztás",
how_to_reveal: "Hogyan fedhető fel",
what_counts: "Mi számít",
evidence_label: "Bizonyíték",
evidence_session_fallback: "munkamenet",
no_evidence: "Még nincs bizonyíték",
},
latest: {
header: "Legutóbbi feloldások",
},
empty: {
no_secrets_header: "Ebben a vizsgálatban nem maradt rejtett titok.",
no_secrets_body:
"Tipp: a titkok általában szokatlan hibákból vagy haladó felhasználói mintákból indulnak — portütközések, jogosultsági falak, hiányzó környezeti változók, YAML-hibák, Docker-ütközések, rollback/checkpoint használata, gyorsítótár-találatok vagy apró javítások sok piros szöveg után.",
},
filters: {
all_categories: "Összes",
visibility_all: "összes",
visibility_unlocked: "feloldott",
visibility_discovered: "felfedezett",
visibility_secret: "titkos",
},
share: {
dialog_label: "Teljesítmény megosztása",
header: "Megosztás: {name}",
close: "Bezárás",
rendering: "Renderelés…",
card_alt: "{name} megosztókártya",
error_generic: "Valami hiba történt.",
x_title: "Megnyitja az X-et előre kitöltött bejegyzéssel",
x_button: "Megosztás az X-en",
copy_title: "Kép másolása a bejegyzésbe való beillesztéshez",
copy_button: "Kép másolása",
copied: "Másolva ✓",
download_button: "PNG letöltése",
hint:
"A „Megosztás az X-en” új lapon nyit meg egy előre kitöltött bejegyzést. Először kattints a „Kép másolása” gombra, ha az 1200×630-as jelvényt is csatolnád — az X engedi, hogy közvetlenül beillesszd a bejegyzésszerkesztőbe. A „PNG letöltése” bárhol felhasználható fájlként menti.",
clipboard_unsupported:
"A kép vágólapra másolása nem támogatott ebben a böngészőben — használd inkább a Letöltést.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban tábla betöltése…",
loadFailed: "Nem sikerült betölteni a Kanban táblát: ",
loadFailedHint:
"A backend első olvasáskor automatikusan létrehozza a kanban.db fájlt. Ha továbbra is fennáll, ellenőrizd a dashboard naplóit.",
board: "Tábla",
newBoard: "+ Új tábla",
newBoardTitle: "Új tábla",
newBoardDescription:
"A táblákkal külön tudod választani az egymással nem összefüggő munkafolyamokat — egyet projektenként, repónként vagy területenként. Az egyik tábla workerei sosem látják a másik tábla feladatait.",
slug: "Slug",
slugHint: "— kisbetűk, kötőjelek, pl. atm10-server",
displayName: "Megjelenítendő név",
displayNameHint: "(opcionális)",
description: "Leírás",
descriptionHint: "(opcionális)",
icon: "Ikon",
iconHint: "(egyetlen karakter vagy emodzsi)",
switchAfterCreate: "Váltás erre a táblára létrehozás után",
cancel: "Mégse",
creating: "Létrehozás…",
createBoard: "Tábla létrehozása",
search: "Keresés",
filterCards: "Kártyák szűrése…",
tenant: "Tenant",
allTenants: "Összes tenant",
assignee: "Felelős",
allProfiles: "Összes profil",
showArchived: "Archiváltak megjelenítése",
lanesByProfile: "Sávok profil szerint",
nudgeDispatcher: "Dispatcher noszogatása",
refresh: "Frissítés",
selected: "kiválasztva",
complete: "Befejezés",
archive: "Archiválás",
apply: "Alkalmaz",
clear: "Törlés",
createTask: "Feladat létrehozása ebben az oszlopban",
noTasks: "— nincsenek feladatok —",
unassigned: "nincs felelős",
untitled: "(névtelen)",
loadingDetail: "Betöltés…",
addComment: "Hozzászólás hozzáadása… (Enter a beküldéshez)",
comment: "Hozzászólás",
status: "Állapot",
workspace: "Munkaterület",
skills: "Készségek",
createdBy: "Létrehozta",
result: "Eredmény",
comments: "Hozzászólások",
events: "Események",
runHistory: "Futási előzmények",
workerLog: "Worker napló",
loadingLog: "Napló betöltése…",
noWorkerLog:
"— még nincs worker napló (a feladat nem indult el, vagy a napló rotálódott) —",
noDescription: "— nincs leírás —",
noComments: "— nincsenek hozzászólások —",
edit: "szerkesztés",
save: "Mentés",
dependencies: "Függőségek",
parents: "Szülők:",
children: "Gyermekek:",
none: "nincs",
addParent: "— szülő hozzáadása —",
addChild: "— gyermek hozzáadása —",
removeDependency: "Függőség eltávolítása",
block: "Blokkolás",
unblock: "Feloldás",
notifyHomeChannels: "Otthoni csatornák értesítése",
diagnostics: "Diagnosztika",
hide: "Elrejtés",
show: "Megjelenítés",
attention: "Figyelem",
tasksNeedAttention: "feladat figyelmet igényel",
taskNeedsAttention: "1 feladat figyelmet igényel",
diagnostic: "diagnosztika",
open: "Megnyitás",
close: "Bezárás (Esc)",
reassignTo: "Új felelős:",
copied: "Másolva",
copyCommand: "Parancs másolása a vágólapra",
reclaim: "Visszavétel",
reassign: "Újrakiosztás",
renderingError: "A Kanban fülön renderelési hiba lépett fel",
reloadView: "Nézet újratöltése",
wsAuthFailed:
"WebSocket-hitelesítés sikertelen — töltsd újra az oldalt a munkamenet-token frissítéséhez.",
markDone: "Megjelölöd {n} feladatot késznek?",
markArchived: "Archiválsz {n} feladatot?",
warning: "Figyelmeztetés",
phantomIds: "Fantom id-k:",
active: "aktív",
ended: "befejeződött",
noProfile: "(nincs profil)",
showAllAttempts: "Összes próbálkozás megjelenítése",
sendingUpdates: "Frissítések küldése ide:",
sendNotifications: "completed / blocked / gave_up értesítések küldése ide:",
archiveBoardConfirm:
"Archiválod a(z) '{name}' táblát? Áthelyezzük a boards/_archived/ mappába, hogy később visszaállíthasd. A táblán lévő feladatok többé nem jelennek meg sehol az UI-ban.",
archiveBoardTitle: "Tábla archiválása",
boardSwitcherHint: "A táblákkal külön tudod választani az egymással nem összefüggő munkafolyamokat",
taskCreatedWarning: "Feladat létrehozva, de: ",
moveFailed: "Áthelyezés sikertelen: ",
bulkFailed: "Tömeges: ",
completionBlockedHallucination: "⚠ Befejezés blokkolva — fantom kártya id-k",
suspectedHallucinatedReferences: "⚠ A szöveg fantom kártya id-kre hivatkozott",
pickProfileFirst: "Először válassz profilt.",
unblockedMessage: "{id} feloldva. A feladat készen áll a következő tickre.",
unblockFailed: "Feloldás sikertelen: ",
reclaimedMessage: "{id} visszavéve. A feladat újra ready állapotban van.",
reclaimFailed: "Visszavétel sikertelen: ",
reassignedMessage: "{id} újrakiosztva neki: {profile}.",
reassignFailed: "Újrakiosztás sikertelen: ",
selectForBulk: "Kijelölés tömeges műveletekhez",
clickToEdit: "Kattints a szerkesztéshez",
clickToEditAssignee: "Kattints a felelős szerkesztéséhez",
emptyAssignee: "(üres = felelős eltávolítása)",
columnLabels: {
triage: "Triázs",
todo: "Tennivaló",
scheduled: "Ütemezett",
ready: "Indulásra kész",
running: "Folyamatban",
blocked: "Blokkolva",
done: "Kész",
archived: "Archivált",
},
columnHelp: {
triage: "Nyers ötletek — egy specifier kidolgozza a specifikációt",
todo: "Függőségekre vár vagy nincs felelőse",
scheduled: "Ismert időzítésre vagy ütemezett utánkövetésre vár",
ready: "A függőségek teljesültek; rendelj hozzá profilt az indításhoz",
running: "Worker felvette — folyamatban",
blocked: "A worker emberi beavatkozást kért",
done: "Befejezve",
archived: "Archiválva",
},
confirmDone:
"Megjelölöd ezt a feladatot késznek? A worker foglalása felszabadul, és a függő gyermekek ready állapotba kerülnek.",
confirmArchive:
"Archiválod ezt a feladatot? Eltűnik az alapértelmezett tábla nézetből.",
confirmBlocked:
"Megjelölöd ezt a feladatot blokkoltként? A worker foglalása felszabadul.",
completionSummary:
"Befejezési összefoglaló a következőhöz: {label}. Ez a feladat eredményeként kerül tárolásra.",
completionSummaryRequired:
"A feladat késznek jelölése előtt kötelező megadni a befejezési összefoglalót.",
triagePlaceholder: "Nyers ötlet — az AI specifikálja…",
taskTitlePlaceholder: "Új feladat címe…",
specifier: "specifier",
assigneePlaceholder: "felelős",
priority: "Prioritás",
skillsPlaceholder:
"készségek (opcionális, vesszővel elválasztva): translation, github-code-review",
noParent: "— nincs szülő —",
workspacePathDir: "munkaterület útvonala (kötelező, pl. ~/projects/my-app)",
workspacePathOptional:
"munkaterület útvonala (opcionális, üresen a felelősből származtatva)",
logTruncated: "(az utolsó 100 KB látható — teljes napló: ",
logAt: ")",
},
};
+2
View File
@@ -0,0 +1,2 @@
export { I18nProvider, useI18n, LOCALE_META } from "./context";
export type { Locale, Translations } from "./types";
+701
View File
@@ -0,0 +1,701 @@
import type { Translations } from "./types";
export const it: Translations = {
common: {
save: "Salva",
saving: "Salvataggio...",
cancel: "Annulla",
close: "Chiudi",
confirm: "Conferma",
delete: "Elimina",
refresh: "Aggiorna",
retry: "Riprova",
search: "Cerca...",
loading: "Caricamento...",
create: "Crea",
creating: "Creazione...",
set: "Imposta",
replace: "Sostituisci",
clear: "Cancella",
live: "In tempo reale",
off: "Spento",
enabled: "abilitato",
disabled: "disabilitato",
active: "attivo",
inactive: "inattivo",
unknown: "sconosciuto",
untitled: "Senza titolo",
none: "Nessuno",
form: "Modulo",
noResults: "Nessun risultato",
of: "di",
page: "Pagina",
msgs: "msg",
tools: "strumenti",
match: "corrispondenza",
other: "Altro",
configured: "configurato",
removed: "rimosso",
failedToToggle: "Commutazione non riuscita",
failedToRemove: "Rimozione non riuscita",
failedToReveal: "Visualizzazione non riuscita",
collapse: "Comprimi",
expand: "Espandi",
general: "Generale",
messaging: "Messaggistica",
pluginLoadFailed:
"Impossibile caricare lo script di questo plugin. Controlla la scheda Network (dashboard-plugins/…) e il percorso dei plugin del server.",
pluginNotRegistered:
"Lo script del plugin non ha chiamato register(), oppure ha generato un errore. Apri la console del browser per i dettagli.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Chiudi navigazione",
closeModelTools: "Chiudi modello e strumenti",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Sessioni attive:",
gatewayStatusLabel: "Stato gateway:",
gatewayStrip: {
failed: "Avvio non riuscito",
off: "Spento",
running: "In esecuzione",
starting: "Avvio in corso",
stopped: "Arrestato",
},
nav: {
analytics: "Analisi",
chat: "Chat",
config: "Configurazione",
cron: "Cron",
documentation: "Documentazione",
keys: "Chiavi",
logs: "Log",
models: "Modelli",
profiles: "profili : multi agent",
plugins: "Plugin",
sessions: "Sessioni",
skills: "Competenze",
},
modelToolsSheetSubtitle: "e strumenti",
modelToolsSheetTitle: "Modello",
navigation: "Navigazione",
openDocumentation: "Apri la documentazione in una nuova scheda",
openNavigation: "Apri navigazione",
pluginNavSection: "Plugin",
sessionsActiveCount: "{count} attive",
statusOverview: "Panoramica dello stato",
system: "Sistema",
webUi: "Web UI",
},
status: {
actionFailed: "Azione non riuscita",
actionFinished: "Completata",
actions: "Azioni",
agent: "Agente",
activeSessions: "Sessioni attive",
connected: "Connesso",
connectedPlatforms: "Piattaforme connesse",
disconnected: "Disconnesso",
error: "Errore",
failed: "Non riuscito",
gateway: "Gateway",
gatewayFailedToStart: "Avvio del gateway non riuscito",
lastUpdate: "Ultimo aggiornamento",
noneRunning: "Nessuno",
notRunning: "Non in esecuzione",
pid: "PID",
platformDisconnected: "disconnesso",
platformError: "errore",
recentSessions: "Sessioni recenti",
restartGateway: "Riavvia gateway",
restartingGateway: "Riavvio del gateway…",
running: "In esecuzione",
runningRemote: "In esecuzione (remoto)",
startFailed: "Avvio non riuscito",
starting: "Avvio in corso",
startedInBackground: "Avviato in background — controlla i log per i progressi",
stopped: "Arrestato",
updateHermes: "Aggiorna Hermes",
updatingHermes: "Aggiornamento di Hermes…",
waitingForOutput: "In attesa di output…",
},
sessions: {
title: "Sessioni",
history: "Cronologia",
overview: "Panoramica",
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
noSessions: "Nessuna sessione",
noMatch: "Nessuna sessione corrisponde alla ricerca",
startConversation: "Avvia una conversazione per vederla qui",
noMessages: "Nessun messaggio",
untitledSession: "Sessione senza titolo",
deleteSession: "Elimina sessione",
confirmDeleteTitle: "Eliminare la sessione?",
confirmDeleteMessage:
"Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.",
sessionDeleted: "Sessione eliminata",
failedToDelete: "Eliminazione della sessione non riuscita",
resumeInChat: "Riprendi nella chat",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
roles: {
user: "Utente",
assistant: "Assistente",
system: "Sistema",
tool: "Strumento",
},
},
analytics: {
period: "Periodo:",
totalTokens: "Token totali",
totalSessions: "Sessioni totali",
apiCalls: "Chiamate API",
dailyTokenUsage: "Utilizzo giornaliero token",
dailyBreakdown: "Dettaglio giornaliero",
perModelBreakdown: "Dettaglio per modello",
topSkills: "Competenze più usate",
skill: "Competenza",
loads: "Caricato dall'agente",
edits: "Gestito dall'agente",
lastUsed: "Ultimo uso",
input: "Input",
output: "Output",
total: "Totale",
noUsageData: "Nessun dato di utilizzo per questo periodo",
startSession: "Avvia una sessione per vedere le analisi qui",
date: "Data",
model: "Modello",
tokens: "Token",
perDayAvg: "/giorno medio",
acrossModels: "su {count} modelli",
inOut: "{input} in / {output} out",
},
models: {
modelsUsed: "Modelli utilizzati",
estimatedCost: "Costo stim.",
tokens: "token",
sessions: "sessioni",
avgPerSession: "media/sessione",
apiCalls: "chiamate API",
toolCalls: "chiamate strumenti",
noModelsData: "Nessun dato sull'uso dei modelli per questo periodo",
startSession: "Avvia una sessione per vedere i dati dei modelli qui",
},
logs: {
title: "Log",
autoRefresh: "Aggiornamento automatico",
file: "File",
level: "Livello",
component: "Componente",
lines: "Righe",
noLogLines: "Nessuna riga di log trovata",
},
cron: {
confirmDeleteMessage:
"Questa operazione rimuove l'attività dalla pianificazione. Non può essere annullata.",
confirmDeleteTitle: "Eliminare l'attività pianificata?",
newJob: "Nuova attività cron",
nameOptional: "Nome (facoltativo)",
namePlaceholder: "es. Riepilogo giornaliero",
prompt: "Prompt",
promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?",
schedule: "Pianificazione (espressione cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Consegna a",
scheduledJobs: "Attività pianificate",
noJobs: "Nessuna attività cron configurata. Creane una sopra.",
last: "Ultima",
next: "Prossima",
pause: "Pausa",
resume: "Riprendi",
triggerNow: "Esegui ora",
delivery: {
local: "Locale",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Nuovo profilo",
name: "Nome",
namePlaceholder: "es. coder, writer, ecc.",
nameRequired: "Il nome è obbligatorio",
nameRule:
"Solo lettere minuscole, cifre, _ e -; deve iniziare con una lettera o cifra; fino a 64 caratteri.",
invalidName: "Nome del profilo non valido",
cloneFromDefault: "Clona la configurazione dal profilo predefinito",
allProfiles: "Profili",
noProfiles: "Nessun profilo trovato.",
defaultBadge: "predefinito",
hasEnv: "env",
model: "Modello",
skills: "Competenze",
rename: "Rinomina",
editSoul: "Modifica SOUL.md",
soulSection: "SOUL.md (personalità / prompt di sistema)",
soulPlaceholder: "# Come dovrebbe comportarsi questo agente…",
saveSoul: "Salva SOUL",
soulSaved: "SOUL.md salvato",
openInTerminal: "Copia comando CLI",
commandCopied: "Copiato negli appunti",
copyFailed: "Impossibile copiare",
confirmDeleteTitle: "Eliminare il profilo?",
confirmDeleteMessage:
"Questa operazione elimina definitivamente il profilo '{name}' — configurazione, chiavi, memorie, sessioni, competenze, attività cron. Non può essere annullata.",
created: "Creato",
deleted: "Eliminato",
renamed: "Rinominato",
},
pluginsPage: {
contextEngineLabel: "Motore di contesto",
dashboardSlots: "Slot del dashboard",
disableRuntime: "Disabilita",
enableAfterInstall: "Abilita dopo l'installazione",
enableRuntime: "Abilita",
forceReinstall: "Forza reinstallazione (elimina prima la cartella esistente)",
headline:
"Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).",
identifierLabel: "URL Git o owner/repo",
inactive: "inattivo",
installBtn: "Installa",
installHeading: "Installa da GitHub / URL Git",
installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.",
memoryProviderLabel: "Provider di memoria",
missingEnvWarn: "Imposta queste variabili in Chiavi prima di eseguire il plugin:",
noDashboardTab: "Nessuna scheda nel dashboard",
openTab: "Apri",
orphanHeading: "Estensioni solo dashboard (nessuna corrispondenza con plugin.yaml)",
pluginListHeading: "Plugin installati",
providerDefaults: "integrato / predefinito",
providersHeading: "Plugin provider runtime",
providersHint:
"Scrive memory.provider (vuoto = integrato) e context.engine in config.yaml. Effetto dalla prossima sessione.",
refreshDashboard: "Riscansiona estensioni dashboard",
removeConfirm: "Rimuovere questo plugin da ~/.hermes/plugins/?",
removeHint: "Solo i plugin installati dall'utente in ~/.hermes/plugins possono essere rimossi.",
rescanHeading: "Registro plugin SPA",
rescanHint: "Riscansiona dopo aver aggiunto file su disco affinché la barra laterale rilevi i nuovi manifest.",
runtimeHeading: "Runtime gateway (plugin YAML)",
saveProviders: "Salva impostazioni provider",
savedProviders: "Impostazioni provider salvate.",
sourceBadge: "Origine",
authRequired: "Autenticazione richiesta",
authRequiredHint: "Esegui questo comando per autenticarti:",
updateGit: "Git pull",
versionBadge: "Versione",
showInSidebar: "Mostra nella barra laterale",
hideFromSidebar: "Nascondi dalla barra laterale",
},
skills: {
title: "Competenze",
searchPlaceholder: "Cerca competenze e toolset...",
enabledOf: "{enabled}/{total} abilitati",
all: "Tutti",
categories: "Categorie",
filters: "Filtri",
noSkills: "Nessuna competenza trovata. Le competenze vengono caricate da ~/.hermes/skills/",
noSkillsMatch: "Nessuna competenza corrisponde alla ricerca o al filtro.",
skillCount: "{count} competenz{s}",
resultCount: "{count} risultat{s}",
noDescription: "Nessuna descrizione disponibile.",
toolsets: "Toolset",
toolsetLabel: "Toolset {name}",
noToolsetsMatch: "Nessun toolset corrisponde alla ricerca.",
setupNeeded: "Configurazione necessaria",
disabledForCli: "Disabilitato per CLI",
more: "+{count} in più",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filtri",
sections: "Sezioni",
exportConfig: "Esporta configurazione come JSON",
importConfig: "Importa configurazione da JSON",
resetDefaults: "Ripristina predefiniti",
resetScopeTooltip: "Ripristina {scope} ai valori predefiniti",
confirmResetScope: "Ripristinare tutte le impostazioni di {scope} ai valori predefiniti? Questa operazione aggiorna solo il modulo — le modifiche non vengono scritte in config.yaml finché non premi Salva.",
resetScopeToast: "{scope} ripristinato ai valori predefiniti — controlla e Salva per rendere persistente",
rawYaml: "Configurazione YAML grezza",
searchResults: "Risultati della ricerca",
fields: "camp{s}",
noFieldsMatch: 'Nessun campo corrisponde a "{query}"',
configSaved: "Configurazione salvata",
yamlConfigSaved: "Configurazione YAML salvata",
failedToSave: "Salvataggio non riuscito",
failedToSaveYaml: "Salvataggio YAML non riuscito",
failedToLoadRaw: "Caricamento configurazione grezza non riuscito",
configImported: "Configurazione importata — controlla e salva",
invalidJson: "File JSON non valido",
categories: {
general: "Generale",
agent: "Agente",
terminal: "Terminale",
display: "Visualizzazione",
delegation: "Delega",
memory: "Memoria",
compression: "Compressione",
security: "Sicurezza",
browser: "Browser",
voice: "Voce",
tts: "Sintesi vocale",
stt: "Riconoscimento vocale",
logging: "Log",
discord: "Discord",
auxiliary: "Ausiliario",
},
},
env: {
changesNote: "Le modifiche vengono salvate immediatamente su disco. Le sessioni attive rilevano automaticamente le nuove chiavi.",
confirmClearMessage:
"Il valore memorizzato per questa variabile sarà rimosso dal tuo file .env. Non può essere annullato dall'interfaccia.",
confirmClearTitle: "Cancellare questa chiave?",
description: "Gestisci chiavi API e segreti memorizzati in",
hideAdvanced: "Nascondi avanzate",
showAdvanced: "Mostra avanzate",
showLess: "Mostra meno",
showMore: "Mostra di più",
llmProviders: "Provider LLM",
providersConfigured: "{configured} di {total} provider configurati",
getKey: "Ottieni chiave",
notConfigured: "{count} non configurat{s}",
notSet: "Non impostato",
keysCount: "{count} chiav{s}",
enterValue: "Inserisci valore...",
replaceCurrentValue: "Sostituisci valore corrente ({preview})",
showValue: "Mostra valore reale",
hideValue: "Nascondi valore",
},
oauth: {
title: "Accessi provider (OAuth)",
providerLogins: "Accessi provider (OAuth)",
description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.",
connected: "Connesso",
expired: "Scaduto",
notConnected: "Non connesso. Esegui {command} in un terminale.",
runInTerminal: "in un terminale.",
noProviders: "Nessun provider compatibile con OAuth rilevato.",
login: "Accedi",
disconnect: "Disconnetti",
managedExternally: "Gestito esternamente",
copied: "Copiato ✓",
cli: "Copia",
copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
connect: "Connetti",
sessionExpires: "La sessione scade tra {time}",
initiatingLogin: "Avvio del flusso di accesso…",
exchangingCode: "Scambio del codice per i token…",
connectedClosing: "Connesso! Chiusura…",
loginFailed: "Accesso non riuscito.",
sessionExpired: "Sessione scaduta. Clicca Riprova per iniziare un nuovo accesso.",
reOpenAuth: "Riapri pagina di autenticazione",
reOpenVerification: "Riapri pagina di verifica",
submitCode: "Invia codice",
pasteCode: "Incolla codice di autorizzazione (con suffisso #state va bene)",
waitingAuth: "In attesa che tu autorizzi nel browser…",
enterCodePrompt: "È stata aperta una nuova scheda. Inserisci questo codice se richiesto:",
pkceStep1: "È stata aperta una nuova scheda su claude.ai. Accedi e clicca Autorizza.",
pkceStep2: "Copia il codice di autorizzazione mostrato dopo l'autorizzazione.",
pkceStep3: "Incollalo qui sotto e invia.",
flowLabels: {
pkce: "Accesso browser (PKCE)",
device_code: "Codice dispositivo",
external: "CLI esterna",
},
expiresIn: "scade tra {time}",
},
language: {
switchTo: "Cambia lingua",
},
theme: {
title: "Tema",
switchTheme: "Cambia tema",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Badge Hermes da collezione, ottenuti dalla cronologia reale delle sessioni. Gli achievement noti non completati vengono mostrati come Scoperti; gli achievement segreti restano nascosti finché non compare il primo comportamento corrispondente.",
scan_subtitle:
"Scansione della cronologia delle sessioni Hermes in corso. La prima scansione può richiedere 510 secondi su cronologie ampie.",
},
actions: {
rescan: "Riscansiona",
},
stats: {
unlocked: "Sbloccati",
unlocked_hint: "badge ottenuti",
discovered: "Scoperti",
discovered_hint: "noti, non ancora ottenuti",
secrets: "Segreti",
secrets_hint: "nascosti fino al primo segnale",
highest_tier: "Livello più alto",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Più recente",
latest_hint_empty: "usa Hermes di più",
none_yet: "Nessuno ancora",
},
state: {
unlocked: "Sbloccato",
discovered: "Scoperto",
secret: "Segreto",
},
tier: {
target: "Obiettivo {tier}",
hidden: "Nascosto",
complete: "Completato",
objective: "Obiettivo",
},
progress: {
hidden: "nascosto",
},
scan: {
building_headline: "Costruzione del profilo achievement…",
building_detail:
"Lettura di sessioni, chiamate agli strumenti, metadati del modello e stato di sblocco.",
starting_headline: "Avvio della scansione achievement…",
progress_detail:
"Scansionate {scanned} di {total} sessioni · {pct}%. I badge si sbloccano man mano che viene elaborata altra cronologia.",
idle_detail:
"Lettura di sessioni, chiamate agli strumenti, metadati del modello e stato di sblocco. I badge appaiono qui non appena vengono sbloccati.",
},
guide: {
tiers_header: "Livelli",
secret_header: "Achievement segreti",
secret_body:
"I segreti nascondono il loro trigger esatto. Quando Hermes rileva un segnale correlato, la carta passa a Scoperto e mostra il requisito.",
scan_status_header: "Stato della scansione",
scan_status_body:
"Hermes sta scansionando la cronologia locale una sola volta, poi le carte appariranno automaticamente. Non è bloccato nulla se richiede qualche secondo.",
what_scanned_header: "Cosa viene scansionato",
what_scanned_body:
"Sessioni, chiamate agli strumenti, metadati del modello, errori, achievement e stato di sblocco locale.",
},
card: {
share_title: "Condividi questo achievement",
share_label: "Condividi {name}",
share_text: "Condividi",
how_to_reveal: "Come rivelarlo",
what_counts: "Cosa conta",
evidence_label: "Prova",
evidence_session_fallback: "sessione",
no_evidence: "Nessuna prova ancora",
},
latest: {
header: "Sblocchi recenti",
},
empty: {
no_secrets_header: "Nessun segreto nascosto rimasto in questa scansione.",
no_secrets_body:
"Indizio: i segreti di solito partono da fallimenti inusuali o pattern da utente esperto — conflitti di porte, muri di permessi, variabili d'ambiente mancanti, errori YAML, collisioni Docker, uso di rollback/checkpoint, cache hit o piccole correzioni dopo molto testo rosso.",
},
filters: {
all_categories: "Tutti",
visibility_all: "tutti",
visibility_unlocked: "sbloccati",
visibility_discovered: "scoperti",
visibility_secret: "segreti",
},
share: {
dialog_label: "Condividi achievement",
header: "Condividi: {name}",
close: "Chiudi",
rendering: "Rendering…",
card_alt: "Carta di condivisione {name}",
error_generic: "Qualcosa è andato storto.",
x_title: "Apre X con un post precompilato",
x_button: "Condividi su X",
copy_title: "Copia l'immagine per incollarla nel tuo post",
copy_button: "Copia immagine",
copied: "Copiato ✓",
download_button: "Scarica PNG",
hint:
"Condividi su X apre un post precompilato in una nuova scheda. Clicca prima su Copia immagine se vuoi allegare il badge 1200×630 — X ti permette di incollarlo direttamente nell'editor del tweet. Scarica PNG salva il file per l'uso ovunque.",
clipboard_unsupported:
"La copia delle immagini negli appunti non è supportata in questo browser — usa Scarica invece.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Caricamento bacheca Kanban…",
loadFailed: "Caricamento della bacheca Kanban non riuscito: ",
loadFailedHint:
"Il backend crea automaticamente kanban.db alla prima lettura. Se il problema persiste, controlla i log del dashboard.",
board: "Bacheca",
newBoard: "+ Nuova bacheca",
newBoardTitle: "Nuova bacheca",
newBoardDescription:
"Le bacheche ti permettono di separare flussi di lavoro non correlati — una per progetto, repository o dominio. I worker su una bacheca non vedono mai le attività di un'altra.",
slug: "Slug",
slugHint: "— minuscolo, trattini, ad es. atm10-server",
displayName: "Nome visualizzato",
displayNameHint: "(facoltativo)",
description: "Descrizione",
descriptionHint: "(facoltativo)",
icon: "Icona",
iconHint: "(un singolo carattere o emoji)",
switchAfterCreate: "Passa a questa bacheca dopo la creazione",
cancel: "Annulla",
creating: "Creazione…",
createBoard: "Crea bacheca",
search: "Cerca",
filterCards: "Filtra schede…",
tenant: "Tenant",
allTenants: "Tutti i tenant",
assignee: "Assegnatario",
allProfiles: "Tutti i profili",
showArchived: "Mostra archiviati",
lanesByProfile: "Corsie per profilo",
nudgeDispatcher: "Sollecita dispatcher",
refresh: "Aggiorna",
selected: "selezionato/i",
complete: "Completa",
archive: "Archivia",
apply: "Applica",
clear: "Cancella",
createTask: "Crea attività in questa colonna",
noTasks: "— nessuna attività —",
unassigned: "non assegnato",
untitled: "(senza titolo)",
loadingDetail: "Caricamento…",
addComment: "Aggiungi un commento… (Enter per inviare)",
comment: "Commento",
status: "Stato",
workspace: "Workspace",
skills: "Competenze",
createdBy: "Creato da",
result: "Result",
comments: "Commenti",
events: "Eventi",
runHistory: "Cronologia esecuzioni",
workerLog: "Log del worker",
loadingLog: "Caricamento log…",
noWorkerLog:
"— nessun log del worker ancora (l'attività non è stata avviata o il log è stato ruotato) —",
noDescription: "— nessuna descrizione —",
noComments: "— nessun commento —",
edit: "modifica",
save: "Salva",
dependencies: "Dipendenze",
parents: "Padri:",
children: "Figli:",
none: "nessuno",
addParent: "— aggiungi padre —",
addChild: "— aggiungi figlio —",
removeDependency: "Rimuovi dipendenza",
block: "Blocca",
unblock: "Sblocca",
notifyHomeChannels: "Notifica i canali home",
diagnostics: "Diagnostica",
hide: "Nascondi",
show: "Mostra",
attention: "Attenzione",
tasksNeedAttention: "attività richiedono attenzione",
taskNeedsAttention: "1 attività richiede attenzione",
diagnostic: "diagnostica",
open: "Apri",
close: "Chiudi (Esc)",
reassignTo: "Riassegna a:",
copied: "Copiato",
copyCommand: "Copia comando negli appunti",
reclaim: "Recupera",
reassign: "Riassegna",
renderingError: "La scheda Kanban ha avuto un errore di rendering",
reloadView: "Ricarica vista",
wsAuthFailed:
"Autenticazione WebSocket non riuscita — ricarica la pagina per aggiornare il token di sessione.",
markDone: "Contrassegnare {n} attività come completate?",
markArchived: "Archiviare {n} attività?",
warning: "Avviso",
phantomIds: "ID fantasma:",
active: "attivo",
ended: "terminato",
noProfile: "(nessun profilo)",
showAllAttempts: "Mostra tutti i tentativi",
sendingUpdates: "Invio aggiornamenti a",
sendNotifications: "Invia notifiche di completed / blocked / gave_up a",
archiveBoardConfirm:
"Archiviare la bacheca '{name}'? Verrà spostata in boards/_archived/ in modo da poterla recuperare in seguito. Le attività di questa bacheca non appariranno più da nessuna parte nell'UI.",
archiveBoardTitle: "Archivia questa bacheca",
boardSwitcherHint: "Le bacheche ti permettono di separare flussi di lavoro non correlati",
taskCreatedWarning: "Attività creata, ma: ",
moveFailed: "Spostamento non riuscito: ",
bulkFailed: "Massivo: ",
completionBlockedHallucination: "⚠ Completamento bloccato — ID schede fantasma",
suspectedHallucinatedReferences: "⚠ Il testo ha fatto riferimento a ID schede fantasma",
pickProfileFirst: "Scegli prima un profilo.",
unblockedMessage: "Sbloccato {id}. L'attività è pronta per il prossimo tick.",
unblockFailed: "Sblocco non riuscito: ",
reclaimedMessage: "Recuperato {id}. L'attività è di nuovo pronta.",
reclaimFailed: "Recupero non riuscito: ",
reassignedMessage: "Riassegnato {id} a {profile}.",
reassignFailed: "Riassegnazione non riuscita: ",
selectForBulk: "Seleziona per azioni massive",
clickToEdit: "Clicca per modificare",
clickToEditAssignee: "Clicca per modificare l'assegnatario",
emptyAssignee: "(vuoto = rimuovi assegnazione)",
columnLabels: {
triage: "Triage",
todo: "Da fare",
scheduled: "Pianificato",
ready: "Pronto",
running: "In corso",
blocked: "Bloccato",
done: "Fatto",
archived: "Archiviato",
},
columnHelp: {
triage: "Idee grezze — un specifier elaborerà la specifica",
todo: "In attesa di dipendenze o non assegnato",
scheduled: "In attesa di un ritardo noto o di un follow-up pianificato",
ready: "Dipendenze soddisfatte; assegna un profilo per il dispatch",
running: "Preso in carico da un worker — in esecuzione",
blocked: "Il worker ha richiesto input umano",
done: "Completato",
archived: "Archiviato",
},
confirmDone:
"Contrassegnare questa attività come completata? La presa in carico del worker viene rilasciata e i figli dipendenti diventano pronti.",
confirmArchive:
"Archiviare questa attività? Sparirà dalla vista predefinita della bacheca.",
confirmBlocked:
"Contrassegnare questa attività come bloccata? La presa in carico del worker viene rilasciata.",
completionSummary:
"Riepilogo di completamento per {label}. Memorizzato come result dell'attività.",
completionSummaryRequired:
"Il riepilogo di completamento è obbligatorio prima di contrassegnare un'attività come completata.",
triagePlaceholder: "Idea approssimativa — l'IA la specificherà…",
taskTitlePlaceholder: "Titolo della nuova attività…",
specifier: "specifier",
assigneePlaceholder: "assegnatario",
priority: "Priorità",
skillsPlaceholder:
"competenze (facoltative, separate da virgole): translation, github-code-review",
noParent: "— nessun padre —",
workspacePathDir: "percorso del workspace (richiesto, ad es. ~/projects/my-app)",
workspacePathOptional:
"percorso del workspace (facoltativo, derivato dall'assegnatario se vuoto)",
logTruncated: "(mostrando ultimi 100 KB — log completo in ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const ja: Translations = {
common: {
save: "保存",
saving: "保存中...",
cancel: "キャンセル",
close: "閉じる",
confirm: "確認",
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: "メッセージング",
pluginLoadFailed:
"このプラグインのスクリプトを読み込めませんでした。Network タブ(dashboard-plugins/…)とサーバーのプラグインパスをご確認ください。",
pluginNotRegistered:
"プラグインのスクリプトが register() を呼び出していないか、スクリプトでエラーが発生しました。詳細はブラウザのコンソールをご確認ください。",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "ナビゲーションを閉じる",
closeModelTools: "モデルとツールを閉じる",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "アクティブなセッション:",
gatewayStatusLabel: "ゲートウェイの状態:",
gatewayStrip: {
failed: "起動に失敗しました",
off: "オフ",
running: "実行中",
starting: "起動中",
stopped: "停止",
},
nav: {
analytics: "分析",
chat: "チャット",
config: "設定",
cron: "Cron",
documentation: "ドキュメント",
keys: "キー",
logs: "ログ",
models: "モデル",
profiles: "プロファイル : マルチエージェント",
plugins: "プラグイン",
sessions: "セッション",
skills: "スキル",
},
modelToolsSheetSubtitle: "とツール",
modelToolsSheetTitle: "モデル",
navigation: "ナビゲーション",
openDocumentation: "ドキュメントを新しいタブで開く",
openNavigation: "ナビゲーションを開く",
pluginNavSection: "プラグイン",
sessionsActiveCount: "{count} 件アクティブ",
statusOverview: "ステータス概要",
system: "システム",
webUi: "Web UI",
},
status: {
actionFailed: "アクションが失敗しました",
actionFinished: "完了",
actions: "アクション",
agent: "エージェント",
activeSessions: "アクティブなセッション",
connected: "接続済み",
connectedPlatforms: "接続済みプラットフォーム",
disconnected: "切断",
error: "エラー",
failed: "失敗",
gateway: "ゲートウェイ",
gatewayFailedToStart: "ゲートウェイの起動に失敗しました",
lastUpdate: "最終更新",
noneRunning: "なし",
notRunning: "実行されていません",
pid: "PID",
platformDisconnected: "切断",
platformError: "エラー",
recentSessions: "最近のセッション",
restartGateway: "ゲートウェイを再起動",
restartingGateway: "ゲートウェイを再起動しています…",
running: "実行中",
runningRemote: "実行中 (リモート)",
startFailed: "起動に失敗しました",
starting: "起動中",
startedInBackground: "バックグラウンドで起動しました — 進行状況はログをご確認ください",
stopped: "停止",
updateHermes: "Hermes を更新",
updatingHermes: "Hermes を更新しています…",
waitingForOutput: "出力を待機しています…",
},
sessions: {
title: "セッション",
history: "履歴",
overview: "概要",
searchPlaceholder: "メッセージ内容を検索...",
noSessions: "まだセッションがありません",
noMatch: "検索条件に一致するセッションはありません",
startConversation: "会話を開始するとここに表示されます",
noMessages: "メッセージがありません",
untitledSession: "無題のセッション",
deleteSession: "セッションを削除",
confirmDeleteTitle: "セッションを削除しますか?",
confirmDeleteMessage:
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
resumeInChat: "チャットで再開",
previousPage: "前のページ",
nextPage: "次のページ",
roles: {
user: "ユーザー",
assistant: "アシスタント",
system: "システム",
tool: "ツール",
},
},
analytics: {
period: "期間:",
totalTokens: "合計トークン数",
totalSessions: "合計セッション数",
apiCalls: "API 呼び出し",
dailyTokenUsage: "日次トークン使用量",
dailyBreakdown: "日次内訳",
perModelBreakdown: "モデル別内訳",
topSkills: "トップスキル",
skill: "スキル",
loads: "エージェント読み込み",
edits: "エージェント管理",
lastUsed: "最終使用",
input: "入力",
output: "出力",
total: "合計",
noUsageData: "この期間の使用データはありません",
startSession: "セッションを開始すると分析がここに表示されます",
date: "日付",
model: "モデル",
tokens: "トークン",
perDayAvg: "/日 平均",
acrossModels: "{count} モデル全体",
inOut: "{input} 入力 / {output} 出力",
},
models: {
modelsUsed: "使用モデル",
estimatedCost: "推定コスト",
tokens: "トークン",
sessions: "セッション",
avgPerSession: "平均/セッション",
apiCalls: "API 呼び出し",
toolCalls: "ツール呼び出し",
noModelsData: "この期間のモデル使用データはありません",
startSession: "セッションを開始するとモデルデータがここに表示されます",
},
logs: {
title: "ログ",
autoRefresh: "自動更新",
file: "ファイル",
level: "レベル",
component: "コンポーネント",
lines: "行数",
noLogLines: "ログ行が見つかりません",
},
cron: {
confirmDeleteMessage:
"ジョブをスケジュールから削除します。この操作は取り消せません。",
confirmDeleteTitle: "スケジュールされたジョブを削除しますか?",
newJob: "新しい Cron ジョブ",
nameOptional: "名前 (任意)",
namePlaceholder: "例: 日次サマリー",
prompt: "プロンプト",
promptPlaceholder: "実行ごとにエージェントが行う内容は?",
schedule: "スケジュール (cron 式)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "配信先",
scheduledJobs: "スケジュール済みジョブ",
noJobs: "Cron ジョブが設定されていません。上で作成してください。",
last: "前回",
next: "次回",
pause: "一時停止",
resume: "再開",
triggerNow: "今すぐ実行",
delivery: {
local: "ローカル",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "新しいプロファイル",
name: "名前",
namePlaceholder: "例: coder, writer など",
nameRequired: "名前は必須です",
nameRule:
"小文字、数字、_ および - のみ使用可能。最初は文字または数字で始める必要があります。最大 64 文字。",
invalidName: "無効なプロファイル名",
cloneFromDefault: "デフォルトプロファイルから設定を複製",
allProfiles: "プロファイル",
noProfiles: "プロファイルが見つかりません。",
defaultBadge: "デフォルト",
hasEnv: "env",
model: "モデル",
skills: "スキル",
rename: "名前を変更",
editSoul: "SOUL.md を編集",
soulSection: "SOUL.md (パーソナリティ / システムプロンプト)",
soulPlaceholder: "# このエージェントの振る舞い…",
saveSoul: "SOUL を保存",
soulSaved: "SOUL.md を保存しました",
openInTerminal: "CLI コマンドをコピー",
commandCopied: "クリップボードにコピーしました",
copyFailed: "コピーできませんでした",
confirmDeleteTitle: "プロファイルを削除しますか?",
confirmDeleteMessage:
"プロファイル '{name}' を完全に削除します — 設定、キー、メモリ、セッション、スキル、cron ジョブ。この操作は取り消せません。",
created: "作成しました",
deleted: "削除しました",
renamed: "名前を変更しました",
},
pluginsPage: {
contextEngineLabel: "コンテキストエンジン",
dashboardSlots: "ダッシュボードスロット",
disableRuntime: "無効化",
enableAfterInstall: "インストール後に有効化",
enableRuntime: "有効化",
forceReinstall: "強制再インストール (既存のフォルダを先に削除)",
headline:
"Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。",
identifierLabel: "Git URL または owner/repo",
inactive: "非アクティブ",
installBtn: "インストール",
installHeading: "GitHub / Git URL からインストール",
installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。",
memoryProviderLabel: "メモリプロバイダー",
missingEnvWarn: "プラグインを実行する前にこれらをキーに設定してください:",
noDashboardTab: "ダッシュボードタブなし",
openTab: "開く",
orphanHeading: "ダッシュボード専用拡張 (該当する agent plugin.yaml なし)",
pluginListHeading: "インストール済みプラグイン",
providerDefaults: "組み込み / デフォルト",
providersHeading: "ランタイムプロバイダープラグイン",
providersHint:
"memory.provider (空 = 組み込み) と context.engine を config.yaml に書き込みます。次のセッションで有効になります。",
refreshDashboard: "ダッシュボード拡張を再スキャン",
removeConfirm: "このプラグインを ~/.hermes/plugins/ から削除しますか?",
removeHint: "削除できるのは ~/.hermes/plugins 配下のユーザーがインストールしたプラグインのみです。",
rescanHeading: "SPA プラグインレジストリ",
rescanHint: "ディスクにファイルを追加した後に再スキャンすると、ダッシュボードのサイドバーが新しいマニフェストを認識します。",
runtimeHeading: "ゲートウェイランタイム (YAML プラグイン)",
saveProviders: "プロバイダー設定を保存",
savedProviders: "プロバイダー設定を保存しました。",
sourceBadge: "ソース",
authRequired: "認証が必要",
authRequiredHint: "認証するには次のコマンドを実行してください:",
updateGit: "Git pull",
versionBadge: "バージョン",
showInSidebar: "サイドバーに表示",
hideFromSidebar: "サイドバーから非表示",
},
skills: {
title: "スキル",
searchPlaceholder: "スキルとツールセットを検索...",
enabledOf: "{enabled}/{total} 有効",
all: "すべて",
categories: "カテゴリ",
filters: "フィルター",
noSkills: "スキルが見つかりません。スキルは ~/.hermes/skills/ から読み込まれます",
noSkillsMatch: "検索またはフィルターに一致するスキルはありません。",
skillCount: "{count} スキル{s}",
resultCount: "{count} 件の結果{s}",
noDescription: "説明はありません。",
toolsets: "ツールセット",
toolsetLabel: "{name} ツールセット",
noToolsetsMatch: "検索に一致するツールセットはありません。",
setupNeeded: "セットアップが必要",
disabledForCli: "CLI では無効",
more: "+{count} 件",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "フィルター",
sections: "セクション",
exportConfig: "設定を JSON としてエクスポート",
importConfig: "JSON から設定をインポート",
resetDefaults: "デフォルトにリセット",
resetScopeTooltip: "{scope} をデフォルトにリセット",
confirmResetScope: "すべての {scope} 設定をデフォルトにリセットしますか?フォームのみ更新されます — 保存を押すまで config.yaml には書き込まれません。",
resetScopeToast: "{scope} をデフォルトにリセットしました — 確認して保存してください",
rawYaml: "生の YAML 設定",
searchResults: "検索結果",
fields: "フィールド{s}",
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: {
changesNote: "変更は即座にディスクへ保存されます。アクティブなセッションは新しいキーを自動的に取得します。",
confirmClearMessage:
"この変数の保存値が .env ファイルから削除されます。この操作は UI から取り消せません。",
confirmClearTitle: "このキーをクリアしますか?",
description: "API キーとシークレットを管理します。保存先:",
hideAdvanced: "詳細設定を隠す",
showAdvanced: "詳細設定を表示",
showLess: "表示を減らす",
showMore: "もっと見る",
llmProviders: "LLM プロバイダー",
providersConfigured: "{configured} / {total} プロバイダーが設定済み",
getKey: "キーを取得",
notConfigured: "{count} 件未設定",
notSet: "未設定",
keysCount: "{count} キー{s}",
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: "コピー",
copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
connect: "接続",
sessionExpires: "セッションは {time} 後に期限切れになります",
initiatingLogin: "ログインフローを開始しています…",
exchangingCode: "コードをトークンと交換しています…",
connectedClosing: "接続しました!閉じています…",
loginFailed: "ログインに失敗しました。",
sessionExpired: "セッションの有効期限が切れました。再試行をクリックして新しいログインを開始してください。",
reOpenAuth: "認証ページを再度開く",
reOpenVerification: "確認ページを再度開く",
submitCode: "コードを送信",
pasteCode: "認可コードを貼り付け (#state サフィックス付きでも問題ありません)",
waitingAuth: "ブラウザでの認可をお待ちしています…",
enterCodePrompt: "新しいタブが開きました。プロンプトが表示されたらこのコードを入力してください:",
pkceStep1: "claude.ai への新しいタブが開きました。サインインして「Authorize」をクリックしてください。",
pkceStep2: "認可後に表示される認可コードをコピーしてください。",
pkceStep3: "下に貼り付けて送信してください。",
flowLabels: {
pkce: "ブラウザログイン (PKCE)",
device_code: "デバイスコード",
external: "外部 CLI",
},
expiresIn: "{time} 後に期限切れ",
},
language: {
switchTo: "言語を切り替え",
},
theme: {
title: "テーマ",
switchTheme: "テーマを切り替え",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"実際のセッション履歴から獲得できる Hermes のコレクタブル バッジです。既知の未達成の実績は「Discovered」として表示され、Secret 実績は最初の該当する挙動が検出されるまで非表示のままです。",
scan_subtitle:
"Hermes のセッション履歴をスキャンしています。履歴が大きい場合、初回スキャンには 5~10 秒かかることがあります。",
},
actions: {
rescan: "再スキャン",
},
stats: {
unlocked: "解除済み",
unlocked_hint: "獲得したバッジ",
discovered: "発見済み",
discovered_hint: "判明していますが未獲得",
secrets: "シークレット",
secrets_hint: "最初のシグナルまで非表示",
highest_tier: "最高ティア",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "最新",
latest_hint_empty: "Hermes をもっと使ってみてください",
none_yet: "まだありません",
},
state: {
unlocked: "解除済み",
discovered: "発見済み",
secret: "シークレット",
},
tier: {
target: "目標 {tier}",
hidden: "非表示",
complete: "達成",
objective: "目的",
},
progress: {
hidden: "非表示",
},
scan: {
building_headline: "実績プロファイルを構築中…",
building_detail:
"セッション、ツール呼び出し、モデルのメタデータ、解除状態を読み込んでいます。",
starting_headline: "実績スキャンを開始しています…",
progress_detail:
"{total} 件中 {scanned} 件のセッションをスキャンしました · {pct}%。履歴が読み込まれるにつれてバッジが解除されます。",
idle_detail:
"セッション、ツール呼び出し、モデルのメタデータ、解除状態を読み込んでいます。バッジは解除され次第ここに表示されます。",
},
guide: {
tiers_header: "ティア",
secret_header: "シークレット実績",
secret_body:
"シークレットはトリガー条件を隠しています。Hermes が関連するシグナルを検出すると、カードは「Discovered」になり、要件が表示されます。",
scan_status_header: "スキャン状況",
scan_status_body:
"Hermes はローカル履歴を一度スキャンし、その後カードが自動的に表示されます。数秒かかってもスタックしているわけではありません。",
what_scanned_header: "スキャン対象",
what_scanned_body:
"セッション、ツール呼び出し、モデルのメタデータ、エラー、実績、ローカルの解除状態。",
},
card: {
share_title: "この実績を共有",
share_label: "{name} を共有",
share_text: "共有",
how_to_reveal: "解除する方法",
what_counts: "対象となる条件",
evidence_label: "エビデンス",
evidence_session_fallback: "セッション",
no_evidence: "エビデンスはまだありません",
},
latest: {
header: "最近の解除",
},
empty: {
no_secrets_header: "このスキャンに残っている隠しシークレットはありません。",
no_secrets_body:
"ヒント: シークレットは通常、想定外の失敗やパワーユーザー的なパターンから生まれます — ポート競合、権限の壁、環境変数の不足、YAML のミス、Docker の衝突、ロールバックやチェックポイントの利用、キャッシュヒット、あるいは大量の赤いエラーの後の小さな修正など。",
},
filters: {
all_categories: "すべて",
visibility_all: "すべて",
visibility_unlocked: "解除済み",
visibility_discovered: "発見済み",
visibility_secret: "シークレット",
},
share: {
dialog_label: "実績を共有",
header: "共有: {name}",
close: "閉じる",
rendering: "描画中…",
card_alt: "{name} の共有カード",
error_generic: "問題が発生しました。",
x_title: "事前入力された投稿で X を開きます",
x_button: "X で共有",
copy_title: "投稿に貼り付けるために画像をコピーします",
copy_button: "画像をコピー",
copied: "コピーしました ✓",
download_button: "PNG をダウンロード",
hint:
"「X で共有」は事前入力された投稿を新しいタブで開きます。1200×630 のバッジを添付したい場合は、先に「画像をコピー」を押してください — X では投稿エディタに直接貼り付けられます。「PNG をダウンロード」はファイルとして保存し、どこでも使えるようにします。",
clipboard_unsupported:
"このブラウザではクリップボードへの画像コピーがサポートされていません — 代わりに「ダウンロード」をご利用ください。",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban ボードを読み込んでいます…",
loadFailed: "Kanban ボードの読み込みに失敗しました: ",
loadFailedHint:
"バックエンドは初回読み込み時に kanban.db を自動作成します。問題が続く場合は、ダッシュボードのログをご確認ください。",
board: "ボード",
newBoard: "+ 新しいボード",
newBoardTitle: "新しいボード",
newBoardDescription:
"ボードを使うと、関連のない作業の流れを分けられます — プロジェクト、リポジトリ、ドメインごとに 1 つずつ。あるボードのワーカーは、別のボードのタスクを見ることはありません。",
slug: "スラッグ",
slugHint: "— 小文字とハイフン、例: atm10-server",
displayName: "表示名",
displayNameHint: "(任意)",
description: "説明",
descriptionHint: "(任意)",
icon: "アイコン",
iconHint: "1 文字または絵文字)",
switchAfterCreate: "作成後にこのボードへ切り替える",
cancel: "キャンセル",
creating: "作成中…",
createBoard: "ボードを作成",
search: "検索",
filterCards: "カードを絞り込む…",
tenant: "テナント",
allTenants: "すべてのテナント",
assignee: "担当者",
allProfiles: "すべてのプロファイル",
showArchived: "アーカイブ済みを表示",
lanesByProfile: "プロファイル別レーン",
nudgeDispatcher: "ディスパッチャーを起動",
refresh: "更新",
selected: "選択中",
complete: "完了",
archive: "アーカイブ",
apply: "適用",
clear: "クリア",
createTask: "この列にタスクを作成",
noTasks: "— タスクはありません —",
unassigned: "未割り当て",
untitled: "(タイトルなし)",
loadingDetail: "読み込み中…",
addComment: "コメントを追加…(Enter で送信)",
comment: "コメント",
status: "ステータス",
workspace: "ワークスペース",
skills: "スキル",
createdBy: "作成者",
result: "結果",
comments: "コメント",
events: "イベント",
runHistory: "実行履歴",
workerLog: "ワーカーログ",
loadingLog: "ログを読み込んでいます…",
noWorkerLog:
"— ワーカーログはまだありません(タスクが起動していないか、ログがローテーションされました)—",
noDescription: "— 説明はありません —",
noComments: "— コメントはありません —",
edit: "編集",
save: "保存",
dependencies: "依存関係",
parents: "親タスク:",
children: "子タスク:",
none: "なし",
addParent: "— 親タスクを追加 —",
addChild: "— 子タスクを追加 —",
removeDependency: "依存関係を削除",
block: "ブロック",
unblock: "ブロック解除",
notifyHomeChannels: "ホームチャンネルに通知する",
diagnostics: "診断情報",
hide: "非表示",
show: "表示",
attention: "注意",
tasksNeedAttention: "件のタスクが対応を必要としています",
taskNeedsAttention: "1 件のタスクが対応を必要としています",
diagnostic: "診断",
open: "開く",
close: "閉じる (Esc)",
reassignTo: "再割り当て先:",
copied: "コピーしました",
copyCommand: "コマンドをクリップボードにコピー",
reclaim: "回収",
reassign: "再割り当て",
renderingError: "Kanban タブで描画エラーが発生しました",
reloadView: "ビューを再読み込み",
wsAuthFailed:
"WebSocket 認証に失敗しました — ページを再読み込みしてセッショントークンを更新してください。",
markDone: "{n} 件のタスクを完了にしますか?",
markArchived: "{n} 件のタスクをアーカイブしますか?",
warning: "警告",
phantomIds: "ファントム ID:",
active: "実行中",
ended: "終了",
noProfile: "(プロファイルなし)",
showAllAttempts: "すべての試行を表示",
sendingUpdates: "更新の送信先: ",
sendNotifications: "完了 / ブロック / 諦めの通知の送信先",
archiveBoardConfirm:
"ボード「{name}」をアーカイブしますか?ボードは boards/_archived/ に移動され、後で復元できます。このボード上のタスクは UI のどこにも表示されなくなります。",
archiveBoardTitle: "このボードをアーカイブ",
boardSwitcherHint: "ボードを使うと、関連のない作業の流れを分けられます",
taskCreatedWarning: "タスクは作成されましたが: ",
moveFailed: "移動に失敗しました: ",
bulkFailed: "一括処理: ",
completionBlockedHallucination: "⚠ 完了がブロックされました — ファントムカード ID",
suspectedHallucinatedReferences: "⚠ 本文がファントムカード ID を参照しています",
pickProfileFirst: "まずプロファイルを選択してください。",
unblockedMessage: "{id} のブロックを解除しました。タスクは次のティックの準備ができています。",
unblockFailed: "ブロック解除に失敗しました: ",
reclaimedMessage: "{id} を回収しました。タスクは ready に戻りました。",
reclaimFailed: "回収に失敗しました: ",
reassignedMessage: "{id} を {profile} に再割り当てしました。",
reassignFailed: "再割り当てに失敗しました: ",
selectForBulk: "一括操作のために選択",
clickToEdit: "クリックして編集",
clickToEditAssignee: "クリックして担当者を編集",
emptyAssignee: "(空 = 割り当て解除)",
columnLabels: {
triage: "トリアージ",
todo: "ToDo",
scheduled: "スケジュール済み",
ready: "準備完了",
running: "進行中",
blocked: "ブロック中",
done: "完了",
archived: "アーカイブ済み",
},
columnHelp: {
triage: "未整理のアイデア — スペシファイアが仕様を肉付けします",
todo: "依存関係の待機中、または未割り当て",
scheduled: "既知の時間遅延またはスケジュール済みのフォローアップ待ち",
ready: "依存関係は満たされています。ディスパッチするにはプロファイルを割り当ててください",
running: "ワーカーが取得中 — 実行中",
blocked: "ワーカーが人間の入力を求めています",
done: "完了",
archived: "アーカイブ済み",
},
confirmDone:
"このタスクを完了にしますか?ワーカーの取得は解放され、依存している子タスクが ready になります。",
confirmArchive:
"このタスクをアーカイブしますか?既定のボードビューから消えます。",
confirmBlocked:
"このタスクをブロック中にしますか?ワーカーの取得は解放されます。",
completionSummary:
"{label} の完了サマリ。これはタスクの結果として保存されます。",
completionSummaryRequired:
"タスクを完了にする前に、完了サマリの入力が必要です。",
triagePlaceholder: "おおまかなアイデア — AI が仕様化します…",
taskTitlePlaceholder: "新しいタスクのタイトル…",
specifier: "スペシファイア",
assigneePlaceholder: "担当者",
priority: "優先度",
skillsPlaceholder:
"スキル(任意、カンマ区切り): translation, github-code-review",
noParent: "— 親タスクなし —",
workspacePathDir: "ワークスペースのパス(必須、例: ~/projects/my-app",
workspacePathOptional:
"ワークスペースのパス(任意、空の場合は担当者から導出)",
logTruncated: "(最後の 100 KB を表示中 — 完全なログは ",
logAt: "",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const ko: Translations = {
common: {
save: "저장",
saving: "저장 중...",
cancel: "취소",
close: "닫기",
confirm: "확인",
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: "메시징",
pluginLoadFailed:
"이 플러그인의 스크립트를 로드할 수 없습니다. Network 탭(dashboard-plugins/…)과 서버의 플러그인 경로를 확인하세요.",
pluginNotRegistered:
"플러그인 스크립트가 register()를 호출하지 않았거나 스크립트에 오류가 발생했습니다. 자세한 내용은 브라우저 콘솔을 열어 확인하세요.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "내비게이션 닫기",
closeModelTools: "모델 및 도구 닫기",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "활성 세션:",
gatewayStatusLabel: "게이트웨이 상태:",
gatewayStrip: {
failed: "시작 실패",
off: "꺼짐",
running: "실행 중",
starting: "시작 중",
stopped: "중지됨",
},
nav: {
analytics: "분석",
chat: "채팅",
config: "설정",
cron: "Cron",
documentation: "문서",
keys: "키",
logs: "로그",
models: "모델",
profiles: "프로필: 멀티 에이전트",
plugins: "플러그인",
sessions: "세션",
skills: "스킬",
},
modelToolsSheetSubtitle: "및 도구",
modelToolsSheetTitle: "모델",
navigation: "내비게이션",
openDocumentation: "새 탭에서 문서 열기",
openNavigation: "내비게이션 열기",
pluginNavSection: "플러그인",
sessionsActiveCount: "{count}개 활성",
statusOverview: "상태 개요",
system: "시스템",
webUi: "Web UI",
},
status: {
actionFailed: "작업 실패",
actionFinished: "완료됨",
actions: "작업",
agent: "에이전트",
activeSessions: "활성 세션",
connected: "연결됨",
connectedPlatforms: "연결된 플랫폼",
disconnected: "연결 끊김",
error: "오류",
failed: "실패",
gateway: "게이트웨이",
gatewayFailedToStart: "게이트웨이 시작 실패",
lastUpdate: "마지막 업데이트",
noneRunning: "없음",
notRunning: "실행 중이 아님",
pid: "PID",
platformDisconnected: "연결 끊김",
platformError: "오류",
recentSessions: "최근 세션",
restartGateway: "게이트웨이 재시작",
restartingGateway: "게이트웨이 재시작 중…",
running: "실행 중",
runningRemote: "실행 중 (원격)",
startFailed: "시작 실패",
starting: "시작 중",
startedInBackground: "백그라운드에서 시작됨 — 진행 상황은 로그를 확인하세요",
stopped: "중지됨",
updateHermes: "Hermes 업데이트",
updatingHermes: "Hermes 업데이트 중…",
waitingForOutput: "출력 대기 중…",
},
sessions: {
title: "세션",
history: "기록",
overview: "개요",
searchPlaceholder: "메시지 내용 검색...",
noSessions: "아직 세션이 없습니다",
noMatch: "검색과 일치하는 세션이 없습니다",
startConversation: "대화를 시작하면 여기에 표시됩니다",
noMessages: "메시지가 없습니다",
untitledSession: "제목 없는 세션",
deleteSession: "세션 삭제",
confirmDeleteTitle: "세션을 삭제하시겠습니까?",
confirmDeleteMessage:
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
roles: {
user: "사용자",
assistant: "어시스턴트",
system: "시스템",
tool: "도구",
},
},
analytics: {
period: "기간:",
totalTokens: "총 토큰",
totalSessions: "총 세션",
apiCalls: "API 호출",
dailyTokenUsage: "일일 토큰 사용량",
dailyBreakdown: "일별 내역",
perModelBreakdown: "모델별 내역",
topSkills: "주요 스킬",
skill: "스킬",
loads: "에이전트 로드됨",
edits: "에이전트 관리",
lastUsed: "마지막 사용",
input: "입력",
output: "출력",
total: "합계",
noUsageData: "이 기간에 대한 사용 데이터가 없습니다",
startSession: "세션을 시작하면 여기에 분석이 표시됩니다",
date: "날짜",
model: "모델",
tokens: "토큰",
perDayAvg: "/일 평균",
acrossModels: "{count}개 모델 전반",
inOut: "입력 {input} / 출력 {output}",
},
models: {
modelsUsed: "사용된 모델",
estimatedCost: "예상 비용",
tokens: "토큰",
sessions: "세션",
avgPerSession: "세션당 평균",
apiCalls: "API 호출",
toolCalls: "도구 호출",
noModelsData: "이 기간에 대한 모델 사용 데이터가 없습니다",
startSession: "세션을 시작하면 여기에 모델 데이터가 표시됩니다",
},
logs: {
title: "로그",
autoRefresh: "자동 새로고침",
file: "파일",
level: "레벨",
component: "구성 요소",
lines: "줄 수",
noLogLines: "로그 줄을 찾을 수 없습니다",
},
cron: {
confirmDeleteMessage:
"이 작업은 일정에서 작업을 제거합니다. 되돌릴 수 없습니다.",
confirmDeleteTitle: "예약된 작업을 삭제하시겠습니까?",
newJob: "새 Cron 작업",
nameOptional: "이름 (선택 사항)",
namePlaceholder: "예: 일일 요약",
prompt: "프롬프트",
promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?",
schedule: "스케줄 (cron 표현식)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "전달 대상",
scheduledJobs: "예약된 작업",
noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.",
last: "마지막",
next: "다음",
pause: "일시 정지",
resume: "재개",
triggerNow: "지금 실행",
delivery: {
local: "로컬",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "새 프로필",
name: "이름",
namePlaceholder: "예: coder, writer 등.",
nameRequired: "이름은 필수입니다",
nameRule:
"소문자, 숫자, _ 및 - 만 사용 가능합니다. 문자나 숫자로 시작해야 하며 최대 64자입니다.",
invalidName: "잘못된 프로필 이름입니다",
cloneFromDefault: "기본 프로필에서 설정 복제",
allProfiles: "프로필",
noProfiles: "프로필을 찾을 수 없습니다.",
defaultBadge: "기본",
hasEnv: "env",
model: "모델",
skills: "스킬",
rename: "이름 변경",
editSoul: "SOUL.md 편집",
soulSection: "SOUL.md (개성 / 시스템 프롬프트)",
soulPlaceholder: "# 이 에이전트가 어떻게 동작해야 하는지…",
saveSoul: "SOUL 저장",
soulSaved: "SOUL.md가 저장되었습니다",
openInTerminal: "CLI 명령 복사",
commandCopied: "클립보드에 복사되었습니다",
copyFailed: "복사할 수 없습니다",
confirmDeleteTitle: "프로필을 삭제하시겠습니까?",
confirmDeleteMessage:
"이 작업은 '{name}' 프로필 — 설정, 키, 메모리, 세션, 스킬, cron 작업 — 을 영구적으로 삭제합니다. 되돌릴 수 없습니다.",
created: "생성됨",
deleted: "삭제됨",
renamed: "이름 변경됨",
},
pluginsPage: {
contextEngineLabel: "컨텍스트 엔진",
dashboardSlots: "대시보드 슬롯",
disableRuntime: "비활성화",
enableAfterInstall: "설치 후 활성화",
enableRuntime: "활성화",
forceReinstall: "강제 재설치 (기존 폴더를 먼저 삭제)",
headline:
"Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).",
identifierLabel: "Git URL 또는 owner/repo",
inactive: "비활성",
installBtn: "설치",
installHeading: "GitHub / Git URL에서 설치",
installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.",
memoryProviderLabel: "메모리 제공자",
missingEnvWarn: "플러그인을 실행하기 전에 Keys에서 다음 항목을 설정하세요:",
noDashboardTab: "대시보드 탭 없음",
openTab: "열기",
orphanHeading: "대시보드 전용 확장 (일치하는 agent plugin.yaml 없음)",
pluginListHeading: "설치된 플러그인",
providerDefaults: "내장 / 기본",
providersHeading: "런타임 제공자 플러그인",
providersHint:
"memory.provider (비어 있으면 = 내장)와 context.engine을 config.yaml에 기록합니다. 다음 세션부터 적용됩니다.",
refreshDashboard: "대시보드 확장 재스캔",
removeConfirm: "~/.hermes/plugins/에서 이 플러그인을 제거하시겠습니까?",
removeHint: "~/.hermes/plugins 아래에 사용자가 설치한 플러그인만 제거할 수 있습니다.",
rescanHeading: "SPA 플러그인 레지스트리",
rescanHint: "디스크에 파일을 추가한 후 재스캔하여 대시보드 사이드바가 새 매니페스트를 인식하도록 합니다.",
runtimeHeading: "게이트웨이 런타임 (YAML 플러그인)",
saveProviders: "제공자 설정 저장",
savedProviders: "제공자 설정이 저장되었습니다.",
sourceBadge: "소스",
authRequired: "인증 필요",
authRequiredHint: "이 명령을 실행하여 인증하세요:",
updateGit: "Git pull",
versionBadge: "버전",
showInSidebar: "사이드바에 표시",
hideFromSidebar: "사이드바에서 숨기기",
},
skills: {
title: "스킬",
searchPlaceholder: "스킬 및 도구 세트 검색...",
enabledOf: "{enabled}/{total} 활성화됨",
all: "전체",
categories: "카테고리",
filters: "필터",
noSkills: "스킬을 찾을 수 없습니다. 스킬은 ~/.hermes/skills/ 에서 로드됩니다",
noSkillsMatch: "검색이나 필터와 일치하는 스킬이 없습니다.",
skillCount: "{count}개 스킬",
resultCount: "{count}개 결과",
noDescription: "사용 가능한 설명이 없습니다.",
toolsets: "도구 세트",
toolsetLabel: "{name} 도구 세트",
noToolsetsMatch: "검색과 일치하는 도구 세트가 없습니다.",
setupNeeded: "설정 필요",
disabledForCli: "CLI에서 비활성화됨",
more: "+{count}개 더",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "필터",
sections: "섹션",
exportConfig: "설정을 JSON으로 내보내기",
importConfig: "JSON에서 설정 가져오기",
resetDefaults: "기본값으로 재설정",
resetScopeTooltip: "{scope}을(를) 기본값으로 재설정",
confirmResetScope: "모든 {scope} 설정을 기본값으로 재설정하시겠습니까? 이 작업은 양식만 업데이트하며, 저장을 누르기 전까지는 변경 사항이 config.yaml에 기록되지 않습니다.",
resetScopeToast: "{scope}이(가) 기본값으로 재설정되었습니다 — 검토 후 저장하여 적용하세요",
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: {
changesNote: "변경 사항은 즉시 디스크에 저장됩니다. 활성 세션은 자동으로 새 키를 가져옵니다.",
confirmClearMessage:
"이 변수에 대해 저장된 값이 .env 파일에서 제거됩니다. UI에서는 이 작업을 되돌릴 수 없습니다.",
confirmClearTitle: "이 키를 지우시겠습니까?",
description: "다음 위치에 저장된 API 키와 비밀을 관리합니다",
hideAdvanced: "고급 숨기기",
showAdvanced: "고급 표시",
showLess: "간략히",
showMore: "더 보기",
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: "복사",
copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
connect: "연결",
sessionExpires: "세션이 {time} 후 만료됩니다",
initiatingLogin: "로그인 흐름 시작 중…",
exchangingCode: "코드를 토큰으로 교환 중…",
connectedClosing: "연결되었습니다! 닫는 중…",
loginFailed: "로그인 실패.",
sessionExpired: "세션이 만료되었습니다. 다시 시도를 클릭하여 새 로그인을 시작하세요.",
reOpenAuth: "인증 페이지 다시 열기",
reOpenVerification: "확인 페이지 다시 열기",
submitCode: "코드 제출",
pasteCode: "인증 코드 붙여넣기 (#state 접미사 포함도 가능)",
waitingAuth: "브라우저에서 인증을 기다리는 중…",
enterCodePrompt: "새 탭이 열렸습니다. 메시지가 표시되면 이 코드를 입력하세요:",
pkceStep1: "claude.ai로 새 탭이 열렸습니다. 로그인하고 Authorize를 클릭하세요.",
pkceStep2: "인증 후 표시된 인증 코드를 복사하세요.",
pkceStep3: "아래에 붙여넣고 제출하세요.",
flowLabels: {
pkce: "브라우저 로그인 (PKCE)",
device_code: "디바이스 코드",
external: "외부 CLI",
},
expiresIn: "{time} 후 만료",
},
language: {
switchTo: "언어 변경",
},
theme: {
title: "테마",
switchTheme: "테마 전환",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"실제 세션 기록에서 획득하는 Hermes 컬렉터블 배지입니다. 알려져 있지만 아직 달성되지 않은 업적은 Discovered로 표시되며, Secret 업적은 일치하는 동작이 처음 나타날 때까지 숨겨집니다.",
scan_subtitle:
"Hermes 세션 기록을 스캔하고 있습니다. 기록이 많으면 첫 스캔에 5~10초가 걸릴 수 있습니다.",
},
actions: {
rescan: "다시 스캔",
},
stats: {
unlocked: "해제됨",
unlocked_hint: "획득한 배지",
discovered: "발견됨",
discovered_hint: "알려져 있으나 아직 획득하지 못함",
secrets: "시크릿",
secrets_hint: "첫 신호가 있을 때까지 숨겨짐",
highest_tier: "최고 등급",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "최근",
latest_hint_empty: "Hermes를 더 사용해 보세요",
none_yet: "아직 없음",
},
state: {
unlocked: "해제됨",
discovered: "발견됨",
secret: "시크릿",
},
tier: {
target: "목표 {tier}",
hidden: "숨김",
complete: "완료",
objective: "목표",
},
progress: {
hidden: "숨김",
},
scan: {
building_headline: "업적 프로필을 구성하고 있습니다…",
building_detail:
"세션, 도구 호출, 모델 메타데이터, 해제 상태를 읽고 있습니다.",
starting_headline: "업적 스캔을 시작합니다…",
progress_detail:
"{total}개 중 {scanned}개의 세션을 스캔했습니다 · {pct}%. 더 많은 기록이 들어오면 배지가 해제됩니다.",
idle_detail:
"세션, 도구 호출, 모델 메타데이터, 해제 상태를 읽고 있습니다. 배지가 해제되면 여기에 표시됩니다.",
},
guide: {
tiers_header: "등급",
secret_header: "시크릿 업적",
secret_body:
"시크릿은 정확한 트리거 조건을 숨깁니다. Hermes가 관련 신호를 감지하면 카드가 Discovered로 바뀌고 요건이 표시됩니다.",
scan_status_header: "스캔 상태",
scan_status_body:
"Hermes는 로컬 기록을 한 번 스캔한 뒤 카드를 자동으로 표시합니다. 몇 초 걸리더라도 멈춘 것이 아닙니다.",
what_scanned_header: "스캔 대상",
what_scanned_body:
"세션, 도구 호출, 모델 메타데이터, 오류, 업적 및 로컬 해제 상태입니다.",
},
card: {
share_title: "이 업적 공유",
share_label: "{name} 공유",
share_text: "공유",
how_to_reveal: "공개하는 방법",
what_counts: "인정되는 조건",
evidence_label: "근거",
evidence_session_fallback: "세션",
no_evidence: "아직 근거가 없습니다",
},
latest: {
header: "최근 해제",
},
empty: {
no_secrets_header: "이번 스캔에 남은 숨겨진 시크릿이 없습니다.",
no_secrets_body:
"힌트: 시크릿은 보통 비정상적인 실패나 파워 유저 패턴에서 시작됩니다 — 포트 충돌, 권한 차단, 누락된 환경 변수, YAML 실수, Docker 충돌, 롤백/체크포인트 사용, 캐시 적중, 또는 많은 오류 메시지 뒤의 작은 수정 등입니다.",
},
filters: {
all_categories: "전체",
visibility_all: "전체",
visibility_unlocked: "해제됨",
visibility_discovered: "발견됨",
visibility_secret: "시크릿",
},
share: {
dialog_label: "업적 공유",
header: "공유: {name}",
close: "닫기",
rendering: "렌더링 중…",
card_alt: "{name} 공유 카드",
error_generic: "문제가 발생했습니다.",
x_title: "미리 작성된 게시물로 X를 엽니다",
x_button: "X에 공유",
copy_title: "게시물에 붙여넣을 수 있도록 이미지를 복사합니다",
copy_button: "이미지 복사",
copied: "복사됨 ✓",
download_button: "PNG 다운로드",
hint:
"X에 공유를 누르면 새 탭에서 미리 작성된 게시물이 열립니다. 1200×630 배지를 첨부하려면 먼저 이미지 복사를 누르세요 — X 작성기에서 바로 붙여넣을 수 있습니다. PNG 다운로드는 파일을 저장하여 어디서나 사용할 수 있게 합니다.",
clipboard_unsupported:
"이 브라우저에서는 클립보드 이미지 복사를 지원하지 않습니다 — 대신 다운로드를 이용하세요.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban 보드를 불러오는 중입니다…",
loadFailed: "Kanban 보드를 불러오지 못했습니다: ",
loadFailedHint:
"백엔드는 처음 읽을 때 kanban.db를 자동으로 생성합니다. 문제가 계속되면 대시보드 로그를 확인하십시오.",
board: "보드",
newBoard: "+ 새 보드",
newBoardTitle: "새 보드",
newBoardDescription:
"보드를 사용하면 관련 없는 작업 흐름을 분리할 수 있습니다 — 프로젝트, 저장소, 도메인마다 하나씩. 한 보드의 워커는 다른 보드의 작업을 절대 보지 않습니다.",
slug: "슬러그",
slugHint: "— 소문자, 하이픈, 예: atm10-server",
displayName: "표시 이름",
displayNameHint: "(선택)",
description: "설명",
descriptionHint: "(선택)",
icon: "아이콘",
iconHint: "(한 글자 또는 이모지)",
switchAfterCreate: "생성 후 이 보드로 전환",
cancel: "취소",
creating: "생성 중…",
createBoard: "보드 생성",
search: "검색",
filterCards: "카드 필터링…",
tenant: "테넌트",
allTenants: "모든 테넌트",
assignee: "담당자",
allProfiles: "모든 프로필",
showArchived: "보관된 항목 표시",
lanesByProfile: "프로필별 레인",
nudgeDispatcher: "디스패처 깨우기",
refresh: "새로 고침",
selected: "선택됨",
complete: "완료",
archive: "보관",
apply: "적용",
clear: "지우기",
createTask: "이 열에 작업 만들기",
noTasks: "— 작업 없음 —",
unassigned: "미지정",
untitled: "(제목 없음)",
loadingDetail: "불러오는 중…",
addComment: "댓글 추가… (Enter로 전송)",
comment: "댓글",
status: "상태",
workspace: "작업 공간",
skills: "스킬",
createdBy: "작성자",
result: "결과",
comments: "댓글",
events: "이벤트",
runHistory: "실행 기록",
workerLog: "워커 로그",
loadingLog: "로그를 불러오는 중…",
noWorkerLog:
"— 아직 워커 로그가 없습니다 (작업이 시작되지 않았거나 로그가 순환되었습니다) —",
noDescription: "— 설명 없음 —",
noComments: "— 댓글 없음 —",
edit: "편집",
save: "저장",
dependencies: "종속성",
parents: "상위 작업:",
children: "하위 작업:",
none: "없음",
addParent: "— 상위 작업 추가 —",
addChild: "— 하위 작업 추가 —",
removeDependency: "종속성 제거",
block: "차단",
unblock: "차단 해제",
notifyHomeChannels: "홈 채널에 알림",
diagnostics: "진단",
hide: "숨기기",
show: "표시",
attention: "주의",
tasksNeedAttention: "개의 작업이 주의를 필요로 합니다",
taskNeedsAttention: "작업 1개가 주의를 필요로 합니다",
diagnostic: "진단",
open: "열기",
close: "닫기 (Esc)",
reassignTo: "다음으로 재지정:",
copied: "복사됨",
copyCommand: "명령을 클립보드로 복사",
reclaim: "회수",
reassign: "재지정",
renderingError: "Kanban 탭에서 렌더링 오류가 발생했습니다",
reloadView: "뷰 다시 불러오기",
wsAuthFailed:
"WebSocket 인증 실패 — 페이지를 다시 불러와 세션 토큰을 갱신하십시오.",
markDone: "{n}개의 작업을 완료로 표시하시겠습니까?",
markArchived: "{n}개의 작업을 보관하시겠습니까?",
warning: "경고",
phantomIds: "팬텀 ID:",
active: "활성",
ended: "종료됨",
noProfile: "(프로필 없음)",
showAllAttempts: "모든 시도 표시",
sendingUpdates: "업데이트 전송 대상: ",
sendNotifications: "완료 / 차단됨 / 포기 알림 전송 대상",
archiveBoardConfirm:
"보드 '{name}'을(를) 보관하시겠습니까? 보드는 boards/_archived/로 이동되어 나중에 복구할 수 있습니다. 이 보드의 작업은 더 이상 UI 어디에도 나타나지 않습니다.",
archiveBoardTitle: "이 보드 보관",
boardSwitcherHint: "보드를 사용하면 관련 없는 작업 흐름을 분리할 수 있습니다",
taskCreatedWarning: "작업이 생성되었지만: ",
moveFailed: "이동 실패: ",
bulkFailed: "일괄 처리: ",
completionBlockedHallucination: "⚠ 완료가 차단됨 — 팬텀 카드 ID",
suspectedHallucinatedReferences: "⚠ 본문이 팬텀 카드 ID를 참조함",
pickProfileFirst: "먼저 프로필을 선택하십시오.",
unblockedMessage: "{id}의 차단을 해제했습니다. 작업이 다음 틱을 위해 준비되었습니다.",
unblockFailed: "차단 해제 실패: ",
reclaimedMessage: "{id}을(를) 회수했습니다. 작업이 ready 상태로 돌아갔습니다.",
reclaimFailed: "회수 실패: ",
reassignedMessage: "{id}을(를) {profile}(으)로 재지정했습니다.",
reassignFailed: "재지정 실패: ",
selectForBulk: "일괄 작업을 위해 선택",
clickToEdit: "클릭하여 편집",
clickToEditAssignee: "클릭하여 담당자 편집",
emptyAssignee: "(비우면 = 지정 해제)",
columnLabels: {
triage: "분류",
todo: "할 일",
scheduled: "예약됨",
ready: "준비됨",
running: "진행 중",
blocked: "차단됨",
done: "완료",
archived: "보관됨",
},
columnHelp: {
triage: "원시 아이디어 — 스페시파이어가 사양을 구체화합니다",
todo: "종속성 대기 중 또는 미지정",
scheduled: "알려진 시간 지연 또는 예약된 후속 조치를 기다리는 중",
ready: "종속성이 충족됨; 디스패치하려면 프로필을 지정하세요",
running: "워커가 점유 중 — 실행 중",
blocked: "워커가 사람의 입력을 요청함",
done: "완료됨",
archived: "보관됨",
},
confirmDone:
"이 작업을 완료로 표시하시겠습니까? 워커의 점유가 해제되고 종속된 하위 작업이 ready 상태가 됩니다.",
confirmArchive:
"이 작업을 보관하시겠습니까? 기본 보드 보기에서 사라집니다.",
confirmBlocked:
"이 작업을 차단됨으로 표시하시겠습니까? 워커의 점유가 해제됩니다.",
completionSummary:
"{label}의 완료 요약입니다. 이는 작업 결과로 저장됩니다.",
completionSummaryRequired:
"작업을 완료로 표시하기 전에 완료 요약이 필요합니다.",
triagePlaceholder: "대략적인 아이디어 — AI가 사양을 작성합니다…",
taskTitlePlaceholder: "새 작업 제목…",
specifier: "스페시파이어",
assigneePlaceholder: "담당자",
priority: "우선순위",
skillsPlaceholder:
"스킬 (선택, 쉼표로 구분): translation, github-code-review",
noParent: "— 상위 작업 없음 —",
workspacePathDir: "작업 공간 경로 (필수, 예: ~/projects/my-app)",
workspacePathOptional:
"작업 공간 경로 (선택, 비어 있으면 담당자에서 파생됨)",
logTruncated: "(마지막 100 KB 표시 중 — 전체 로그 위치: ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const pt: Translations = {
common: {
save: "Guardar",
saving: "A guardar...",
cancel: "Cancelar",
close: "Fechar",
confirm: "Confirmar",
delete: "Eliminar",
refresh: "Atualizar",
retry: "Tentar novamente",
search: "Pesquisar...",
loading: "A carregar...",
create: "Criar",
creating: "A criar...",
set: "Definir",
replace: "Substituir",
clear: "Limpar",
live: "Ativo",
off: "Desligado",
enabled: "ativado",
disabled: "desativado",
active: "ativo",
inactive: "inativo",
unknown: "desconhecido",
untitled: "Sem título",
none: "Nenhum",
form: "Formulário",
noResults: "Sem resultados",
of: "de",
page: "Página",
msgs: "msgs",
tools: "ferramentas",
match: "correspondência",
other: "Outro",
configured: "configurado",
removed: "removido",
failedToToggle: "Falha ao alternar",
failedToRemove: "Falha ao remover",
failedToReveal: "Falha ao revelar",
collapse: "Recolher",
expand: "Expandir",
general: "Geral",
messaging: "Mensagens",
pluginLoadFailed:
"Não foi possível carregar o script deste plugin. Verifique o separador Network (dashboard-plugins/…) e o caminho do plugin no servidor.",
pluginNotRegistered:
"O script do plugin não chamou register(), ou o script falhou. Abra a consola do browser para mais detalhes.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Fechar navegação",
closeModelTools: "Fechar modelo e ferramentas",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Sessões ativas:",
gatewayStatusLabel: "Estado do gateway:",
gatewayStrip: {
failed: "Falha ao iniciar",
off: "Desligado",
running: "A executar",
starting: "A iniciar",
stopped: "Parado",
},
nav: {
analytics: "Análise",
chat: "Chat",
config: "Configuração",
cron: "Cron",
documentation: "Documentação",
keys: "Chaves",
logs: "Registos",
models: "Modelos",
profiles: "perfis: multiagentes",
plugins: "Plugins",
sessions: "Sessões",
skills: "Competências",
},
modelToolsSheetSubtitle: "e ferramentas",
modelToolsSheetTitle: "Modelo",
navigation: "Navegação",
openDocumentation: "Abrir documentação num novo separador",
openNavigation: "Abrir navegação",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} ativa(s)",
statusOverview: "Visão geral do estado",
system: "Sistema",
webUi: "Web UI",
},
status: {
actionFailed: "Ação falhou",
actionFinished: "Concluído",
actions: "Ações",
agent: "Agente",
activeSessions: "Sessões ativas",
connected: "Ligado",
connectedPlatforms: "Plataformas ligadas",
disconnected: "Desligado",
error: "Erro",
failed: "Falhou",
gateway: "Gateway",
gatewayFailedToStart: "O gateway falhou ao iniciar",
lastUpdate: "Última atualização",
noneRunning: "Nenhum",
notRunning: "Não está a executar",
pid: "PID",
platformDisconnected: "desligado",
platformError: "erro",
recentSessions: "Sessões recentes",
restartGateway: "Reiniciar gateway",
restartingGateway: "A reiniciar gateway…",
running: "A executar",
runningRemote: "A executar (remoto)",
startFailed: "Falha ao iniciar",
starting: "A iniciar",
startedInBackground: "Iniciado em segundo plano — verifique os registos para acompanhar",
stopped: "Parado",
updateHermes: "Atualizar Hermes",
updatingHermes: "A atualizar Hermes…",
waitingForOutput: "À espera de saída…",
},
sessions: {
title: "Sessões",
history: "Histórico",
overview: "Visão geral",
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
noSessions: "Ainda não há sessões",
noMatch: "Nenhuma sessão corresponde à pesquisa",
startConversation: "Inicie uma conversa para a ver aqui",
noMessages: "Sem mensagens",
untitledSession: "Sessão sem título",
deleteSession: "Eliminar sessão",
confirmDeleteTitle: "Eliminar sessão?",
confirmDeleteMessage:
"Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.",
sessionDeleted: "Sessão eliminada",
failedToDelete: "Falha ao eliminar a sessão",
resumeInChat: "Retomar no Chat",
previousPage: "Página anterior",
nextPage: "Página seguinte",
roles: {
user: "Utilizador",
assistant: "Assistente",
system: "Sistema",
tool: "Ferramenta",
},
},
analytics: {
period: "Período:",
totalTokens: "Tokens totais",
totalSessions: "Sessões totais",
apiCalls: "Chamadas à API",
dailyTokenUsage: "Utilização diária de tokens",
dailyBreakdown: "Detalhe diário",
perModelBreakdown: "Detalhe por modelo",
topSkills: "Competências principais",
skill: "Competência",
loads: "Carregadas pelo agente",
edits: "Geridas pelo agente",
lastUsed: "Última utilização",
input: "Entrada",
output: "Saída",
total: "Total",
noUsageData: "Sem dados de utilização para este período",
startSession: "Inicie uma sessão para ver as análises aqui",
date: "Data",
model: "Modelo",
tokens: "Tokens",
perDayAvg: "/dia (média)",
acrossModels: "em {count} modelos",
inOut: "{input} entrada / {output} saída",
},
models: {
modelsUsed: "Modelos utilizados",
estimatedCost: "Custo est.",
tokens: "tokens",
sessions: "sessões",
avgPerSession: "média/sessão",
apiCalls: "chamadas à API",
toolCalls: "chamadas a ferramentas",
noModelsData: "Sem dados de utilização de modelos para este período",
startSession: "Inicie uma sessão para ver os dados de modelos aqui",
},
logs: {
title: "Registos",
autoRefresh: "Atualização automática",
file: "Ficheiro",
level: "Nível",
component: "Componente",
lines: "Linhas",
noLogLines: "Não foram encontradas linhas de registo",
},
cron: {
confirmDeleteMessage:
"Esta ação remove a tarefa do agendamento. Não é possível anular.",
confirmDeleteTitle: "Eliminar tarefa agendada?",
newJob: "Nova tarefa cron",
nameOptional: "Nome (opcional)",
namePlaceholder: "ex: Resumo diário",
prompt: "Prompt",
promptPlaceholder: "O que deve o agente fazer em cada execução?",
schedule: "Agendamento (expressão cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Entregar a",
scheduledJobs: "Tarefas agendadas",
noJobs: "Sem tarefas cron configuradas. Crie uma acima.",
last: "Última",
next: "Próxima",
pause: "Pausar",
resume: "Retomar",
triggerNow: "Acionar agora",
delivery: {
local: "Local",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Novo perfil",
name: "Nome",
namePlaceholder: "ex: coder, writer, etc.",
nameRequired: "O nome é obrigatório",
nameRule:
"Apenas letras minúsculas, dígitos, _ e -; deve começar com letra ou dígito; até 64 caracteres.",
invalidName: "Nome de perfil inválido",
cloneFromDefault: "Clonar configuração do perfil predefinido",
allProfiles: "Perfis",
noProfiles: "Não foram encontrados perfis.",
defaultBadge: "predefinido",
hasEnv: "env",
model: "Modelo",
skills: "Competências",
rename: "Renomear",
editSoul: "Editar SOUL.md",
soulSection: "SOUL.md (personalidade / prompt do sistema)",
soulPlaceholder: "# Como este agente se deve comportar…",
saveSoul: "Guardar SOUL",
soulSaved: "SOUL.md guardado",
openInTerminal: "Copiar comando da CLI",
commandCopied: "Copiado para a área de transferência",
copyFailed: "Não foi possível copiar",
confirmDeleteTitle: "Eliminar perfil?",
confirmDeleteMessage:
"Esta ação elimina permanentemente o perfil '{name}' — configuração, chaves, memórias, sessões, competências, tarefas cron. Não é possível anular.",
created: "Criado",
deleted: "Eliminado",
renamed: "Renomeado",
},
pluginsPage: {
contextEngineLabel: "Motor de contexto",
dashboardSlots: "Slots do dashboard",
disableRuntime: "Desativar",
enableAfterInstall: "Ativar após instalação",
enableRuntime: "Ativar",
forceReinstall: "Forçar reinstalação (eliminar pasta existente primeiro)",
headline:
"Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo",
inactive: "inativo",
installBtn: "Instalar",
installHeading: "Instalar a partir de GitHub / URL Git",
installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.",
memoryProviderLabel: "Fornecedor de memória",
missingEnvWarn: "Defina os seguintes em Chaves antes de o plugin poder executar:",
noDashboardTab: "Sem separador no dashboard",
openTab: "Abrir",
orphanHeading: "Extensões só de dashboard (sem plugin.yaml de agente correspondente)",
pluginListHeading: "Plugins instalados",
providerDefaults: "incorporado / predefinido",
providersHeading: "Plugins de fornecedor em runtime",
providersHint:
"Escreve memory.provider (vazio = incorporado) e context.engine no config.yaml. Aplicado na próxima sessão.",
refreshDashboard: "Re-analisar extensões do dashboard",
removeConfirm: "Remover este plugin de ~/.hermes/plugins/?",
removeHint: "Apenas plugins instalados pelo utilizador em ~/.hermes/plugins podem ser removidos.",
rescanHeading: "Registo de plugins SPA",
rescanHint: "Re-analise depois de adicionar ficheiros em disco para que a barra lateral detete novos manifestos.",
runtimeHeading: "Runtime do gateway (plugins YAML)",
saveProviders: "Guardar definições do fornecedor",
savedProviders: "Definições do fornecedor guardadas.",
sourceBadge: "Fonte",
authRequired: "Autenticação necessária",
authRequiredHint: "Execute este comando para autenticar:",
updateGit: "Git pull",
versionBadge: "Versão",
showInSidebar: "Mostrar na barra lateral",
hideFromSidebar: "Ocultar da barra lateral",
},
skills: {
title: "Competências",
searchPlaceholder: "Pesquisar competências e conjuntos de ferramentas...",
enabledOf: "{enabled}/{total} ativadas",
all: "Todas",
categories: "Categorias",
filters: "Filtros",
noSkills: "Nenhuma competência encontrada. As competências são carregadas de ~/.hermes/skills/",
noSkillsMatch: "Nenhuma competência corresponde à pesquisa ou filtro.",
skillCount: "{count} competência{s}",
resultCount: "{count} resultado{s}",
noDescription: "Sem descrição disponível.",
toolsets: "Conjuntos de ferramentas",
toolsetLabel: "conjunto {name}",
noToolsetsMatch: "Nenhum conjunto de ferramentas corresponde à pesquisa.",
setupNeeded: "Configuração necessária",
disabledForCli: "Desativado para CLI",
more: "+{count} mais",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filtros",
sections: "Secções",
exportConfig: "Exportar configuração como JSON",
importConfig: "Importar configuração de JSON",
resetDefaults: "Repor predefinições",
resetScopeTooltip: "Repor {scope} para predefinições",
confirmResetScope: "Repor todas as definições de {scope} para os valores predefinidos? Isto apenas atualiza o formulário — as alterações só são escritas em config.yaml quando premir Guardar.",
resetScopeToast: "{scope} reposto para predefinições — reveja e Guarde para persistir",
rawYaml: "Configuração YAML em bruto",
searchResults: "Resultados da pesquisa",
fields: "campo{s}",
noFieldsMatch: 'Nenhum campo corresponde a "{query}"',
configSaved: "Configuração guardada",
yamlConfigSaved: "Configuração YAML guardada",
failedToSave: "Falha ao guardar",
failedToSaveYaml: "Falha ao guardar YAML",
failedToLoadRaw: "Falha ao carregar configuração em bruto",
configImported: "Configuração importada — reveja e guarde",
invalidJson: "Ficheiro JSON inválido",
categories: {
general: "Geral",
agent: "Agente",
terminal: "Terminal",
display: "Visualização",
delegation: "Delegação",
memory: "Memória",
compression: "Compressão",
security: "Segurança",
browser: "Browser",
voice: "Voz",
tts: "Texto para fala",
stt: "Fala para texto",
logging: "Registo",
discord: "Discord",
auxiliary: "Auxiliar",
},
},
env: {
changesNote: "As alterações são guardadas em disco imediatamente. As sessões ativas detetam novas chaves automaticamente.",
confirmClearMessage:
"O valor armazenado para esta variável será removido do seu ficheiro .env. Esta ação não pode ser anulada a partir da UI.",
confirmClearTitle: "Limpar esta chave?",
description: "Gerir chaves de API e segredos armazenados em",
hideAdvanced: "Ocultar avançadas",
showAdvanced: "Mostrar avançadas",
showLess: "Mostrar menos",
showMore: "Mostrar mais",
llmProviders: "Fornecedores LLM",
providersConfigured: "{configured} de {total} fornecedores configurados",
getKey: "Obter chave",
notConfigured: "{count} não configurado(s)",
notSet: "Não definido",
keysCount: "{count} chave{s}",
enterValue: "Introduzir valor...",
replaceCurrentValue: "Substituir valor atual ({preview})",
showValue: "Mostrar valor real",
hideValue: "Ocultar valor",
},
oauth: {
title: "Inícios de sessão de fornecedor (OAuth)",
providerLogins: "Inícios de sessão de fornecedor (OAuth)",
description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.",
connected: "Ligado",
expired: "Expirado",
notConnected: "Não ligado. Execute {command} num terminal.",
runInTerminal: "num terminal.",
noProviders: "Não foram detetados fornecedores compatíveis com OAuth.",
login: "Iniciar sessão",
disconnect: "Desligar",
managedExternally: "Gerido externamente",
copied: "Copiado ✓",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / fallback)",
connect: "Ligar",
sessionExpires: "A sessão expira em {time}",
initiatingLogin: "A iniciar fluxo de início de sessão…",
exchangingCode: "A trocar código por tokens…",
connectedClosing: "Ligado! A fechar…",
loginFailed: "Início de sessão falhou.",
sessionExpired: "Sessão expirada. Clique em Tentar novamente para iniciar um novo início de sessão.",
reOpenAuth: "Reabrir página de autenticação",
reOpenVerification: "Reabrir página de verificação",
submitCode: "Submeter código",
pasteCode: "Cole o código de autorização (com sufixo #state também é válido)",
waitingAuth: "À espera que autorize no browser…",
enterCodePrompt: "Foi aberto um novo separador. Introduza este código se for solicitado:",
pkceStep1: "Foi aberto um novo separador para claude.ai. Inicie sessão e clique em Authorize.",
pkceStep2: "Copie o código de autorização mostrado após autorizar.",
pkceStep3: "Cole-o abaixo e submeta.",
flowLabels: {
pkce: "Início de sessão pelo browser (PKCE)",
device_code: "Código de dispositivo",
external: "CLI externa",
},
expiresIn: "expira em {time}",
},
language: {
switchTo: "Mudar idioma",
},
theme: {
title: "Tema",
switchTheme: "Mudar tema",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Distintivos colecionáveis do Hermes obtidos a partir do histórico real de sessões. Conquistas conhecidas mas ainda não obtidas aparecem como Descobertas; conquistas Secretas permanecem ocultas até surgir o primeiro comportamento correspondente.",
scan_subtitle:
"A analisar o histórico de sessões do Hermes. A primeira análise pode demorar 510 segundos em históricos extensos.",
},
actions: {
rescan: "Voltar a analisar",
},
stats: {
unlocked: "Desbloqueadas",
unlocked_hint: "distintivos obtidos",
discovered: "Descobertas",
discovered_hint: "conhecidas, ainda não obtidas",
secrets: "Secretas",
secrets_hint: "ocultas até ao primeiro sinal",
highest_tier: "Nível mais alto",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Mais recente",
latest_hint_empty: "execute mais o Hermes",
none_yet: "Ainda nenhuma",
},
state: {
unlocked: "Desbloqueada",
discovered: "Descoberta",
secret: "Secreta",
},
tier: {
target: "Objetivo {tier}",
hidden: "Oculto",
complete: "Completo",
objective: "Objetivo",
},
progress: {
hidden: "oculto",
},
scan: {
building_headline: "A construir perfil de conquistas…",
building_detail:
"A ler sessões, chamadas de ferramentas, metadados de modelos e estado de desbloqueio.",
starting_headline: "A iniciar análise de conquistas…",
progress_detail:
"Analisadas {scanned} de {total} sessões · {pct}%. Os distintivos são desbloqueados à medida que mais histórico é processado.",
idle_detail:
"A ler sessões, chamadas de ferramentas, metadados de modelos e estado de desbloqueio. Os distintivos aparecem aqui à medida que são desbloqueados.",
},
guide: {
tiers_header: "Níveis",
secret_header: "Conquistas secretas",
secret_body:
"As secretas escondem o seu acionador exato. Assim que o Hermes detetar um sinal relacionado, o cartão passa a Descoberta e mostra o requisito.",
scan_status_header: "Estado da análise",
scan_status_body:
"O Hermes analisa o histórico local uma vez e depois os cartões aparecem automaticamente. Nada está bloqueado se isto demorar alguns segundos.",
what_scanned_header: "O que é analisado",
what_scanned_body:
"Sessões, chamadas de ferramentas, metadados de modelos, erros, conquistas e estado de desbloqueio local.",
},
card: {
share_title: "Partilhar esta conquista",
share_label: "Partilhar {name}",
share_text: "Partilhar",
how_to_reveal: "Como revelar",
what_counts: "O que conta",
evidence_label: "Evidência",
evidence_session_fallback: "sessão",
no_evidence: "Ainda sem evidência",
},
latest: {
header: "Desbloqueios recentes",
},
empty: {
no_secrets_header: "Não restam segredos ocultos nesta análise.",
no_secrets_body:
"Pista: as secretas começam normalmente em padrões pouco comuns de falha ou de utilizador avançado — conflitos de portas, barreiras de permissões, variáveis de ambiente em falta, erros de YAML, colisões de Docker, uso de rollback/checkpoint, acertos de cache ou pequenas correções após muito texto a vermelho.",
},
filters: {
all_categories: "Todas",
visibility_all: "todas",
visibility_unlocked: "desbloqueadas",
visibility_discovered: "descobertas",
visibility_secret: "secretas",
},
share: {
dialog_label: "Partilhar conquista",
header: "Partilhar: {name}",
close: "Fechar",
rendering: "A renderizar…",
card_alt: "Cartão de partilha de {name}",
error_generic: "Algo correu mal.",
x_title: "Abre o X com uma publicação pré-preenchida",
x_button: "Partilhar no X",
copy_title: "Copiar a imagem para colar na sua publicação",
copy_button: "Copiar imagem",
copied: "Copiado ✓",
download_button: "Transferir PNG",
hint:
"Partilhar no X abre uma publicação pré-preenchida num novo separador. Clique primeiro em Copiar imagem se quiser anexar o distintivo 1200×630 — o X permite colá-lo diretamente no compositor da publicação. Transferir PNG guarda o ficheiro para utilização em qualquer lado.",
clipboard_unsupported:
"A cópia de imagens para a área de transferência não é suportada neste navegador — utilize Transferir.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "A carregar o quadro Kanban…",
loadFailed: "Falha ao carregar o quadro Kanban: ",
loadFailedHint:
"O backend cria automaticamente kanban.db na primeira leitura. Se persistir, consulte os registos do dashboard.",
board: "Quadro",
newBoard: "+ Novo quadro",
newBoardTitle: "Novo quadro",
newBoardDescription:
"Os quadros permitem-lhe separar fluxos de trabalho não relacionados — um por projeto, repositório ou domínio. Os workers de um quadro nunca veem as tarefas de outro quadro.",
slug: "Slug",
slugHint: "— minúsculas, hífenes, p. ex. atm10-server",
displayName: "Nome a apresentar",
displayNameHint: "(opcional)",
description: "Descrição",
descriptionHint: "(opcional)",
icon: "Ícone",
iconHint: "(carácter único ou emoji)",
switchAfterCreate: "Mudar para este quadro após o criar",
cancel: "Cancelar",
creating: "A criar…",
createBoard: "Criar quadro",
search: "Pesquisar",
filterCards: "Filtrar cartões…",
tenant: "Tenant",
allTenants: "Todos os tenants",
assignee: "Responsável",
allProfiles: "Todos os perfis",
showArchived: "Mostrar arquivados",
lanesByProfile: "Faixas por perfil",
nudgeDispatcher: "Despertar o dispatcher",
refresh: "Atualizar",
selected: "selecionado(s)",
complete: "Concluir",
archive: "Arquivar",
apply: "Aplicar",
clear: "Limpar",
createTask: "Criar tarefa nesta coluna",
noTasks: "— sem tarefas —",
unassigned: "sem atribuição",
untitled: "(sem título)",
loadingDetail: "A carregar…",
addComment: "Adicionar um comentário… (Enter para submeter)",
comment: "Comentário",
status: "Estado",
workspace: "Espaço de trabalho",
skills: "Competências",
createdBy: "Criado por",
result: "Resultado",
comments: "Comentários",
events: "Eventos",
runHistory: "Histórico de execuções",
workerLog: "Registo do worker",
loadingLog: "A carregar registo…",
noWorkerLog:
"— ainda não há registo do worker (a tarefa não foi iniciada ou o registo foi rotacionado) —",
noDescription: "— sem descrição —",
noComments: "— sem comentários —",
edit: "editar",
save: "Guardar",
dependencies: "Dependências",
parents: "Pais:",
children: "Filhos:",
none: "nenhum",
addParent: "— adicionar pai —",
addChild: "— adicionar filho —",
removeDependency: "Remover dependência",
block: "Bloquear",
unblock: "Desbloquear",
notifyHomeChannels: "Notificar canais principais",
diagnostics: "Diagnósticos",
hide: "Ocultar",
show: "Mostrar",
attention: "Atenção",
tasksNeedAttention: "tarefas precisam de atenção",
taskNeedsAttention: "1 tarefa precisa de atenção",
diagnostic: "diagnóstico",
open: "Abrir",
close: "Fechar (Esc)",
reassignTo: "Reatribuir a:",
copied: "Copiado",
copyCommand: "Copiar comando para a área de transferência",
reclaim: "Reivindicar",
reassign: "Reatribuir",
renderingError: "O separador Kanban encontrou um erro de renderização",
reloadView: "Recarregar vista",
wsAuthFailed:
"Falha de autenticação WebSocket — recarregue a página para atualizar o token de sessão.",
markDone: "Marcar {n} tarefa(s) como concluídas?",
markArchived: "Arquivar {n} tarefa(s)?",
warning: "Aviso",
phantomIds: "Ids fantasma:",
active: "ativo",
ended: "terminado",
noProfile: "(sem perfil)",
showAllAttempts: "Mostrar todas as tentativas",
sendingUpdates: "A enviar atualizações para",
sendNotifications: "Enviar notificações de completed / blocked / gave_up para",
archiveBoardConfirm:
"Arquivar o quadro '{name}'? Será movido para boards/_archived/ para que possa recuperá-lo mais tarde. As tarefas deste quadro deixarão de aparecer em qualquer parte da interface.",
archiveBoardTitle: "Arquivar este quadro",
boardSwitcherHint: "Os quadros permitem-lhe separar fluxos de trabalho não relacionados",
taskCreatedWarning: "Tarefa criada, mas: ",
moveFailed: "Falha ao mover: ",
bulkFailed: "Em lote: ",
completionBlockedHallucination: "⚠ Conclusão bloqueada — ids de cartões fantasma",
suspectedHallucinatedReferences: "⚠ O texto referenciou ids de cartões fantasma",
pickProfileFirst: "Escolha primeiro um perfil.",
unblockedMessage: "{id} desbloqueado. A tarefa está pronta para o próximo tick.",
unblockFailed: "Falha ao desbloquear: ",
reclaimedMessage: "{id} reivindicado. A tarefa voltou a ready.",
reclaimFailed: "Falha ao reivindicar: ",
reassignedMessage: "{id} reatribuído a {profile}.",
reassignFailed: "Falha ao reatribuir: ",
selectForBulk: "Selecionar para ações em lote",
clickToEdit: "Clique para editar",
clickToEditAssignee: "Clique para editar responsável",
emptyAssignee: "(vazio = remover atribuição)",
columnLabels: {
triage: "Triagem",
todo: "A fazer",
scheduled: "Agendado",
ready: "Pronto",
running: "Em curso",
blocked: "Bloqueado",
done: "Concluído",
archived: "Arquivado",
},
columnHelp: {
triage: "Ideias em bruto — um specifier vai detalhar a especificação",
todo: "À espera de dependências ou sem atribuição",
scheduled: "À espera de um atraso conhecido ou de um seguimento agendado",
ready: "Dependências satisfeitas; atribua um perfil para despachar",
running: "Reivindicado por um worker — em execução",
blocked: "O worker pediu intervenção humana",
done: "Concluído",
archived: "Arquivado",
},
confirmDone:
"Marcar esta tarefa como concluída? A reivindicação do worker é libertada e os filhos dependentes ficam prontos.",
confirmArchive:
"Arquivar esta tarefa? Desaparece da vista padrão do quadro.",
confirmBlocked:
"Marcar esta tarefa como bloqueada? A reivindicação do worker é libertada.",
completionSummary:
"Resumo de conclusão para {label}. Será guardado como o resultado da tarefa.",
completionSummaryRequired:
"É necessário um resumo de conclusão antes de marcar uma tarefa como concluída.",
triagePlaceholder: "Ideia aproximada — a IA irá especificá-la…",
taskTitlePlaceholder: "Título da nova tarefa…",
specifier: "specifier",
assigneePlaceholder: "responsável",
priority: "Prioridade",
skillsPlaceholder:
"competências (opcional, separadas por vírgulas): translation, github-code-review",
noParent: "— sem pai —",
workspacePathDir: "caminho do espaço de trabalho (obrigatório, p. ex. ~/projects/my-app)",
workspacePathOptional:
"caminho do espaço de trabalho (opcional, derivado do responsável se vazio)",
logTruncated: "(a mostrar os últimos 100 KB — registo completo em ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const ru: Translations = {
common: {
save: "Сохранить",
saving: "Сохранение...",
cancel: "Отмена",
close: "Закрыть",
confirm: "Подтвердить",
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: "Мессенджеры",
pluginLoadFailed:
"Не удалось загрузить скрипт этого плагина. Проверьте вкладку «Сеть» (dashboard-plugins/…) и путь к плагинам на сервере.",
pluginNotRegistered:
"Скрипт плагина не вызвал register() или завершился с ошибкой. Откройте консоль браузера для подробностей.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Закрыть навигацию",
closeModelTools: "Закрыть модель и инструменты",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Активные сессии:",
gatewayStatusLabel: "Статус шлюза:",
gatewayStrip: {
failed: "Ошибка запуска",
off: "Отключён",
running: "Работает",
starting: "Запуск",
stopped: "Остановлен",
},
nav: {
analytics: "Аналитика",
chat: "Чат",
config: "Конфигурация",
cron: "Cron",
documentation: "Документация",
keys: "Ключи",
logs: "Журналы",
models: "Модели",
profiles: "профили: мульти-агенты",
plugins: "Плагины",
sessions: "Сессии",
skills: "Навыки",
},
modelToolsSheetSubtitle: "и инструменты",
modelToolsSheetTitle: "Модель",
navigation: "Навигация",
openDocumentation: "Открыть документацию в новой вкладке",
openNavigation: "Открыть навигацию",
pluginNavSection: "Плагины",
sessionsActiveCount: "{count} активн.",
statusOverview: "Обзор статуса",
system: "Система",
webUi: "Web UI",
},
status: {
actionFailed: "Ошибка действия",
actionFinished: "Завершено",
actions: "Действия",
agent: "Агент",
activeSessions: "Активные сессии",
connected: "Подключено",
connectedPlatforms: "Подключённые платформы",
disconnected: "Отключено",
error: "Ошибка",
failed: "Сбой",
gateway: "Шлюз",
gatewayFailedToStart: "Шлюзу не удалось запуститься",
lastUpdate: "Последнее обновление",
noneRunning: "Нет",
notRunning: "Не запущено",
pid: "PID",
platformDisconnected: "отключено",
platformError: "ошибка",
recentSessions: "Недавние сессии",
restartGateway: "Перезапустить шлюз",
restartingGateway: "Перезапуск шлюза…",
running: "Работает",
runningRemote: "Работает (удалённо)",
startFailed: "Ошибка запуска",
starting: "Запуск",
startedInBackground: "Запущено в фоне — следите за журналами",
stopped: "Остановлено",
updateHermes: "Обновить Hermes",
updatingHermes: "Обновление Hermes…",
waitingForOutput: "Ожидание вывода…",
},
sessions: {
title: "Сессии",
history: "История",
overview: "Обзор",
searchPlaceholder: "Поиск по содержимому сообщений...",
noSessions: "Сессий пока нет",
noMatch: "Нет сессий, соответствующих запросу",
startConversation: "Начните разговор, чтобы увидеть его здесь",
noMessages: "Нет сообщений",
untitledSession: "Сессия без названия",
deleteSession: "Удалить сессию",
confirmDeleteTitle: "Удалить сессию?",
confirmDeleteMessage:
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
resumeInChat: "Продолжить в чате",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
roles: {
user: "Пользователь",
assistant: "Ассистент",
system: "Система",
tool: "Инструмент",
},
},
analytics: {
period: "Период:",
totalTokens: "Всего токенов",
totalSessions: "Всего сессий",
apiCalls: "Вызовы API",
dailyTokenUsage: "Расход токенов по дням",
dailyBreakdown: "Разбивка по дням",
perModelBreakdown: "Разбивка по моделям",
topSkills: "Популярные навыки",
skill: "Навык",
loads: "Загружено агентом",
edits: "Управляется агентом",
lastUsed: "Последнее использование",
input: "Ввод",
output: "Вывод",
total: "Итого",
noUsageData: "Нет данных об использовании за этот период",
startSession: "Начните сессию, чтобы увидеть аналитику",
date: "Дата",
model: "Модель",
tokens: "Токены",
perDayAvg: "/день в среднем",
acrossModels: "по {count} моделям",
inOut: "{input} вход / {output} выход",
},
models: {
modelsUsed: "Использовано моделей",
estimatedCost: "Оценка стоимости",
tokens: "токены",
sessions: "сессии",
avgPerSession: "ср./сессию",
apiCalls: "вызовы API",
toolCalls: "вызовы инструментов",
noModelsData: "Нет данных по моделям за этот период",
startSession: "Начните сессию, чтобы увидеть данные по моделям",
},
logs: {
title: "Журналы",
autoRefresh: "Автообновление",
file: "Файл",
level: "Уровень",
component: "Компонент",
lines: "Строк",
noLogLines: "Записи журнала не найдены",
},
cron: {
confirmDeleteMessage:
"Это удалит задачу из расписания. Действие нельзя отменить.",
confirmDeleteTitle: "Удалить запланированную задачу?",
newJob: "Новая Cron-задача",
nameOptional: "Имя (необязательно)",
namePlaceholder: "напр. Ежедневная сводка",
prompt: "Запрос",
promptPlaceholder: "Что должен делать агент при каждом запуске?",
schedule: "Расписание (cron-выражение)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Доставить в",
scheduledJobs: "Запланированные задачи",
noJobs: "Cron-задачи не настроены. Создайте задачу выше.",
last: "Последний",
next: "Следующий",
pause: "Пауза",
resume: "Возобновить",
triggerNow: "Запустить сейчас",
delivery: {
local: "Локально",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Новый профиль",
name: "Имя",
namePlaceholder: "напр. coder, writer и т.п.",
nameRequired: "Имя обязательно",
nameRule:
"Только строчные буквы, цифры, _ и -; должно начинаться с буквы или цифры; до 64 символов.",
invalidName: "Недопустимое имя профиля",
cloneFromDefault: "Клонировать конфигурацию из профиля по умолчанию",
allProfiles: "Профили",
noProfiles: "Профили не найдены.",
defaultBadge: "по умолчанию",
hasEnv: "env",
model: "Модель",
skills: "Навыки",
rename: "Переименовать",
editSoul: "Редактировать SOUL.md",
soulSection: "SOUL.md (личность / системный промпт)",
soulPlaceholder: "# Как должен вести себя этот агент…",
saveSoul: "Сохранить SOUL",
soulSaved: "SOUL.md сохранён",
openInTerminal: "Скопировать команду CLI",
commandCopied: "Скопировано в буфер обмена",
copyFailed: "Не удалось скопировать",
confirmDeleteTitle: "Удалить профиль?",
confirmDeleteMessage:
"Это безвозвратно удалит профиль '{name}' — конфигурацию, ключи, память, сессии, навыки, cron-задачи. Отменить нельзя.",
created: "Создан",
deleted: "Удалён",
renamed: "Переименован",
},
pluginsPage: {
contextEngineLabel: "Движок контекста",
dashboardSlots: "Слоты панели",
disableRuntime: "Отключить",
enableAfterInstall: "Включить после установки",
enableRuntime: "Включить",
forceReinstall: "Принудительная переустановка (сначала удалить существующую папку)",
headline:
"Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).",
identifierLabel: "Git URL или owner/repo",
inactive: "неактивно",
installBtn: "Установить",
installHeading: "Установка из GitHub / Git URL",
installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.",
memoryProviderLabel: "Провайдер памяти",
missingEnvWarn: "Задайте эти переменные в разделе «Ключи», прежде чем плагин сможет работать:",
noDashboardTab: "Нет вкладки в панели",
openTab: "Открыть",
orphanHeading: "Расширения только для панели (без соответствующего plugin.yaml агента)",
pluginListHeading: "Установленные плагины",
providerDefaults: "встроенный / по умолчанию",
providersHeading: "Плагины-провайдеры рантайма",
providersHint:
"Записывает memory.provider (пусто = встроенный) и context.engine в config.yaml. Применяется со следующей сессии.",
refreshDashboard: "Пересканировать расширения панели",
removeConfirm: "Удалить этот плагин из ~/.hermes/plugins/?",
removeHint: "Удалять можно только плагины, установленные пользователем в ~/.hermes/plugins.",
rescanHeading: "Реестр SPA-плагинов",
rescanHint: "Пересканируйте после добавления файлов на диск, чтобы боковая панель подхватила новые манифесты.",
runtimeHeading: "Рантайм шлюза (YAML-плагины)",
saveProviders: "Сохранить настройки провайдеров",
savedProviders: "Настройки провайдеров сохранены.",
sourceBadge: "Источник",
authRequired: "Требуется аутентификация",
authRequiredHint: "Выполните эту команду для аутентификации:",
updateGit: "Git pull",
versionBadge: "Версия",
showInSidebar: "Показывать в боковой панели",
hideFromSidebar: "Скрыть из боковой панели",
},
skills: {
title: "Навыки",
searchPlaceholder: "Поиск навыков и наборов инструментов...",
enabledOf: "{enabled}/{total} включено",
all: "Все",
categories: "Категории",
filters: "Фильтры",
noSkills: "Навыки не найдены. Навыки загружаются из ~/.hermes/skills/",
noSkillsMatch: "Нет навыков, соответствующих запросу или фильтру.",
skillCount: "{count} навык{s}",
resultCount: "{count} результат{s}",
noDescription: "Описание отсутствует.",
toolsets: "Наборы инструментов",
toolsetLabel: "Набор инструментов {name}",
noToolsetsMatch: "Нет наборов инструментов, соответствующих запросу.",
setupNeeded: "Требуется настройка",
disabledForCli: "Отключено для CLI",
more: "+{count} ещё",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Фильтры",
sections: "Разделы",
exportConfig: "Экспортировать конфигурацию в JSON",
importConfig: "Импортировать конфигурацию из JSON",
resetDefaults: "Сбросить к значениям по умолчанию",
resetScopeTooltip: "Сбросить {scope} к значениям по умолчанию",
confirmResetScope: "Сбросить все настройки {scope} к значениям по умолчанию? Это обновит только форму — изменения не будут записаны в config.yaml, пока вы не нажмёте «Сохранить».",
resetScopeToast: "{scope} сброшено к значениям по умолчанию — проверьте и сохраните",
rawYaml: "Исходная YAML-конфигурация",
searchResults: "Результаты поиска",
fields: "пол{s}",
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: {
changesNote: "Изменения сохраняются на диск немедленно. Активные сессии автоматически подхватывают новые ключи.",
confirmClearMessage:
"Сохранённое значение этой переменной будет удалено из вашего файла .env. Это нельзя отменить из интерфейса.",
confirmClearTitle: "Очистить этот ключ?",
description: "Управление API-ключами и секретами, хранящимися в",
hideAdvanced: "Скрыть расширенные",
showAdvanced: "Показать расширенные",
showLess: "Показать меньше",
showMore: "Показать больше",
llmProviders: "Провайдеры LLM",
providersConfigured: "Настроено {configured} из {total} провайдеров",
getKey: "Получить ключ",
notConfigured: "{count} не настроено",
notSet: "Не задано",
keysCount: "{count} ключ{s}",
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: "Копировать",
copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
connect: "Подключить",
sessionExpires: "Сессия истечёт через {time}",
initiatingLogin: "Запуск процесса входа…",
exchangingCode: "Обмен кода на токены…",
connectedClosing: "Подключено! Закрытие…",
loginFailed: "Ошибка входа.",
sessionExpired: "Сессия истекла. Нажмите «Повторить» для нового входа.",
reOpenAuth: "Снова открыть страницу авторизации",
reOpenVerification: "Снова открыть страницу подтверждения",
submitCode: "Отправить код",
pasteCode: "Вставьте код авторизации (с суффиксом #state — допустимо)",
waitingAuth: "Ожидание авторизации в браузере…",
enterCodePrompt: "Открыта новая вкладка. Введите этот код, если будет запрошено:",
pkceStep1: "В новой вкладке открыт claude.ai. Войдите и нажмите «Authorize».",
pkceStep2: "Скопируйте код авторизации, отображённый после авторизации.",
pkceStep3: "Вставьте его ниже и отправьте.",
flowLabels: {
pkce: "Вход через браузер (PKCE)",
device_code: "Код устройства",
external: "Внешний CLI",
},
expiresIn: "истекает через {time}",
},
language: {
switchTo: "Сменить язык",
},
theme: {
title: "Тема",
switchTheme: "Сменить тему",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Коллекционные значки Hermes, полученные на основе реальной истории сессий. Известные, но ещё не полученные достижения отображаются как «Обнаруженные»; «Секретные» достижения остаются скрытыми до появления первого подходящего поведения.",
scan_subtitle:
"Анализ истории сессий Hermes. Первое сканирование может занять 5–10 секунд при большой истории.",
},
actions: {
rescan: "Пересканировать",
},
stats: {
unlocked: "Разблокировано",
unlocked_hint: "полученные значки",
discovered: "Обнаружено",
discovered_hint: "известные, ещё не получены",
secrets: "Секреты",
secrets_hint: "скрыты до первого сигнала",
highest_tier: "Высший уровень",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Последнее",
latest_hint_empty: "запускайте Hermes чаще",
none_yet: "Пока нет",
},
state: {
unlocked: "Разблокировано",
discovered: "Обнаружено",
secret: "Секрет",
},
tier: {
target: "Цель: {tier}",
hidden: "Скрыто",
complete: "Завершено",
objective: "Задача",
},
progress: {
hidden: "скрыто",
},
scan: {
building_headline: "Создание профиля достижений…",
building_detail:
"Чтение сессий, вызовов инструментов, метаданных моделей и состояния разблокировки.",
starting_headline: "Запуск сканирования достижений…",
progress_detail:
"Просканировано {scanned} из {total} сессий · {pct}%. Значки разблокируются по мере поступления истории.",
idle_detail:
"Чтение сессий, вызовов инструментов, метаданных моделей и состояния разблокировки. Значки появляются здесь по мере разблокировки.",
},
guide: {
tiers_header: "Уровни",
secret_header: "Секретные достижения",
secret_body:
"Секретные достижения скрывают свой точный триггер. Как только Hermes обнаруживает связанный сигнал, карточка становится «Обнаруженной» и показывает требование.",
scan_status_header: "Статус сканирования",
scan_status_body:
"Hermes сканирует локальную историю один раз, затем карточки появятся автоматически. Если это занимает несколько секунд — ничего не зависло.",
what_scanned_header: "Что сканируется",
what_scanned_body:
"Сессии, вызовы инструментов, метаданные моделей, ошибки, достижения и локальное состояние разблокировки.",
},
card: {
share_title: "Поделиться этим достижением",
share_label: "Поделиться: {name}",
share_text: "Поделиться",
how_to_reveal: "Как открыть",
what_counts: "Что засчитывается",
evidence_label: "Подтверждение",
evidence_session_fallback: "сессия",
no_evidence: "Подтверждений пока нет",
},
latest: {
header: "Недавние разблокировки",
},
empty: {
no_secrets_header: "В этом сканировании больше не осталось скрытых секретов.",
no_secrets_body:
"Подсказка: секреты обычно начинаются с необычных ошибок или паттернов опытных пользователей — конфликты портов, ограничения прав, отсутствующие переменные окружения, ошибки YAML, коллизии Docker, использование rollback/checkpoint, попадания в кеш или мелкие исправления после большого количества красного текста.",
},
filters: {
all_categories: "Все",
visibility_all: "все",
visibility_unlocked: "разблокированные",
visibility_discovered: "обнаруженные",
visibility_secret: "секретные",
},
share: {
dialog_label: "Поделиться достижением",
header: "Поделиться: {name}",
close: "Закрыть",
rendering: "Отрисовка…",
card_alt: "Карточка для публикации {name}",
error_generic: "Что-то пошло не так.",
x_title: "Открывает X с заранее заполненным постом",
x_button: "Поделиться в X",
copy_title: "Скопировать изображение для вставки в публикацию",
copy_button: "Скопировать изображение",
copied: "Скопировано ✓",
download_button: "Скачать PNG",
hint:
"«Поделиться в X» открывает пост с заранее заполненным текстом в новой вкладке. Сначала нажмите «Скопировать изображение», если хотите прикрепить значок 1200×630 — X позволяет вставить его прямо в редактор твита. «Скачать PNG» сохраняет файл для использования где угодно.",
clipboard_unsupported:
"Копирование изображений в буфер обмена не поддерживается в этом браузере — используйте «Скачать».",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Загрузка доски Kanban…",
loadFailed: "Не удалось загрузить доску Kanban: ",
loadFailedHint:
"Бэкенд автоматически создаёт kanban.db при первом чтении. Если ошибка повторяется, проверьте логи панели.",
board: "Доска",
newBoard: "+ Новая доска",
newBoardTitle: "Новая доска",
newBoardDescription:
"Доски позволяют разделять не связанные между собой потоки работы — по одной на проект, репозиторий или область. Воркеры одной доски никогда не видят задачи другой.",
slug: "Slug",
slugHint: "— строчные буквы, дефисы, например atm10-server",
displayName: "Отображаемое имя",
displayNameHint: "(необязательно)",
description: "Описание",
descriptionHint: "(необязательно)",
icon: "Значок",
iconHint: "(один символ или эмодзи)",
switchAfterCreate: "Переключиться на эту доску после создания",
cancel: "Отмена",
creating: "Создание…",
createBoard: "Создать доску",
search: "Поиск",
filterCards: "Фильтр карточек…",
tenant: "Tenant",
allTenants: "Все tenant'ы",
assignee: "Исполнитель",
allProfiles: "Все профили",
showArchived: "Показать архив",
lanesByProfile: "Дорожки по профилю",
nudgeDispatcher: "Подтолкнуть диспетчер",
refresh: "Обновить",
selected: "выбрано",
complete: "Завершить",
archive: "В архив",
apply: "Применить",
clear: "Очистить",
createTask: "Создать задачу в этой колонке",
noTasks: "— нет задач —",
unassigned: "без исполнителя",
untitled: "(без названия)",
loadingDetail: "Загрузка…",
addComment: "Добавить комментарий… (Enter — отправить)",
comment: "Комментарий",
status: "Статус",
workspace: "Рабочая область",
skills: "Навыки",
createdBy: "Создал",
result: "Результат",
comments: "Комментарии",
events: "События",
runHistory: "История запусков",
workerLog: "Журнал воркера",
loadingLog: "Загрузка журнала…",
noWorkerLog:
"— журнала воркера ещё нет (задача не запускалась или журнал был ротирован) —",
noDescription: "— нет описания —",
noComments: "— нет комментариев —",
edit: "изменить",
save: "Сохранить",
dependencies: "Зависимости",
parents: "Родители:",
children: "Потомки:",
none: "нет",
addParent: "— добавить родителя —",
addChild: "— добавить потомка —",
removeDependency: "Удалить зависимость",
block: "Заблокировать",
unblock: "Разблокировать",
notifyHomeChannels: "Уведомить домашние каналы",
diagnostics: "Диагностика",
hide: "Скрыть",
show: "Показать",
attention: "Внимание",
tasksNeedAttention: "задач(и) требуют внимания",
taskNeedsAttention: "1 задача требует внимания",
diagnostic: "диагностика",
open: "Открыть",
close: "Закрыть (Esc)",
reassignTo: "Переназначить на:",
copied: "Скопировано",
copyCommand: "Скопировать команду в буфер обмена",
reclaim: "Вернуть",
reassign: "Переназначить",
renderingError: "Во вкладке Kanban произошла ошибка отрисовки",
reloadView: "Перезагрузить вид",
wsAuthFailed:
"Сбой аутентификации WebSocket — перезагрузите страницу, чтобы обновить токен сессии.",
markDone: "Отметить {n} задач(и) как выполненные?",
markArchived: "Архивировать {n} задач(и)?",
warning: "Предупреждение",
phantomIds: "Фантомные id:",
active: "активно",
ended: "завершено",
noProfile: "(нет профиля)",
showAllAttempts: "Показать все попытки",
sendingUpdates: "Отправка обновлений в",
sendNotifications: "Отправлять уведомления completed / blocked / gave_up в",
archiveBoardConfirm:
"Архивировать доску '{name}'? Она будет перемещена в boards/_archived/, чтобы её можно было восстановить позже. Задачи этой доски больше не будут отображаться нигде в интерфейсе.",
archiveBoardTitle: "Архивировать эту доску",
boardSwitcherHint: "Доски позволяют разделять не связанные между собой потоки работы",
taskCreatedWarning: "Задача создана, но: ",
moveFailed: "Не удалось переместить: ",
bulkFailed: "Массовая операция: ",
completionBlockedHallucination: "⚠ Завершение заблокировано — фантомные id карточек",
suspectedHallucinatedReferences: "⚠ В тексте упомянуты фантомные id карточек",
pickProfileFirst: "Сначала выберите профиль.",
unblockedMessage: "{id} разблокирована. Задача готова к следующему тику.",
unblockFailed: "Не удалось разблокировать: ",
reclaimedMessage: "{id} возвращена. Задача снова в состоянии ready.",
reclaimFailed: "Не удалось вернуть: ",
reassignedMessage: "{id} переназначена на {profile}.",
reassignFailed: "Не удалось переназначить: ",
selectForBulk: "Выбрать для массовых действий",
clickToEdit: "Нажмите, чтобы изменить",
clickToEditAssignee: "Нажмите, чтобы изменить исполнителя",
emptyAssignee: "(пусто = снять назначение)",
columnLabels: {
triage: "Сортировка",
todo: "К выполнению",
scheduled: "Запланировано",
ready: "Готово к работе",
running: "В работе",
blocked: "Заблокировано",
done: "Готово",
archived: "В архиве",
},
columnHelp: {
triage: "Сырые идеи — specifier подготовит спецификацию",
todo: "Ожидает зависимостей или без исполнителя",
scheduled: "Ожидает известной задержки по времени или запланированного продолжения",
ready: "Зависимости выполнены; назначьте профиль для диспетчеризации",
running: "Взято воркером — выполняется",
blocked: "Воркер запросил вмешательство человека",
done: "Завершено",
archived: "В архиве",
},
confirmDone:
"Отметить эту задачу как выполненную? Захват воркера будет освобождён, а зависимые потомки станут готовыми.",
confirmArchive:
"Архивировать эту задачу? Она исчезнет из стандартного вида доски.",
confirmBlocked:
"Отметить эту задачу как заблокированную? Захват воркера будет освобождён.",
completionSummary:
"Сводка завершения для {label}. Сохраняется как результат задачи.",
completionSummaryRequired:
"Перед отметкой задачи как выполненной требуется сводка завершения.",
triagePlaceholder: "Черновая идея — ИИ её проспецифицирует…",
taskTitlePlaceholder: "Название новой задачи…",
specifier: "specifier",
assigneePlaceholder: "исполнитель",
priority: "Приоритет",
skillsPlaceholder:
"навыки (необязательно, через запятую): translation, github-code-review",
noParent: "— без родителя —",
workspacePathDir: "путь к рабочей области (обязательно, например ~/projects/my-app)",
workspacePathOptional:
"путь к рабочей области (необязательно, выводится из исполнителя, если не указан)",
logTruncated: "(показаны последние 100 KB — полный журнал в ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const tr: Translations = {
common: {
save: "Kaydet",
saving: "Kaydediliyor...",
cancel: "İptal",
close: "Kapat",
confirm: "Onayla",
delete: "Sil",
refresh: "Yenile",
retry: "Yeniden dene",
search: "Ara...",
loading: "Yükleniyor...",
create: "Oluştur",
creating: "Oluşturuluyor...",
set: "Ayarla",
replace: "Değiştir",
clear: "Temizle",
live: "Canlı",
off: "Kapalı",
enabled: "etkin",
disabled: "devre dışı",
active: "aktif",
inactive: "pasif",
unknown: "bilinmiyor",
untitled: "Başlıksız",
none: "Yok",
form: "Form",
noResults: "Sonuç yok",
of: "/",
page: "Sayfa",
msgs: "mesaj",
tools: "araçlar",
match: "eşleşme",
other: "Diğer",
configured: "yapılandırıldı",
removed: "kaldırıldı",
failedToToggle: "Değiştirilemedi",
failedToRemove: "Kaldırılamadı",
failedToReveal: "Gösterilemedi",
collapse: "Daralt",
expand: "Genişlet",
general: "Genel",
messaging: "Mesajlaşma",
pluginLoadFailed:
"Bu eklentinin betiği yüklenemedi. Ağ sekmesini (dashboard-plugins/…) ve sunucunun eklenti yolunu kontrol edin.",
pluginNotRegistered:
"Eklenti betiği register() çağırmadı veya betik hata verdi. Ayrıntılar için tarayıcı konsolunu açın.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Gezintiyi kapat",
closeModelTools: "Modeli ve araçları kapat",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Aktif Oturumlar:",
gatewayStatusLabel: "Ağ Geçidi Durumu:",
gatewayStrip: {
failed: "Başlatma başarısız",
off: "Kapalı",
running: "Çalışıyor",
starting: "Başlatılıyor",
stopped: "Durduruldu",
},
nav: {
analytics: "Analiz",
chat: "Sohbet",
config: "Yapılandırma",
cron: "Cron",
documentation: "Dokümantasyon",
keys: "Anahtarlar",
logs: "Günlükler",
models: "Modeller",
profiles: "profiller : çoklu agent",
plugins: "Eklentiler",
sessions: "Oturumlar",
skills: "Yetenekler",
},
modelToolsSheetSubtitle: "& araçlar",
modelToolsSheetTitle: "Model",
navigation: "Gezinti",
openDocumentation: "Dokümantasyonu yeni sekmede aç",
openNavigation: "Gezintiyi aç",
pluginNavSection: "Eklentiler",
sessionsActiveCount: "{count} aktif",
statusOverview: "Durum özeti",
system: "Sistem",
webUi: "Web UI",
},
status: {
actionFailed: "İşlem başarısız",
actionFinished: "Tamamlandı",
actions: "İşlemler",
agent: "Agent",
activeSessions: "Aktif Oturumlar",
connected: "Bağlandı",
connectedPlatforms: "Bağlı Platformlar",
disconnected: "Bağlantı kesildi",
error: "Hata",
failed: "Başarısız",
gateway: "Ağ Geçidi",
gatewayFailedToStart: "Ağ geçidi başlatılamadı",
lastUpdate: "Son güncelleme",
noneRunning: "Yok",
notRunning: "Çalışmıyor",
pid: "PID",
platformDisconnected: "bağlantı kesildi",
platformError: "hata",
recentSessions: "Son Oturumlar",
restartGateway: "Ağ Geçidini Yeniden Başlat",
restartingGateway: "Ağ geçidi yeniden başlatılıyor…",
running: "Çalışıyor",
runningRemote: "Çalışıyor (uzak)",
startFailed: "Başlatma başarısız",
starting: "Başlatılıyor",
startedInBackground: "Arka planda başlatıldı — ilerleme için günlüklere bakın",
stopped: "Durduruldu",
updateHermes: "Hermes'i Güncelle",
updatingHermes: "Hermes güncelleniyor…",
waitingForOutput: "Çıktı bekleniyor…",
},
sessions: {
title: "Oturumlar",
history: "Geçmiş",
overview: "Genel bakış",
searchPlaceholder: "Mesaj içeriğinde ara...",
noSessions: "Henüz oturum yok",
noMatch: "Aramanızla eşleşen oturum yok",
startConversation: "Burada görmek için bir konuşma başlatın",
noMessages: "Mesaj yok",
untitledSession: "Başlıksız oturum",
deleteSession: "Oturumu sil",
confirmDeleteTitle: "Oturum silinsin mi?",
confirmDeleteMessage:
"Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
sessionDeleted: "Oturum silindi",
failedToDelete: "Oturum silinemedi",
resumeInChat: "Sohbette Devam Et",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
roles: {
user: "Kullanıcı",
assistant: "Asistan",
system: "Sistem",
tool: "Araç",
},
},
analytics: {
period: "Dönem:",
totalTokens: "Toplam Token",
totalSessions: "Toplam Oturum",
apiCalls: "API Çağrıları",
dailyTokenUsage: "Günlük Token Kullanımı",
dailyBreakdown: "Günlük Dağılım",
perModelBreakdown: "Model Bazında Dağılım",
topSkills: "En Çok Kullanılan Yetenekler",
skill: "Yetenek",
loads: "Agent Yüklendi",
edits: "Agent Yönetildi",
lastUsed: "Son Kullanım",
input: "Giriş",
output: "Çıkış",
total: "Toplam",
noUsageData: "Bu dönem için kullanım verisi yok",
startSession: "Burada analizleri görmek için bir oturum başlatın",
date: "Tarih",
model: "Model",
tokens: "Token",
perDayAvg: "/gün ort",
acrossModels: "{count} model üzerinden",
inOut: "{input} giriş / {output} çıkış",
},
models: {
modelsUsed: "Kullanılan Modeller",
estimatedCost: "Tahmini Maliyet",
tokens: "token",
sessions: "oturum",
avgPerSession: "ort/oturum",
apiCalls: "API çağrıları",
toolCalls: "araç çağrıları",
noModelsData: "Bu dönem için model kullanım verisi yok",
startSession: "Burada model verilerini görmek için bir oturum başlatın",
},
logs: {
title: "Günlükler",
autoRefresh: "Otomatik yenile",
file: "Dosya",
level: "Seviye",
component: "Bileşen",
lines: "Satırlar",
noLogLines: "Günlük satırı bulunamadı",
},
cron: {
confirmDeleteMessage:
"Bu, görevi zamanlamadan kaldırır. Bu işlem geri alınamaz.",
confirmDeleteTitle: "Zamanlanmış görev silinsin mi?",
newJob: "Yeni Cron Görevi",
nameOptional: "Ad (isteğe bağlı)",
namePlaceholder: "örn. Günlük özet",
prompt: "İstem",
promptPlaceholder: "Agent her çalıştırmada ne yapmalı?",
schedule: "Zamanlama (cron ifadesi)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Şuraya teslim et",
scheduledJobs: "Zamanlanmış Görevler",
noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.",
last: "Son",
next: "Sonraki",
pause: "Duraklat",
resume: "Devam ettir",
triggerNow: "Şimdi tetikle",
delivery: {
local: "Yerel",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Yeni Profil",
name: "Ad",
namePlaceholder: "örn. coder, writer, vb.",
nameRequired: "Ad gereklidir",
nameRule:
"Yalnızca küçük harfler, rakamlar, _ ve - kullanılabilir; harf veya rakamla başlamalı; en fazla 64 karakter.",
invalidName: "Geçersiz profil adı",
cloneFromDefault: "Varsayılan profilden yapılandırmayı klonla",
allProfiles: "Profiller",
noProfiles: "Profil bulunamadı.",
defaultBadge: "varsayılan",
hasEnv: "env",
model: "Model",
skills: "Yetenekler",
rename: "Yeniden adlandır",
editSoul: "SOUL.md'yi düzenle",
soulSection: "SOUL.md (kişilik / sistem istemi)",
soulPlaceholder: "# Bu agent nasıl davranmalı…",
saveSoul: "SOUL'u kaydet",
soulSaved: "SOUL.md kaydedildi",
openInTerminal: "CLI komutunu kopyala",
commandCopied: "Panoya kopyalandı",
copyFailed: "Kopyalanamadı",
confirmDeleteTitle: "Profil silinsin mi?",
confirmDeleteMessage:
"Bu, '{name}' profilini kalıcı olarak siler — yapılandırma, anahtarlar, hatıralar, oturumlar, yetenekler, cron görevleri. Geri alınamaz.",
created: "Oluşturuldu",
deleted: "Silindi",
renamed: "Yeniden adlandırıldı",
},
pluginsPage: {
contextEngineLabel: "Bağlam motoru",
dashboardSlots: "Pano yuvaları",
disableRuntime: "Devre dışı bırak",
enableAfterInstall: "Yüklemeden sonra etkinleştir",
enableRuntime: "Etkinleştir",
forceReinstall: "Yeniden yüklemeyi zorla (önce mevcut klasörü sil)",
headline:
"Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).",
identifierLabel: "Git URL veya owner/repo",
inactive: "pasif",
installBtn: "Yükle",
installHeading: "GitHub / Git URL'sinden yükle",
installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.",
memoryProviderLabel: "Bellek sağlayıcısı",
missingEnvWarn: "Eklenti çalışmadan önce bunları Anahtarlar bölümünde ayarlayın:",
noDashboardTab: "Pano sekmesi yok",
openTab: "Aç",
orphanHeading: "Yalnızca pano uzantıları (eşleşen agent plugin.yaml yok)",
pluginListHeading: "Yüklü eklentiler",
providerDefaults: "yerleşik / varsayılan",
providersHeading: "Çalışma zamanı sağlayıcı eklentileri",
providersHint:
"config.yaml'a memory.provider (boş = yerleşik) ve context.engine yazar. Bir sonraki oturumda etkili olur.",
refreshDashboard: "Pano uzantılarını yeniden tara",
removeConfirm: "Bu eklenti ~/.hermes/plugins/ içinden kaldırılsın mı?",
removeHint: "Yalnızca ~/.hermes/plugins altındaki kullanıcı tarafından yüklenmiş eklentiler kaldırılabilir.",
rescanHeading: "SPA eklenti kayıt defteri",
rescanHint: "Diske dosya ekledikten sonra yeniden tarayın, böylece pano kenar çubuğu yeni manifestleri algılar.",
runtimeHeading: "Ağ geçidi çalışma zamanı (YAML eklentileri)",
saveProviders: "Sağlayıcı ayarlarını kaydet",
savedProviders: "Sağlayıcı ayarları kaydedildi.",
sourceBadge: "Kaynak",
authRequired: "Kimlik doğrulama gerekli",
authRequiredHint: "Kimlik doğrulamak için bu komutu çalıştırın:",
updateGit: "Git pull",
versionBadge: "Sürüm",
showInSidebar: "Kenar çubuğunda göster",
hideFromSidebar: "Kenar çubuğundan gizle",
},
skills: {
title: "Yetenekler",
searchPlaceholder: "Yetenek ve araç setlerinde ara...",
enabledOf: "{enabled}/{total} etkin",
all: "Tümü",
categories: "Kategoriler",
filters: "Filtreler",
noSkills: "Yetenek bulunamadı. Yetenekler ~/.hermes/skills/ adresinden yüklenir",
noSkillsMatch: "Aramanız veya filtrenizle eşleşen yetenek yok.",
skillCount: "{count} yetenek{s}",
resultCount: "{count} sonuç{s}",
noDescription: "Açıklama mevcut değil.",
toolsets: "Araç setleri",
toolsetLabel: "{name} araç seti",
noToolsetsMatch: "Aramayla eşleşen araç seti yok.",
setupNeeded: "Kurulum gerekli",
disabledForCli: "CLI için devre dışı",
more: "+{count} daha",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filtreler",
sections: "Bölümler",
exportConfig: "Yapılandırmayı JSON olarak dışa aktar",
importConfig: "Yapılandırmayı JSON'dan içe aktar",
resetDefaults: "Varsayılanlara sıfırla",
resetScopeTooltip: "{scope} varsayılanlara sıfırla",
confirmResetScope: "{scope} ayarlarının tümü varsayılanlara sıfırlansın mı? Bu yalnızca formu günceller — değişiklikler Kaydet'e basılana kadar config.yaml'a yazılmaz.",
resetScopeToast: "{scope} varsayılanlara sıfırlandı — gözden geçirip kalıcı kılmak için Kaydet'e basın",
rawYaml: "Ham YAML Yapılandırması",
searchResults: "Arama Sonuçları",
fields: "alan{s}",
noFieldsMatch: '"{query}" ile eşleşen alan yok',
configSaved: "Yapılandırma kaydedildi",
yamlConfigSaved: "YAML yapılandırması kaydedildi",
failedToSave: "Kaydedilemedi",
failedToSaveYaml: "YAML kaydedilemedi",
failedToLoadRaw: "Ham yapılandırma yüklenemedi",
configImported: "Yapılandırma içe aktarıldı — gözden geçirip kaydedin",
invalidJson: "Geçersiz JSON dosyası",
categories: {
general: "Genel",
agent: "Agent",
terminal: "Terminal",
display: "Görüntü",
delegation: "Yetkilendirme",
memory: "Bellek",
compression: "Sıkıştırma",
security: "Güvenlik",
browser: "Tarayıcı",
voice: "Ses",
tts: "Metinden Konuşmaya",
stt: "Konuşmadan Metne",
logging: "Günlükleme",
discord: "Discord",
auxiliary: "Yardımcı",
},
},
env: {
changesNote: "Değişiklikler diske hemen kaydedilir. Aktif oturumlar yeni anahtarları otomatik olarak alır.",
confirmClearMessage:
"Bu değişken için saklanan değer .env dosyanızdan kaldırılacak. Bu işlem arayüzden geri alınamaz.",
confirmClearTitle: "Bu anahtar temizlensin mi?",
description: "Şurada saklanan API anahtarlarını ve sırları yönetin",
hideAdvanced: "Gelişmişi Gizle",
showAdvanced: "Gelişmişi Göster",
showLess: "Daha az göster",
showMore: "Daha fazla göster",
llmProviders: "LLM Sağlayıcıları",
providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı",
getKey: "Anahtar al",
notConfigured: "{count} yapılandırılmamış",
notSet: "Ayarlanmadı",
keysCount: "{count} anahtar",
enterValue: "Değer girin...",
replaceCurrentValue: "Mevcut değeri değiştir ({preview})",
showValue: "Gerçek değeri göster",
hideValue: "Değeri gizle",
},
oauth: {
title: "Sağlayıcı Girişleri (OAuth)",
providerLogins: "Sağlayıcı Girişleri (OAuth)",
description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.",
connected: "Bağlandı",
expired: "Süresi doldu",
notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.",
runInTerminal: "bir terminalde.",
noProviders: "OAuth uyumlu sağlayıcı algılanmadı.",
login: "Giriş",
disconnect: "Bağlantıyı kes",
managedExternally: "Harici olarak yönetiliyor",
copied: "Kopyalandı ✓",
cli: "Kopyala",
copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
connect: "Bağlan",
sessionExpires: "Oturumun süresi {time} sonra dolacak",
initiatingLogin: "Giriş akışı başlatılıyor…",
exchangingCode: "Kod, jetonlarla değiştiriliyor…",
connectedClosing: "Bağlandı! Kapatılıyor…",
loginFailed: "Giriş başarısız.",
sessionExpired: "Oturum süresi doldu. Yeni bir giriş başlatmak için Yeniden Dene'ye tıklayın.",
reOpenAuth: "Kimlik doğrulama sayfasını yeniden aç",
reOpenVerification: "Doğrulama sayfasını yeniden aç",
submitCode: "Kodu gönder",
pasteCode: "Yetkilendirme kodunu yapıştırın (#state ekiyle de olabilir)",
waitingAuth: "Tarayıcıda yetkilendirmeniz bekleniyor…",
enterCodePrompt: "Yeni bir sekme açıldı. İstenirse bu kodu girin:",
pkceStep1: "claude.ai için yeni bir sekme açıldı. Giriş yapın ve Yetkilendir'e tıklayın.",
pkceStep2: "Yetkilendirmeden sonra gösterilen yetkilendirme kodunu kopyalayın.",
pkceStep3: "Aşağıya yapıştırıp gönderin.",
flowLabels: {
pkce: "Tarayıcı girişi (PKCE)",
device_code: "Cihaz kodu",
external: "Harici CLI",
},
expiresIn: "{time} sonra sona erer",
},
language: {
switchTo: "Dil değiştir",
},
theme: {
title: "Tema",
switchTheme: "Temayı değiştir",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Gerçek oturum geçmişinden kazanılan, koleksiyonluk Hermes rozetleri. Bilinen ama henüz tamamlanmamış başarılar Keşfedildi olarak gösterilir; Gizli başarılar ilk eşleşen davranış görünene kadar saklı kalır.",
scan_subtitle:
"Hermes oturum geçmişi taranıyor. Büyük geçmişlerde ilk tarama 510 saniye sürebilir.",
},
actions: {
rescan: "Yeniden tara",
},
stats: {
unlocked: "Açıldı",
unlocked_hint: "kazanılan rozetler",
discovered: "Keşfedildi",
discovered_hint: "biliniyor, henüz kazanılmadı",
secrets: "Sırlar",
secrets_hint: "ilk sinyale kadar gizli",
highest_tier: "En yüksek kademe",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "En son",
latest_hint_empty: "Hermes'i daha çok çalıştır",
none_yet: "Henüz yok",
},
state: {
unlocked: "Açıldı",
discovered: "Keşfedildi",
secret: "Gizli",
},
tier: {
target: "Hedef {tier}",
hidden: "Gizli",
complete: "Tamamlandı",
objective: "Amaç",
},
progress: {
hidden: "gizli",
},
scan: {
building_headline: "Başarı profili oluşturuluyor…",
building_detail:
"Oturumlar, araç çağrıları, model meta verileri ve açılma durumu okunuyor.",
starting_headline: "Başarı taraması başlatılıyor…",
progress_detail:
"{total} oturumun {scanned} tanesi tarandı · %{pct}. Daha fazla geçmiş aktıkça rozetler açılır.",
idle_detail:
"Oturumlar, araç çağrıları, model meta verileri ve açılma durumu okunuyor. Rozetler açıldıkça burada görünür.",
},
guide: {
tiers_header: "Kademeler",
secret_header: "Gizli başarılar",
secret_body:
"Sırlar, tetikleyicilerini saklı tutar. Hermes ilgili bir sinyal gördüğünde kart Keşfedildi durumuna geçer ve gereksinimini gösterir.",
scan_status_header: "Tarama durumu",
scan_status_body:
"Hermes yerel geçmişi bir kez tarıyor; sonra kartlar otomatik olarak görünür. Birkaç saniye sürmesi normaldir, hiçbir şey takılmadı.",
what_scanned_header: "Neler taranır",
what_scanned_body:
"Oturumlar, araç çağrıları, model meta verileri, hatalar, başarılar ve yerel açılma durumu.",
},
card: {
share_title: "Bu başarıyı paylaş",
share_label: "{name} paylaş",
share_text: "Paylaş",
how_to_reveal: "Nasıl ortaya çıkarılır",
what_counts: "Neler sayılır",
evidence_label: "Kanıt",
evidence_session_fallback: "oturum",
no_evidence: "Henüz kanıt yok",
},
latest: {
header: "Son açılanlar",
},
empty: {
no_secrets_header: "Bu taramada gizli sır kalmadı.",
no_secrets_body:
"İpucu: sırlar genellikle alışılmadık hata veya ileri kullanıcı kalıplarıyla başlar — port çakışmaları, izin duvarları, eksik ortam değişkenleri, YAML hataları, Docker çakışmaları, geri alma/checkpoint kullanımı, önbellek isabetleri ya da çokça kırmızı yazıdan sonra yapılan ufak düzeltmeler.",
},
filters: {
all_categories: "Tümü",
visibility_all: "tümü",
visibility_unlocked: "açıldı",
visibility_discovered: "keşfedildi",
visibility_secret: "gizli",
},
share: {
dialog_label: "Başarıyı paylaş",
header: "Paylaş: {name}",
close: "Kapat",
rendering: "Oluşturuluyor…",
card_alt: "{name} paylaşım kartı",
error_generic: "Bir şeyler ters gitti.",
x_title: "X'i önceden doldurulmuş bir gönderiyle açar",
x_button: "X'te paylaş",
copy_title: "Görseli kopyalayıp gönderine yapıştır",
copy_button: "Görseli kopyala",
copied: "Kopyalandı ✓",
download_button: "PNG indir",
hint:
"X'te paylaş, yeni sekmede önceden doldurulmuş bir gönderi açar. 1200×630 rozetin eklenmesini istiyorsan önce Görseli kopyala'ya tıkla — X, görseli doğrudan tweet düzenleyiciye yapıştırmana izin verir. PNG indir, dosyayı her yerde kullanmak üzere kaydeder.",
clipboard_unsupported:
"Bu tarayıcıda panoya görsel kopyalama desteklenmiyor — bunun yerine İndir'i kullanın.",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Kanban panosu yükleniyor…",
loadFailed: "Kanban panosu yüklenemedi: ",
loadFailedHint:
"Backend, ilk okumada kanban.db'yi otomatik olarak oluşturur. Sorun devam ederse panel günlüklerini kontrol edin.",
board: "Pano",
newBoard: "+ Yeni pano",
newBoardTitle: "Yeni pano",
newBoardDescription:
"Panolar, ilgisiz iş akışlarını ayırmanızı sağlar — proje, depo veya alan başına bir pano. Bir panodaki worker'lar başka bir panonun görevlerini asla görmez.",
slug: "Slug",
slugHint: "— küçük harf, tire, ör. atm10-server",
displayName: "Görünen ad",
displayNameHint: "(isteğe bağlı)",
description: "Açıklama",
descriptionHint: "(isteğe bağlı)",
icon: "Simge",
iconHint: "(tek karakter veya emoji)",
switchAfterCreate: "Oluşturduktan sonra bu panoya geç",
cancel: "İptal",
creating: "Oluşturuluyor…",
createBoard: "Pano oluştur",
search: "Ara",
filterCards: "Kartları filtrele…",
tenant: "Tenant",
allTenants: "Tüm tenant'lar",
assignee: "Atanan kişi",
allProfiles: "Tüm profiller",
showArchived: "Arşivlenenleri göster",
lanesByProfile: "Profile göre şeritler",
nudgeDispatcher: "Dispatcher'ı dürt",
refresh: "Yenile",
selected: "seçili",
complete: "Tamamla",
archive: "Arşivle",
apply: "Uygula",
clear: "Temizle",
createTask: "Bu sütunda görev oluştur",
noTasks: "— görev yok —",
unassigned: "atanmamış",
untitled: "(başlıksız)",
loadingDetail: "Yükleniyor…",
addComment: "Yorum ekle… (göndermek için Enter)",
comment: "Yorum",
status: "Durum",
workspace: "Workspace",
skills: "Beceriler",
createdBy: "Oluşturan",
result: "Result",
comments: "Yorumlar",
events: "Olaylar",
runHistory: "Çalıştırma geçmişi",
workerLog: "Worker günlüğü",
loadingLog: "Günlük yükleniyor…",
noWorkerLog:
"— henüz worker günlüğü yok (görev başlatılmadı veya günlük döndürüldü) —",
noDescription: "— açıklama yok —",
noComments: "— yorum yok —",
edit: "düzenle",
save: "Kaydet",
dependencies: "Bağımlılıklar",
parents: "Üstler:",
children: "Altlar:",
none: "yok",
addParent: "— üst ekle —",
addChild: "— alt ekle —",
removeDependency: "Bağımlılığı kaldır",
block: "Engelle",
unblock: "Engeli kaldır",
notifyHomeChannels: "Ana kanalları bilgilendir",
diagnostics: "Tanılama",
hide: "Gizle",
show: "Göster",
attention: "Dikkat",
tasksNeedAttention: "görev dikkat gerektiriyor",
taskNeedsAttention: "1 görev dikkat gerektiriyor",
diagnostic: "tanılama",
open: "Aç",
close: "Kapat (Esc)",
reassignTo: "Yeniden ata:",
copied: "Kopyalandı",
copyCommand: "Komutu panoya kopyala",
reclaim: "Geri al",
reassign: "Yeniden ata",
renderingError: "Kanban sekmesinde bir oluşturma hatası oluştu",
reloadView: "Görünümü yeniden yükle",
wsAuthFailed:
"WebSocket kimlik doğrulaması başarısız — oturum jetonunu yenilemek için sayfayı yeniden yükleyin.",
markDone: "{n} görev tamamlandı olarak işaretlensin mi?",
markArchived: "{n} görev arşivlensin mi?",
warning: "Uyarı",
phantomIds: "Hayalet ID'ler:",
active: "etkin",
ended: "sona erdi",
noProfile: "(profil yok)",
showAllAttempts: "Tüm denemeleri göster",
sendingUpdates: "Güncellemeler şuraya gönderiliyor",
sendNotifications: "completed / blocked / gave_up bildirimlerini şuraya gönder",
archiveBoardConfirm:
"'{name}' panosu arşivlensin mi? boards/_archived/ dizinine taşınacak, böylece daha sonra kurtarabilirsiniz. Bu panodaki görevler artık UI'nin hiçbir yerinde görünmeyecek.",
archiveBoardTitle: "Bu panoyu arşivle",
boardSwitcherHint: "Panolar, ilgisiz iş akışlarını ayırmanızı sağlar",
taskCreatedWarning: "Görev oluşturuldu, ancak: ",
moveFailed: "Taşıma başarısız: ",
bulkFailed: "Toplu: ",
completionBlockedHallucination: "⚠ Tamamlanma engellendi — hayalet kart ID'leri",
suspectedHallucinatedReferences: "⚠ Metin hayalet kart ID'lerine atıfta bulundu",
pickProfileFirst: "Önce bir profil seçin.",
unblockedMessage: "{id} engeli kaldırıldı. Görev sonraki tick için hazır.",
unblockFailed: "Engel kaldırma başarısız: ",
reclaimedMessage: "{id} geri alındı. Görev tekrar hazır.",
reclaimFailed: "Geri alma başarısız: ",
reassignedMessage: "{id}, {profile} kişisine yeniden atandı.",
reassignFailed: "Yeniden atama başarısız: ",
selectForBulk: "Toplu işlemler için seç",
clickToEdit: "Düzenlemek için tıklayın",
clickToEditAssignee: "Atanan kişiyi düzenlemek için tıklayın",
emptyAssignee: "(boş = atamayı kaldır)",
columnLabels: {
triage: "Triyaj",
todo: "Yapılacak",
scheduled: "Zamanlandı",
ready: "Hazır",
running: "Sürüyor",
blocked: "Engellendi",
done: "Bitti",
archived: "Arşivlendi",
},
columnHelp: {
triage: "Ham fikirler — bir specifier şartnameyi detaylandıracak",
todo: "Bağımlılıklar bekleniyor veya atanmamış",
scheduled: "Bilinen bir zaman gecikmesi veya zamanlanmış takip bekleniyor",
ready: "Bağımlılıklar karşılandı; dispatch için bir profil atayın",
running: "Bir worker tarafından alındı — yürütülüyor",
blocked: "Worker insan girdisi istedi",
done: "Tamamlandı",
archived: "Arşivlendi",
},
confirmDone:
"Bu görev tamamlandı olarak işaretlensin mi? Worker'ın sahiplenmesi serbest bırakılır ve bağımlı altlar hazır hale gelir.",
confirmArchive:
"Bu görev arşivlensin mi? Varsayılan pano görünümünden kaybolur.",
confirmBlocked:
"Bu görev engellendi olarak işaretlensin mi? Worker'ın sahiplenmesi serbest bırakılır.",
completionSummary:
"{label} için tamamlanma özeti. Görev result'ı olarak saklanır.",
completionSummaryRequired:
"Bir görevi tamamlandı olarak işaretlemeden önce tamamlanma özeti gereklidir.",
triagePlaceholder: "Kabataslak fikir — yapay zeka şartnameyi yazacak…",
taskTitlePlaceholder: "Yeni görev başlığı…",
specifier: "specifier",
assigneePlaceholder: "atanan",
priority: "Öncelik",
skillsPlaceholder:
"beceriler (isteğe bağlı, virgülle ayrılmış): translation, github-code-review",
noParent: "— üst yok —",
workspacePathDir: "workspace yolu (zorunlu, ör. ~/projects/my-app)",
workspacePathOptional:
"workspace yolu (isteğe bağlı, boşsa atanan kişiden türetilir)",
logTruncated: "(son 100 KB gösteriliyor — tam günlük şurada: ",
logAt: ")",
},
};
+708
View File
@@ -0,0 +1,708 @@
export type Locale =
| "en"
| "zh"
| "zh-hant"
| "ja"
| "de"
| "es"
| "fr"
| "tr"
| "uk"
| "af"
| "ko"
| "it"
| "ga"
| "pt"
| "ru"
| "hu";
export interface Translations {
// ── Common ──
common: {
save: string;
saving: string;
cancel: string;
close: string;
confirm: 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;
pluginLoadFailed: string;
pluginNotRegistered: string;
};
// ── App shell ──
app: {
brand: string;
brandShort: string;
closeNavigation: string;
closeModelTools: string;
footer: {
org: string;
};
activeSessionsLabel: string;
gatewayStatusLabel: string;
gatewayStrip: {
failed: string;
off: string;
running: string;
starting: string;
stopped: string;
};
nav: {
analytics: string;
chat: string;
config: string;
cron: string;
documentation: string;
keys: string;
logs: string;
models: string;
profiles: string;
plugins: string;
sessions: string;
skills: string;
};
modelToolsSheetSubtitle: string;
modelToolsSheetTitle: string;
navigation: string;
openDocumentation: string;
openNavigation: string;
pluginNavSection: string;
sessionsActiveCount: string;
statusOverview: string;
system: string;
webUi: string;
};
// ── Status page ──
status: {
actionFailed: string;
actionFinished: string;
actions: string;
agent: string;
connected: string;
connectedPlatforms: string;
disconnected: string;
error: string;
failed: string;
gateway: string;
gatewayFailedToStart: string;
lastUpdate: string;
noneRunning: string;
notRunning: string;
pid: string;
platformDisconnected: string;
platformError: string;
activeSessions: string;
recentSessions: string;
restartGateway: string;
restartingGateway: string;
running: string;
runningRemote: string;
startFailed: string;
starting: string;
startedInBackground: string;
stopped: string;
updateHermes: string;
updatingHermes: string;
waitingForOutput: string;
};
// ── Sessions page ──
sessions: {
title: string;
history: string;
overview: string;
searchPlaceholder: string;
noSessions: string;
noMatch: string;
startConversation: string;
noMessages: string;
untitledSession: string;
deleteSession: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
resumeInChat: 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;
topSkills: string;
skill: string;
loads: string;
edits: string;
lastUsed: string;
input: string;
output: string;
total: string;
noUsageData: string;
startSession: string;
date: string;
model: string;
tokens: string;
perDayAvg: string;
acrossModels: string;
inOut: string;
};
// ── Models page ──
models: {
modelsUsed: string;
estimatedCost: string;
tokens: string;
sessions: string;
avgPerSession: string;
apiCalls: string;
toolCalls: string;
noModelsData: string;
startSession: string;
};
// ── Logs page ──
logs: {
title: string;
autoRefresh: string;
file: string;
level: string;
component: string;
lines: string;
noLogLines: string;
};
// ── Cron page ──
cron: {
confirmDeleteMessage: string;
confirmDeleteTitle: string;
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;
};
};
// ── Plugins page ──
pluginsPage: {
contextEngineLabel: string;
dashboardSlots: string;
disableRuntime: string;
enableAfterInstall: string;
enableRuntime: string;
forceReinstall: string;
headline: string;
identifierLabel: string;
inactive: string;
installBtn: string;
installHeading: string;
installHint: string;
memoryProviderLabel: string;
missingEnvWarn: string;
noDashboardTab: string;
openTab: string;
orphanHeading: string;
pluginListHeading: string;
providerDefaults: string;
providersHeading: string;
providersHint: string;
refreshDashboard: string;
removeConfirm: string;
removeHint: string;
rescanHeading: string;
rescanHint: string;
runtimeHeading: string;
saveProviders: string;
savedProviders: string;
sourceBadge: string;
authRequired: string;
authRequiredHint: string;
updateGit: string;
versionBadge: string;
showInSidebar: string;
hideFromSidebar: string;
};
// ── Profiles page ──
profiles: {
newProfile: string;
name: string;
namePlaceholder: string;
nameRequired: string;
nameRule: string;
invalidName: string;
cloneFromDefault: string;
allProfiles: string;
noProfiles: string;
defaultBadge: string;
hasEnv: string;
model: string;
skills: string;
rename: string;
editSoul: string;
soulSection: string;
soulPlaceholder: string;
saveSoul: string;
soulSaved: string;
openInTerminal: string;
commandCopied: string;
copyFailed: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
created: string;
deleted: string;
renamed: string;
};
// ── Skills page ──
skills: {
title: string;
searchPlaceholder: string;
enabledOf: string;
all: string;
categories: string;
filters: string;
noSkills: string;
noSkillsMatch: string;
skillCount: string;
resultCount: string;
noDescription: string;
toolsets: string;
toolsetLabel: string;
noToolsetsMatch: string;
setupNeeded: string;
disabledForCli: string;
more: string;
};
// ── Config page ──
config: {
configPath: string;
filters: string;
sections: string;
exportConfig: string;
importConfig: string;
resetDefaults: string;
resetScopeTooltip: string;
confirmResetScope: string;
resetScopeToast: 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: {
changesNote: string;
confirmClearMessage: string;
confirmClearTitle: string;
description: string;
enterValue: string;
getKey: string;
hideAdvanced: string;
hideValue: string;
keysCount: string;
llmProviders: string;
notConfigured: string;
notSet: string;
providersConfigured: string;
replaceCurrentValue: string;
showAdvanced: string;
showLess: string;
showMore: string;
showValue: 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;
};
// ── Theme switcher ──
theme: {
title: string;
switchTheme: string;
};
// ── Achievements plugin (plugins/hermes-achievements) ──
achievements: {
hero: {
kicker: string;
title: string;
subtitle: string;
scan_subtitle: string;
};
actions: {
rescan: string;
};
stats: {
unlocked: string;
unlocked_hint: string;
discovered: string;
discovered_hint: string;
secrets: string;
secrets_hint: string;
highest_tier: string;
highest_tier_hint: string;
latest: string;
latest_hint_empty: string;
none_yet: string;
};
state: {
unlocked: string;
discovered: string;
secret: string;
};
tier: {
target: string;
hidden: string;
complete: string;
objective: string;
};
progress: {
hidden: string;
};
scan: {
building_headline: string;
building_detail: string;
starting_headline: string;
progress_detail: string;
idle_detail: string;
};
guide: {
tiers_header: string;
secret_header: string;
secret_body: string;
scan_status_header: string;
scan_status_body: string;
what_scanned_header: string;
what_scanned_body: string;
};
card: {
share_title: string;
share_label: string;
share_text: string;
how_to_reveal: string;
what_counts: string;
evidence_label: string;
evidence_session_fallback: string;
no_evidence: string;
};
latest: {
header: string;
};
empty: {
no_secrets_header: string;
no_secrets_body: string;
};
filters: {
all_categories: string;
visibility_all: string;
visibility_unlocked: string;
visibility_discovered: string;
visibility_secret: string;
};
share: {
dialog_label: string;
header: string;
close: string;
rendering: string;
card_alt: string;
error_generic: string;
x_title: string;
x_button: string;
copy_title: string;
copy_button: string;
copied: string;
download_button: string;
hint: string;
clipboard_unsupported: string;
tweet_text: string;
};
};
// ── Kanban ──
kanban: {
loading: string;
loadFailed: string;
loadFailedHint: string;
board: string;
newBoard: string;
newBoardTitle: string;
newBoardDescription: string;
slug: string;
slugHint: string;
displayName: string;
displayNameHint: string;
description: string;
descriptionHint: string;
icon: string;
iconHint: string;
switchAfterCreate: string;
cancel: string;
creating: string;
createBoard: string;
search: string;
filterCards: string;
tenant: string;
allTenants: string;
assignee: string;
allProfiles: string;
showArchived: string;
lanesByProfile: string;
nudgeDispatcher: string;
refresh: string;
selected: string;
complete: string;
archive: string;
apply: string;
clear: string;
createTask: string;
noTasks: string;
unassigned: string;
needsAssignee?: string;
needsAssigneeHint?: string;
untitled: string;
loadingDetail: string;
addComment: string;
comment: string;
status: string;
workspace: string;
skills: string;
createdBy: string;
result: string;
comments: string;
events: string;
runHistory: string;
workerLog: string;
loadingLog: string;
noWorkerLog: string;
noDescription: string;
noComments: string;
edit: string;
save: string;
dependencies: string;
parents: string;
children: string;
none: string;
addParent: string;
addChild: string;
removeDependency: string;
block: string;
unblock: string;
notifyHomeChannels: string;
diagnostics: string;
hide: string;
show: string;
attention: string;
tasksNeedAttention: string;
taskNeedsAttention: string;
diagnostic: string;
open: string;
close: string;
reassignTo: string;
copied: string;
copyCommand: string;
reclaim: string;
reassign: string;
renderingError: string;
reloadView: string;
wsAuthFailed: string;
markDone: string;
markArchived: string;
warning: string;
phantomIds: string;
active: string;
ended: string;
noProfile: string;
showAllAttempts: string;
sendingUpdates: string;
sendNotifications: string;
archiveBoardConfirm: string;
archiveBoardTitle: string;
boardSwitcherHint: string;
taskCreatedWarning: string;
moveFailed: string;
bulkFailed: string;
completionBlockedHallucination: string;
suspectedHallucinatedReferences: string;
pickProfileFirst: string;
unblockedMessage: string;
unblockFailed: string;
reclaimedMessage: string;
reclaimFailed: string;
reassignedMessage: string;
reassignFailed: string;
selectForBulk: string;
clickToEdit: string;
clickToEditAssignee: string;
emptyAssignee: string;
columnLabels: {
triage: string;
todo: string;
scheduled: string;
ready: string;
running: string;
blocked: string;
done: string;
archived: string;
};
columnHelp: {
triage: string;
todo: string;
scheduled: string;
ready: string;
running: string;
blocked: string;
done: string;
archived: string;
};
confirmDone: string;
confirmArchive: string;
confirmBlocked: string;
confirmScheduled?: string;
completionSummary: string;
completionSummaryRequired: string;
triagePlaceholder: string;
taskTitlePlaceholder: string;
specifier: string;
assigneePlaceholder: string;
priority: string;
skillsPlaceholder: string;
noParent: string;
workspacePathDir: string;
workspacePathOptional: string;
logTruncated: string;
logAt: string;
};
}
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const uk: Translations = {
common: {
save: "Зберегти",
saving: "Збереження...",
cancel: "Скасувати",
close: "Закрити",
confirm: "Підтвердити",
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: "Обмін повідомленнями",
pluginLoadFailed:
"Не вдалося завантажити скрипт цього плагіна. Перевірте вкладку Network (dashboard-plugins/…) та шлях до плагінів на сервері.",
pluginNotRegistered:
"Скрипт плагіна не викликав register(), або у скрипті сталася помилка. Відкрийте консоль браузера, щоб побачити деталі.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Закрити навігацію",
closeModelTools: "Закрити модель та інструменти",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Активні сесії:",
gatewayStatusLabel: "Стан шлюзу:",
gatewayStrip: {
failed: "Помилка запуску",
off: "Вимкнено",
running: "Працює",
starting: "Запускається",
stopped: "Зупинено",
},
nav: {
analytics: "Аналітика",
chat: "Чат",
config: "Конфігурація",
cron: "Cron",
documentation: "Документація",
keys: "Ключі",
logs: "Журнали",
models: "Моделі",
profiles: "профілі: мульти-агенти",
plugins: "Плагіни",
sessions: "Сесії",
skills: "Навички",
},
modelToolsSheetSubtitle: "та інструменти",
modelToolsSheetTitle: "Модель",
navigation: "Навігація",
openDocumentation: "Відкрити документацію в новій вкладці",
openNavigation: "Відкрити навігацію",
pluginNavSection: "Плагіни",
sessionsActiveCount: "{count} активних",
statusOverview: "Огляд стану",
system: "Система",
webUi: "Web UI",
},
status: {
actionFailed: "Дія не вдалася",
actionFinished: "Завершено",
actions: "Дії",
agent: "Агент",
activeSessions: "Активні сесії",
connected: "Підключено",
connectedPlatforms: "Підключені платформи",
disconnected: "Відключено",
error: "Помилка",
failed: "Не вдалося",
gateway: "Шлюз",
gatewayFailedToStart: "Не вдалося запустити шлюз",
lastUpdate: "Останнє оновлення",
noneRunning: "Немає",
notRunning: "Не запущено",
pid: "PID",
platformDisconnected: "відключено",
platformError: "помилка",
recentSessions: "Останні сесії",
restartGateway: "Перезапустити шлюз",
restartingGateway: "Перезапуск шлюзу…",
running: "Працює",
runningRemote: "Працює (віддалено)",
startFailed: "Помилка запуску",
starting: "Запускається",
startedInBackground: "Запущено у фоні — перевірте журнали для прогресу",
stopped: "Зупинено",
updateHermes: "Оновити Hermes",
updatingHermes: "Оновлення Hermes…",
waitingForOutput: "Очікування виводу…",
},
sessions: {
title: "Сесії",
history: "Історія",
overview: "Огляд",
searchPlaceholder: "Пошук у вмісті повідомлень...",
noSessions: "Поки немає сесій",
noMatch: "Жодна сесія не відповідає вашому пошуку",
startConversation: "Почніть розмову, щоб побачити її тут",
noMessages: "Немає повідомлень",
untitledSession: "Сесія без назви",
deleteSession: "Видалити сесію",
confirmDeleteTitle: "Видалити сесію?",
confirmDeleteMessage:
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
resumeInChat: "Продовжити в чаті",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
roles: {
user: "Користувач",
assistant: "Асистент",
system: "Система",
tool: "Інструмент",
},
},
analytics: {
period: "Період:",
totalTokens: "Усього токенів",
totalSessions: "Усього сесій",
apiCalls: "Виклики API",
dailyTokenUsage: "Щоденне використання токенів",
dailyBreakdown: "Щоденна розбивка",
perModelBreakdown: "Розбивка за моделями",
topSkills: "Топ навичок",
skill: "Навичка",
loads: "Агент завантажив",
edits: "Агент керує",
lastUsed: "Останнє використання",
input: "Вхід",
output: "Вихід",
total: "Усього",
noUsageData: "Немає даних про використання за цей період",
startSession: "Почніть сесію, щоб побачити аналітику тут",
date: "Дата",
model: "Модель",
tokens: "Токени",
perDayAvg: "/день у сер.",
acrossModels: "по {count} моделях",
inOut: "{input} вх. / {output} вих.",
},
models: {
modelsUsed: "Використано моделей",
estimatedCost: "Орієнт. вартість",
tokens: "токени",
sessions: "сесії",
avgPerSession: "сер./сесію",
apiCalls: "виклики API",
toolCalls: "виклики інструментів",
noModelsData: "Немає даних про використання моделей за цей період",
startSession: "Почніть сесію, щоб побачити дані моделей тут",
},
logs: {
title: "Журнали",
autoRefresh: "Автооновлення",
file: "Файл",
level: "Рівень",
component: "Компонент",
lines: "Рядки",
noLogLines: "Записів журналу не знайдено",
},
cron: {
confirmDeleteMessage:
"Це видаляє завдання з розкладу. Цю дію не можна скасувати.",
confirmDeleteTitle: "Видалити заплановане завдання?",
newJob: "Нове Cron-завдання",
nameOptional: "Назва (необов'язково)",
namePlaceholder: "напр. Щоденне зведення",
prompt: "Запит",
promptPlaceholder: "Що агент має робити при кожному запуску?",
schedule: "Розклад (cron-вираз)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Надіслати на",
scheduledJobs: "Заплановані завдання",
noJobs: "Cron-завдань не налаштовано. Створіть одне вище.",
last: "Останнє",
next: "Наступне",
pause: "Призупинити",
resume: "Відновити",
triggerNow: "Запустити зараз",
delivery: {
local: "Локально",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "Новий профіль",
name: "Назва",
namePlaceholder: "напр. coder, writer тощо.",
nameRequired: "Назва обов'язкова",
nameRule:
"Лише малі літери, цифри, _ та -; має починатися з літери або цифри; до 64 символів.",
invalidName: "Недопустима назва профілю",
cloneFromDefault: "Клонувати конфігурацію з профілю за замовчуванням",
allProfiles: "Профілі",
noProfiles: "Профілів не знайдено.",
defaultBadge: "за замовчуванням",
hasEnv: "env",
model: "Модель",
skills: "Навички",
rename: "Перейменувати",
editSoul: "Редагувати SOUL.md",
soulSection: "SOUL.md (особистість / системний запит)",
soulPlaceholder: "# Як цей агент має поводитися…",
saveSoul: "Зберегти SOUL",
soulSaved: "SOUL.md збережено",
openInTerminal: "Скопіювати CLI-команду",
commandCopied: "Скопійовано в буфер обміну",
copyFailed: "Не вдалося скопіювати",
confirmDeleteTitle: "Видалити профіль?",
confirmDeleteMessage:
"Це назавжди видаляє профіль '{name}' — конфігурацію, ключі, спогади, сесії, навички, cron-завдання. Не можна скасувати.",
created: "Створено",
deleted: "Видалено",
renamed: "Перейменовано",
},
pluginsPage: {
contextEngineLabel: "Контекстний рушій",
dashboardSlots: "Слоти панелі",
disableRuntime: "Вимкнути",
enableAfterInstall: "Увімкнути після встановлення",
enableRuntime: "Увімкнути",
forceReinstall: "Примусово перевстановити (спершу видалити наявну теку)",
headline:
"Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).",
identifierLabel: "Git URL або owner/repo",
inactive: "неактивний",
installBtn: "Встановити",
installHeading: "Встановити з GitHub / Git URL",
installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.",
memoryProviderLabel: "Постачальник пам'яті",
missingEnvWarn: "Встановіть їх у Keys, перш ніж плагін зможе працювати:",
noDashboardTab: "Немає вкладки панелі",
openTab: "Відкрити",
orphanHeading: "Розширення лише для панелі (без відповідного agent plugin.yaml)",
pluginListHeading: "Встановлені плагіни",
providerDefaults: "вбудований / за замовчуванням",
providersHeading: "Плагіни постачальників часу виконання",
providersHint:
"Записує memory.provider (порожньо = вбудований) та context.engine у config.yaml. Набуває чинності в наступній сесії.",
refreshDashboard: "Перескан розширень панелі",
removeConfirm: "Видалити цей плагін з ~/.hermes/plugins/?",
removeHint: "Видаляти можна лише плагіни, встановлені користувачем у ~/.hermes/plugins.",
rescanHeading: "Реєстр SPA-плагінів",
rescanHint: "Скануйте після додавання файлів на диск, щоб бічна панель підхопила нові маніфести.",
runtimeHeading: "Час виконання шлюзу (YAML-плагіни)",
saveProviders: "Зберегти налаштування постачальників",
savedProviders: "Налаштування постачальників збережено.",
sourceBadge: "Джерело",
authRequired: "Потрібна автентифікація",
authRequiredHint: "Виконайте цю команду, щоб автентифікуватися:",
updateGit: "Git pull",
versionBadge: "Версія",
showInSidebar: "Показати у бічній панелі",
hideFromSidebar: "Сховати з бічної панелі",
},
skills: {
title: "Навички",
searchPlaceholder: "Пошук навичок та наборів інструментів...",
enabledOf: "{enabled}/{total} увімкнено",
all: "Усі",
categories: "Категорії",
filters: "Фільтри",
noSkills: "Навичок не знайдено. Навички завантажуються з ~/.hermes/skills/",
noSkillsMatch: "Жодна навичка не відповідає вашому пошуку чи фільтру.",
skillCount: "{count} навичок",
resultCount: "{count} результатів",
noDescription: "Опис відсутній.",
toolsets: "Набори інструментів",
toolsetLabel: "Набір {name}",
noToolsetsMatch: "Жоден набір інструментів не відповідає пошуку.",
setupNeeded: "Потрібне налаштування",
disabledForCli: "Вимкнено для CLI",
more: "+ще {count}",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Фільтри",
sections: "Розділи",
exportConfig: "Експортувати конфігурацію як JSON",
importConfig: "Імпортувати конфігурацію з JSON",
resetDefaults: "Скинути до значень за замовчуванням",
resetScopeTooltip: "Скинути {scope} до значень за замовчуванням",
confirmResetScope: "Скинути всі налаштування {scope} до значень за замовчуванням? Це лише оновлює форму — зміни не записуються до config.yaml, доки ви не натиснете «Зберегти».",
resetScopeToast: "{scope} скинуто до значень за замовчуванням — перегляньте та збережіть, щоб застосувати",
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: {
changesNote: "Зміни одразу зберігаються на диск. Активні сесії автоматично підхоплюють нові ключі.",
confirmClearMessage:
"Збережене значення цієї змінної буде видалено з вашого .env-файлу. Цю дію не можна скасувати з UI.",
confirmClearTitle: "Очистити цей ключ?",
description: "Керуйте API-ключами та секретами, що зберігаються в",
hideAdvanced: "Сховати розширене",
showAdvanced: "Показати розширене",
showLess: "Показати менше",
showMore: "Показати більше",
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: "Копіювати",
copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
connect: "Підключити",
sessionExpires: "Сесія завершиться через {time}",
initiatingLogin: "Запуск процесу входу…",
exchangingCode: "Обмін коду на токени…",
connectedClosing: "Підключено! Закриття…",
loginFailed: "Помилка входу.",
sessionExpired: "Сесія прострочена. Натисніть «Повторити», щоб розпочати новий вхід.",
reOpenAuth: "Знову відкрити сторінку авторизації",
reOpenVerification: "Знову відкрити сторінку перевірки",
submitCode: "Надіслати код",
pasteCode: "Вставте код авторизації (з суфіксом #state теж нормально)",
waitingAuth: "Очікування на вашу авторизацію в браузері…",
enterCodePrompt: "Відкрилася нова вкладка. Якщо буде запит, введіть цей код:",
pkceStep1: "Відкрилася нова вкладка з claude.ai. Увійдіть та натисніть Authorize.",
pkceStep2: "Скопіюйте код авторизації, що відображається після авторизації.",
pkceStep3: "Вставте його нижче та надішліть.",
flowLabels: {
pkce: "Вхід через браузер (PKCE)",
device_code: "Код пристрою",
external: "Зовнішній CLI",
},
expiresIn: "завершується через {time}",
},
language: {
switchTo: "Змінити мову",
},
theme: {
title: "Тема",
switchTheme: "Змінити тему",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"Колекційні значки Hermes, отримані з реальної історії сеансів. Відомі, але ще не виконані досягнення показані як Виявлені; Секретні досягнення залишаються прихованими, доки не з'явиться перший відповідний сигнал.",
scan_subtitle:
"Сканування історії сеансів Hermes. Перше сканування на великих історіях може тривати 5–10 секунд.",
},
actions: {
rescan: "Повторне сканування",
},
stats: {
unlocked: "Розблоковано",
unlocked_hint: "отримані значки",
discovered: "Виявлено",
discovered_hint: "відомі, ще не отримані",
secrets: "Секрети",
secrets_hint: "приховані до першого сигналу",
highest_tier: "Найвищий рівень",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "Останнє",
latest_hint_empty: "запускайте Hermes частіше",
none_yet: "Поки немає",
},
state: {
unlocked: "Розблоковано",
discovered: "Виявлено",
secret: "Секрет",
},
tier: {
target: "Ціль {tier}",
hidden: "Приховано",
complete: "Завершено",
objective: "Завдання",
},
progress: {
hidden: "приховано",
},
scan: {
building_headline: "Побудова профілю досягнень…",
building_detail:
"Читання сеансів, викликів інструментів, метаданих моделей і стану розблокування.",
starting_headline: "Запуск сканування досягнень…",
progress_detail:
"Проскановано {scanned} з {total} сеансів · {pct}%. Значки розблоковуються в міру надходження історії.",
idle_detail:
"Читання сеансів, викликів інструментів, метаданих моделей і стану розблокування. Значки з'являються тут у міру розблокування.",
},
guide: {
tiers_header: "Рівні",
secret_header: "Секретні досягнення",
secret_body:
"Секрети приховують свій точний тригер. Щойно Hermes побачить пов'язаний сигнал, картка стає Виявленою та показує свою умову.",
scan_status_header: "Стан сканування",
scan_status_body:
"Hermes одноразово сканує локальну історію, а потім картки з'являться автоматично. Якщо це триває кілька секунд — нічого не зависло.",
what_scanned_header: "Що сканується",
what_scanned_body:
"Сеанси, виклики інструментів, метадані моделей, помилки, досягнення та локальний стан розблокування.",
},
card: {
share_title: "Поділитися цим досягненням",
share_label: "Поділитися {name}",
share_text: "Поділитися",
how_to_reveal: "Як розкрити",
what_counts: "Що зараховується",
evidence_label: "Доказ",
evidence_session_fallback: "сеанс",
no_evidence: "Доказів поки немає",
},
latest: {
header: "Нещодавні розблокування",
},
empty: {
no_secrets_header: "У цьому скануванні не залишилося прихованих секретів.",
no_secrets_body:
"Підказка: секрети зазвичай починаються з незвичних збоїв або шаблонів досвідчених користувачів — конфлікти портів, стіни дозволів, відсутні змінні середовища, помилки YAML, колізії Docker, відкат/контрольні точки, влучання в кеш або дрібні виправлення після купи червоного тексту.",
},
filters: {
all_categories: "Усі",
visibility_all: "усі",
visibility_unlocked: "розблоковано",
visibility_discovered: "виявлено",
visibility_secret: "секрет",
},
share: {
dialog_label: "Поділитися досягненням",
header: "Поділитися: {name}",
close: "Закрити",
rendering: "Рендеринг…",
card_alt: "Картка для поширення {name}",
error_generic: "Щось пішло не так.",
x_title: "Відкриває X із попередньо заповненим дописом",
x_button: "Поділитися в X",
copy_title: "Скопіюйте зображення, щоб вставити у свій допис",
copy_button: "Копіювати зображення",
copied: "Скопійовано ✓",
download_button: "Завантажити PNG",
hint:
"«Поділитися в X» відкриває попередньо заповнений допис у новій вкладці. Якщо хочете прикріпити значок 1200×630 — спочатку натисніть «Копіювати зображення»: X дозволить вставити його прямо в редактор твіта. «Завантажити PNG» збереже файл для використання будь-де.",
clipboard_unsupported:
"Цей браузер не підтримує копіювання зображень у буфер обміну — використайте «Завантажити».",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "Завантаження дошки Kanban…",
loadFailed: "Не вдалося завантажити дошку Kanban: ",
loadFailedHint:
"Бекенд автоматично створює kanban.db під час першого читання. Якщо помилка не зникає, перевірте журнали панелі.",
board: "Дошка",
newBoard: "+ Нова дошка",
newBoardTitle: "Нова дошка",
newBoardDescription:
"Дошки дозволяють розділяти непов'язані потоки роботи — по одній на проєкт, репозиторій або домен. Воркери на одній дошці ніколи не бачать задач іншої дошки.",
slug: "Slug",
slugHint: "— рядкові літери, дефіси, напр. atm10-server",
displayName: "Відображувана назва",
displayNameHint: "(необов'язково)",
description: "Опис",
descriptionHint: "(необов'язково)",
icon: "Іконка",
iconHint: "(один символ або емодзі)",
switchAfterCreate: "Перейти на цю дошку після створення",
cancel: "Скасувати",
creating: "Створення…",
createBoard: "Створити дошку",
search: "Пошук",
filterCards: "Фільтрувати картки…",
tenant: "Орендар",
allTenants: "Усі орендарі",
assignee: "Виконавець",
allProfiles: "Усі профілі",
showArchived: "Показати архівовані",
lanesByProfile: "Доріжки за профілем",
nudgeDispatcher: "Підштовхнути диспетчер",
refresh: "Оновити",
selected: "вибрано",
complete: "Завершити",
archive: "Архівувати",
apply: "Застосувати",
clear: "Очистити",
createTask: "Створити задачу в цьому стовпці",
noTasks: "— немає задач —",
unassigned: "не призначено",
untitled: "(без назви)",
loadingDetail: "Завантаження…",
addComment: "Додати коментар… (Enter для надсилання)",
comment: "Коментар",
status: "Статус",
workspace: "Робоча область",
skills: "Навички",
createdBy: "Створив",
result: "Результат",
comments: "Коментарі",
events: "Події",
runHistory: "Історія запусків",
workerLog: "Журнал воркера",
loadingLog: "Завантаження журналу…",
noWorkerLog:
"— журналу воркера ще немає (задачу не запущено або журнал ротаційно видалено) —",
noDescription: "— немає опису —",
noComments: "— немає коментарів —",
edit: "редагувати",
save: "Зберегти",
dependencies: "Залежності",
parents: "Батьки:",
children: "Нащадки:",
none: "немає",
addParent: "— додати батька —",
addChild: "— додати нащадка —",
removeDependency: "Видалити залежність",
block: "Заблокувати",
unblock: "Розблокувати",
notifyHomeChannels: "Повідомити домашні канали",
diagnostics: "Діагностика",
hide: "Приховати",
show: "Показати",
attention: "Увага",
tasksNeedAttention: "задач потребують уваги",
taskNeedsAttention: "1 задача потребує уваги",
diagnostic: "діагностика",
open: "Відкрити",
close: "Закрити (Esc)",
reassignTo: "Перепризначити на:",
copied: "Скопійовано",
copyCommand: "Скопіювати команду в буфер обміну",
reclaim: "Повернути",
reassign: "Перепризначити",
renderingError: "На вкладці Kanban сталася помилка рендерингу",
reloadView: "Перезавантажити вигляд",
wsAuthFailed:
"Помилка автентифікації WebSocket — перезавантажте сторінку, щоб оновити токен сесії.",
markDone: "Позначити {n} задач(у) як виконані?",
markArchived: "Архівувати {n} задач(у)?",
warning: "Попередження",
phantomIds: "Фантомні id:",
active: "активна",
ended: "завершена",
noProfile: "(немає профілю)",
showAllAttempts: "Показати всі спроби",
sendingUpdates: "Надсилання оновлень до",
sendNotifications: "Надсилати сповіщення completed / blocked / gave_up до",
archiveBoardConfirm:
"Архівувати дошку «{name}»? Її буде переміщено до boards/_archived/, тож пізніше її можна відновити. Задачі цієї дошки більше не з'являтимуться в інтерфейсі.",
archiveBoardTitle: "Архівувати цю дошку",
boardSwitcherHint: "Дошки дозволяють розділяти непов'язані потоки роботи",
taskCreatedWarning: "Задачу створено, але: ",
moveFailed: "Переміщення не вдалося: ",
bulkFailed: "Масова дія: ",
completionBlockedHallucination: "⚠ Завершення заблоковано — фантомні id карток",
suspectedHallucinatedReferences: "⚠ Текст посилався на фантомні id карток",
pickProfileFirst: "Спочатку виберіть профіль.",
unblockedMessage: "{id} розблоковано. Задача готова до наступного тіку.",
unblockFailed: "Розблокування не вдалося: ",
reclaimedMessage: "{id} повернуто. Задача знову готова.",
reclaimFailed: "Повернення не вдалося: ",
reassignedMessage: "{id} перепризначено на {profile}.",
reassignFailed: "Перепризначення не вдалося: ",
selectForBulk: "Вибрати для масових дій",
clickToEdit: "Клікніть, щоб редагувати",
clickToEditAssignee: "Клікніть, щоб редагувати виконавця",
emptyAssignee: "(порожньо = зняти призначення)",
columnLabels: {
triage: "Сортування",
todo: "До виконання",
scheduled: "Заплановано",
ready: "Готово",
running: "У роботі",
blocked: "Заблоковано",
done: "Виконано",
archived: "Архів",
},
columnHelp: {
triage: "Сирі ідеї — специфікатор деталізує специфікацію",
todo: "Очікує на залежності або не призначено",
scheduled: "Очікує на відому затримку в часі або заплановане продовження",
ready: "Залежності задоволені; призначте профіль для диспетчеризації",
running: "Захоплено воркером — у роботі",
blocked: "Воркер запитав втручання людини",
done: "Завершено",
archived: "Архівовано",
},
confirmDone:
"Позначити цю задачу як виконану? Захоплення воркера буде звільнено, а залежні нащадки стануть готовими.",
confirmArchive:
"Архівувати цю задачу? Вона зникне з типового вигляду дошки.",
confirmBlocked:
"Позначити цю задачу як заблоковану? Захоплення воркера буде звільнено.",
completionSummary:
"Підсумок завершення для {label}. Зберігається як result задачі.",
completionSummaryRequired:
"Підсумок завершення обов'язковий перед позначенням задачі виконаною.",
triagePlaceholder: "Чорнова ідея — ШІ її специфікує…",
taskTitlePlaceholder: "Назва нової задачі…",
specifier: "специфікатор",
assigneePlaceholder: "виконавець",
priority: "Пріоритет",
skillsPlaceholder:
"навички (необов'язково, через кому): translation, github-code-review",
noParent: "— без батька —",
workspacePathDir: "шлях робочої області (обов'язково, напр. ~/projects/my-app)",
workspacePathOptional:
"шлях робочої області (необов'язково, виводиться з виконавця, якщо порожньо)",
logTruncated: "(показано останні 100 KB — повний журнал у ",
logAt: ")",
},
};
+702
View File
@@ -0,0 +1,702 @@
import type { Translations } from "./types";
export const zhHant: Translations = {
common: {
save: "儲存",
saving: "儲存中...",
cancel: "取消",
close: "關閉",
confirm: "確認",
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: "訊息平台",
pluginLoadFailed:
"無法載入此外掛的指令碼。請檢查網路請求(dashboard-plugins/…)以及伺服器上的外掛路徑。",
pluginNotRegistered:
"外掛指令碼未呼叫 register(),或執行時發生錯誤。請開啟瀏覽器主控台查看詳細資訊。",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "關閉導覽",
closeModelTools: "關閉模型與工具",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "使用中工作階段:",
gatewayStatusLabel: "閘道狀態:",
gatewayStrip: {
failed: "啟動失敗",
off: "關閉",
running: "執行中",
starting: "啟動中",
stopped: "已停止",
},
nav: {
analytics: "分析",
chat: "對話",
config: "設定",
cron: "排程任務",
documentation: "文件",
keys: "金鑰",
logs: "日誌",
models: "模型",
profiles: "多代理設定檔",
plugins: "外掛管理",
sessions: "工作階段",
skills: "技能",
},
modelToolsSheetSubtitle: "與工具",
modelToolsSheetTitle: "模型",
navigation: "導覽",
openDocumentation: "在新分頁開啟文件",
openNavigation: "開啟導覽",
pluginNavSection: "外掛",
sessionsActiveCount: "{count} 個使用中",
statusOverview: "狀態總覽",
system: "系統",
webUi: "管理面板",
},
status: {
actionFailed: "動作失敗",
actionFinished: "已完成",
actions: "動作",
agent: "代理",
activeSessions: "使用中工作階段",
connected: "已連線",
connectedPlatforms: "已連線平台",
disconnected: "已中斷連線",
error: "錯誤",
failed: "失敗",
gateway: "閘道",
gatewayFailedToStart: "閘道啟動失敗",
lastUpdate: "最後更新",
noneRunning: "無",
notRunning: "未執行",
pid: "PID",
platformDisconnected: "已中斷",
platformError: "錯誤",
recentSessions: "近期工作階段",
restartGateway: "重新啟動閘道",
restartingGateway: "正在重新啟動閘道…",
running: "執行中",
runningRemote: "執行中(遠端)",
startFailed: "啟動失敗",
starting: "啟動中",
startedInBackground: "已於背景啟動 — 請查看日誌以取得進度",
stopped: "已停止",
updateHermes: "更新 Hermes",
updatingHermes: "正在更新 Hermes…",
waitingForOutput: "等待輸出…",
},
sessions: {
title: "工作階段",
history: "歷史",
overview: "總覽",
searchPlaceholder: "搜尋訊息內容...",
noSessions: "尚無工作階段",
noMatch: "沒有符合的工作階段",
startConversation: "開始對話後將顯示於此",
noMessages: "尚無訊息",
untitledSession: "未命名工作階段",
deleteSession: "刪除工作階段",
confirmDeleteTitle: "刪除工作階段?",
confirmDeleteMessage:
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
resumeInChat: "在對話中繼續",
previousPage: "上一頁",
nextPage: "下一頁",
roles: {
user: "使用者",
assistant: "助理",
system: "系統",
tool: "工具",
},
},
analytics: {
period: "時間範圍:",
totalTokens: "Token 總數",
totalSessions: "工作階段總數",
apiCalls: "API 呼叫",
dailyTokenUsage: "每日 Token 用量",
dailyBreakdown: "每日明細",
perModelBreakdown: "各模型用量明細",
topSkills: "常用技能",
skill: "技能",
loads: "代理載入",
edits: "代理管理",
lastUsed: "最近使用",
input: "輸入",
output: "輸出",
total: "總計",
noUsageData: "此時間範圍內無使用資料",
startSession: "開始工作階段後將於此處顯示分析資料",
date: "日期",
model: "模型",
tokens: "Token",
perDayAvg: "/日 平均",
acrossModels: "共 {count} 個模型",
inOut: "輸入 {input} / 輸出 {output}",
},
models: {
modelsUsed: "使用模型數",
estimatedCost: "預估費用",
tokens: "Token",
sessions: "工作階段",
avgPerSession: "平均/工作階段",
apiCalls: "API 呼叫",
toolCalls: "工具呼叫",
noModelsData: "此時間範圍內無模型使用資料",
startSession: "開始工作階段後將於此處顯示模型資料",
},
logs: {
title: "日誌",
autoRefresh: "自動重新整理",
file: "檔案",
level: "層級",
component: "元件",
lines: "行數",
noLogLines: "找不到日誌記錄",
},
cron: {
confirmDeleteMessage:
"將從排程移除此任務,此操作無法復原。",
confirmDeleteTitle: "刪除排程任務?",
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: "Email",
},
},
profiles: {
newProfile: "新增設定檔",
name: "名稱",
namePlaceholder: "例如:coder、writer 等",
nameRequired: "名稱為必填",
nameRule:
"僅允許小寫字母、數字、底線及連字號;首字必須為字母或數字;最多 64 個字元。",
invalidName: "設定檔名稱無效",
cloneFromDefault: "從預設設定檔複製設定",
allProfiles: "設定檔",
noProfiles: "找不到設定檔。",
defaultBadge: "預設",
hasEnv: "env",
model: "模型",
skills: "技能",
rename: "重新命名",
editSoul: "編輯 SOUL.md",
soulSection: "SOUL.md(人格 / 系統提示詞)",
soulPlaceholder: "# 此代理應如何運作…",
saveSoul: "儲存 SOUL",
soulSaved: "SOUL.md 已儲存",
openInTerminal: "複製 CLI 指令",
commandCopied: "已複製到剪貼簿",
copyFailed: "複製失敗",
confirmDeleteTitle: "刪除設定檔?",
confirmDeleteMessage:
"將永久刪除設定檔「{name}」 — 包括設定、金鑰、記憶、工作階段、技能、排程任務。無法復原。",
created: "已建立",
deleted: "已刪除",
renamed: "已重新命名",
},
pluginsPage: {
contextEngineLabel: "上下文引擎",
dashboardSlots: "面板插槽",
disableRuntime: "停用",
enableAfterInstall: "安裝後啟用",
enableRuntime: "啟用",
forceReinstall: "強制重新安裝(先刪除既有資料夾)",
headline:
"探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI)。",
identifierLabel: "Git 網址或 owner/repo",
inactive: "未啟用",
installBtn: "安裝",
installHeading: "從 GitHub / Git URL 安裝",
installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。",
memoryProviderLabel: "記憶提供者",
missingEnvWarn: "請先在「金鑰」頁面設定下列項目,外掛才能執行:",
noDashboardTab: "無儀表板分頁",
openTab: "開啟",
orphanHeading: "僅儀表板擴充功能(無對應的 agent plugin.yaml",
pluginListHeading: "已安裝的外掛",
providerDefaults: "內建 / 預設",
providersHeading: "執行階段提供者外掛",
providersHint:
"會寫入 config.yamlmemory.provider(留空為內建)與 context.engine。下一個工作階段生效。",
refreshDashboard: "重新掃描儀表板擴充功能",
removeConfirm: "從 ~/.hermes/plugins/ 移除此外掛?",
removeHint: "僅可移除位於 ~/.hermes/plugins 下使用者安裝的外掛。",
rescanHeading: "SPA 外掛註冊表",
rescanHint: "在磁碟新增檔案後重新掃描,使儀表板側邊欄載入新的 manifest。",
runtimeHeading: "閘道執行階段(YAML 外掛)",
saveProviders: "儲存提供者設定",
savedProviders: "提供者設定已儲存。",
sourceBadge: "來源",
authRequired: "需要驗證",
authRequiredHint: "執行此指令以完成驗證:",
updateGit: "Git pull",
versionBadge: "版本",
showInSidebar: "顯示於側邊欄",
hideFromSidebar: "從側邊欄隱藏",
},
skills: {
title: "技能",
searchPlaceholder: "搜尋技能與工具集...",
enabledOf: "已啟用 {enabled}/{total}",
all: "全部",
categories: "分類",
filters: "篩選",
noSkills: "找不到技能。技能由 ~/.hermes/skills/ 載入",
noSkillsMatch: "沒有符合搜尋或篩選條件的技能。",
skillCount: "{count} 個技能",
resultCount: "{count} 個結果",
noDescription: "無可用描述。",
toolsets: "工具集",
toolsetLabel: "{name} 工具集",
noToolsetsMatch: "沒有符合搜尋條件的工具集。",
setupNeeded: "需要設定",
disabledForCli: "CLI 已停用",
more: "還有 {count} 個",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "篩選",
sections: "分類",
exportConfig: "匯出設定為 JSON",
importConfig: "從 JSON 匯入設定",
resetDefaults: "重設為預設值",
resetScopeTooltip: "將{scope}重設為預設值",
confirmResetScope: "要將{scope}的所有設定重設為預設值嗎?此操作只更新表單,在按下「儲存」前不會寫入 config.yaml。",
resetScopeToast: "{scope}已重設為預設值 — 請檢視並儲存以套用",
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: {
changesNote: "變更會立即儲存到磁碟。使用中的工作階段將自動取得新金鑰。",
confirmClearMessage:
"此變數已儲存的值將從 .env 檔案中移除。無法從介面復原。",
confirmClearTitle: "清除此金鑰?",
description: "管理儲存於下列位置的 API 金鑰與密鑰",
hideAdvanced: "隱藏進階選項",
showAdvanced: "顯示進階選項",
showLess: "顯示較少",
showMore: "顯示更多",
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: "複製",
copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
connect: "連線",
sessionExpires: "工作階段將於 {time} 後過期",
initiatingLogin: "正在啟動登入流程…",
exchangingCode: "正在交換權杖…",
connectedClosing: "已連線!正在關閉…",
loginFailed: "登入失敗。",
sessionExpired: "工作階段已過期。請點擊「重試」開始新的登入。",
reOpenAuth: "重新開啟授權頁面",
reOpenVerification: "重新開啟驗證頁面",
submitCode: "提交代碼",
pasteCode: "貼上授權代碼(包含 #state 後綴亦可)",
waitingAuth: "等待您於瀏覽器中完成授權…",
enterCodePrompt: "已開啟新分頁。如有提示,請輸入此代碼:",
pkceStep1: "已於新分頁開啟 claude.ai。請登入並點擊「Authorize」。",
pkceStep2: "複製授權後顯示的授權代碼。",
pkceStep3: "將其貼到下方並提交。",
flowLabels: {
pkce: "瀏覽器登入(PKCE",
device_code: "裝置代碼",
external: "外部 CLI",
},
expiresIn: "{time}後過期",
},
language: {
switchTo: "切換語言",
},
theme: {
title: "主題",
switchTheme: "切換主題",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"從真實工作階段歷史中獲得的 Hermes 可收集徽章。已知尚未達成的成就會顯示為「已發現」;秘密成就在首次出現相符行為之前保持隱藏。",
scan_subtitle:
"正在掃描 Hermes 工作階段歷史。在歷史紀錄較多時,首次掃描可能需要 5–10 秒。",
},
actions: {
rescan: "重新掃描",
},
stats: {
unlocked: "已解鎖",
unlocked_hint: "獲得的徽章",
discovered: "已發現",
discovered_hint: "已知,但尚未獲得",
secrets: "秘密",
secrets_hint: "在首次訊號出現前保持隱藏",
highest_tier: "最高等級",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "最新",
latest_hint_empty: "多多執行 Hermes",
none_yet: "尚無",
},
state: {
unlocked: "已解鎖",
discovered: "已發現",
secret: "秘密",
},
tier: {
target: "目標 {tier}",
hidden: "隱藏",
complete: "已完成",
objective: "目標",
},
progress: {
hidden: "隱藏",
},
scan: {
building_headline: "正在建立成就檔案…",
building_detail:
"正在讀取工作階段、工具呼叫、模型中繼資料以及解鎖狀態。",
starting_headline: "正在開始成就掃描…",
progress_detail:
"已掃描 {scanned} / {total} 個工作階段 · {pct}%。隨著更多歷史串入,徽章會陸續解鎖。",
idle_detail:
"正在讀取工作階段、工具呼叫、模型中繼資料以及解鎖狀態。徽章解鎖後會顯示在這裡。",
},
guide: {
tiers_header: "等級",
secret_header: "秘密成就",
secret_body:
"秘密成就會隱藏其確切觸發條件。一旦 Hermes 偵測到相關訊號,卡片便會變為「已發現」並顯示其需求。",
scan_status_header: "掃描狀態",
scan_status_body:
"Hermes 正在對本機歷史進行一次掃描,之後卡片會自動出現。即使需要幾秒鐘,也並未卡住。",
what_scanned_header: "掃描內容",
what_scanned_body:
"工作階段、工具呼叫、模型中繼資料、錯誤、成就以及本機解鎖狀態。",
},
card: {
share_title: "分享此成就",
share_label: "分享 {name}",
share_text: "分享",
how_to_reveal: "如何揭示",
what_counts: "計入條件",
evidence_label: "證據",
evidence_session_fallback: "工作階段",
no_evidence: "尚無證據",
},
latest: {
header: "最近解鎖",
},
empty: {
no_secrets_header: "本次掃描已沒有隱藏的秘密。",
no_secrets_body:
"提示:秘密通常源自異常失敗或進階使用者的行為模式 —— 連接埠衝突、權限阻擋、缺少環境變數、YAML 錯誤、Docker 衝突、回復或檢查點的使用、快取命中,或在大量紅色錯誤後做出的小小修正。",
},
filters: {
all_categories: "全部",
visibility_all: "全部",
visibility_unlocked: "已解鎖",
visibility_discovered: "已發現",
visibility_secret: "秘密",
},
share: {
dialog_label: "分享成就",
header: "分享:{name}",
close: "關閉",
rendering: "繪製中…",
card_alt: "{name} 分享卡片",
error_generic: "發生錯誤。",
x_title: "在 X 中開啟預先填寫的貼文",
x_button: "在 X 上分享",
copy_title: "複製圖片以貼上到你的貼文",
copy_button: "複製圖片",
copied: "已複製 ✓",
download_button: "下載 PNG",
hint:
"「在 X 上分享」會在新分頁中開啟預先填寫的貼文。若想附上 1200×630 的徽章,請先點擊「複製圖片」—— X 允許你直接貼到推文編輯器中。「下載 PNG」會將檔案儲存下來,可在任何地方使用。",
clipboard_unsupported:
"此瀏覽器不支援剪貼簿圖片複製 —— 請改用「下載」。",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "正在載入看板…",
loadFailed: "載入看板失敗:",
loadFailedHint:
"後端會在首次讀取時自動建立 kanban.db。如果問題持續,請檢查儀表板日誌。",
board: "看板",
newBoard: "+ 新增看板",
newBoardTitle: "新增看板",
newBoardDescription:
"看板可將不相關的工作流分開——每個專案、程式碼庫或網域一個看板。一個看板上的工作者不會看到另一個看板的任務。",
slug: "識別碼",
slugHint: "— 小寫字母、連字號,例如 atm10-server",
displayName: "顯示名稱",
displayNameHint: "(選填)",
description: "描述",
descriptionHint: "(選填)",
icon: "圖示",
iconHint: "(單一字元或表情符號)",
switchAfterCreate: "建立後切換到此看板",
cancel: "取消",
creating: "建立中…",
createBoard: "建立看板",
search: "搜尋",
filterCards: "篩選卡片…",
tenant: "租戶",
allTenants: "全部租戶",
assignee: "負責人",
allProfiles: "全部設定檔",
showArchived: "顯示已封存",
lanesByProfile: "依設定檔分組",
nudgeDispatcher: "觸發排程器",
refresh: "重新整理",
selected: "已選取",
complete: "完成",
archive: "封存",
apply: "套用",
clear: "清除",
createTask: "在此欄建立任務",
noTasks: "— 沒有任務 —",
unassigned: "未指派",
untitled: "(無標題)",
loadingDetail: "載入中…",
addComment: "新增留言…(按 Enter 送出)",
comment: "留言",
status: "狀態",
workspace: "工作區",
skills: "技能",
createdBy: "建立者",
result: "結果",
comments: "留言",
events: "事件",
runHistory: "執行紀錄",
workerLog: "工作者日誌",
loadingLog: "正在載入日誌…",
noWorkerLog:
"— 尚無工作者日誌(任務尚未啟動或日誌已被輪替)—",
noDescription: "— 沒有描述 —",
noComments: "— 沒有留言 —",
edit: "編輯",
save: "儲存",
dependencies: "相依項目",
parents: "上層任務:",
children: "下層任務:",
none: "無",
addParent: "— 新增上層任務 —",
addChild: "— 新增下層任務 —",
removeDependency: "移除相依項目",
block: "封鎖",
unblock: "解除封鎖",
notifyHomeChannels: "通知主要頻道",
diagnostics: "診斷",
hide: "隱藏",
show: "顯示",
attention: "注意",
tasksNeedAttention: "個任務需要關注",
taskNeedsAttention: "1 個任務需要關注",
diagnostic: "診斷",
open: "開啟",
close: "關閉 (Esc)",
reassignTo: "重新指派給:",
copied: "已複製",
copyCommand: "複製指令到剪貼簿",
reclaim: "收回",
reassign: "重新指派",
renderingError: "看板分頁發生繪製錯誤",
reloadView: "重新載入檢視",
wsAuthFailed:
"WebSocket 驗證失敗 — 請重新載入頁面以更新工作階段權杖。",
markDone: "將 {n} 個任務標記為完成?",
markArchived: "封存 {n} 個任務?",
warning: "警告",
phantomIds: "幽靈 ID",
active: "進行中",
ended: "已結束",
noProfile: "(無設定檔)",
showAllAttempts: "顯示所有嘗試",
sendingUpdates: "正在傳送更新到",
sendNotifications: "傳送完成 / 封鎖 / 放棄通知到",
archiveBoardConfirm:
"封存看板「{name}」?看板將會移至 boards/_archived/,以便日後復原。此看板上的任務將不再出現在 UI 中的任何位置。",
archiveBoardTitle: "封存此看板",
boardSwitcherHint: "看板可將不相關的工作流分開",
taskCreatedWarning: "任務已建立,但:",
moveFailed: "移動失敗:",
bulkFailed: "批次操作:",
completionBlockedHallucination: "⚠ 完成被封鎖 — 幽靈卡片 ID",
suspectedHallucinatedReferences: "⚠ 文字內容引用了幽靈卡片 ID",
pickProfileFirst: "請先選擇一個設定檔。",
unblockedMessage: "已解除封鎖 {id}。任務已準備好進入下一輪排程。",
unblockFailed: "解除封鎖失敗:",
reclaimedMessage: "已收回 {id}。任務已回到就緒狀態。",
reclaimFailed: "收回失敗:",
reassignedMessage: "已將 {id} 重新指派給 {profile}。",
reassignFailed: "重新指派失敗:",
selectForBulk: "選取以進行批次操作",
clickToEdit: "點擊以編輯",
clickToEditAssignee: "點擊以編輯負責人",
emptyAssignee: "(留空 = 取消指派)",
columnLabels: {
triage: "待分類",
todo: "待辦",
scheduled: "已排程",
ready: "就緒",
running: "進行中",
blocked: "已封鎖",
done: "已完成",
archived: "已封存",
},
columnHelp: {
triage: "原始想法 — 規格制定者將完善規格",
todo: "等待相依項目或尚未指派",
scheduled: "等待已知的時間延遲或已排程的後續處理",
ready: "相依項目已滿足;指派設定檔以便排程",
running: "已被工作者領取 — 執行中",
blocked: "工作者請求人工輸入",
done: "已完成",
archived: "已封存",
},
confirmDone:
"將此任務標記為完成?工作者的領取將被釋放,下層相依任務將變為就緒。",
confirmArchive:
"封存此任務?它將從預設看板檢視中消失。",
confirmBlocked:
"將此任務標記為已封鎖?工作者的領取將被釋放。",
completionSummary:
"{label} 的完成摘要。這將作為任務結果儲存。",
completionSummaryRequired:
"在將任務標記為完成之前,必須提供完成摘要。",
triagePlaceholder: "粗略的想法 — AI 將完善規格…",
taskTitlePlaceholder: "新任務標題…",
specifier: "規格制定者",
assigneePlaceholder: "負責人",
priority: "優先順序",
skillsPlaceholder:
"技能(選填,以逗號分隔):translation、github-code-review",
noParent: "— 無上層任務 —",
workspacePathDir: "工作區路徑(必填,例如 ~/projects/my-app",
workspacePathOptional:
"工作區路徑(選填,留空則依負責人推導)",
logTruncated: "(顯示最後 100 KB — 完整日誌位於 ",
logAt: "",
},
};
+698
View File
@@ -0,0 +1,698 @@
import type { Translations } from "./types";
export const zh: Translations = {
common: {
save: "保存",
saving: "保存中...",
cancel: "取消",
close: "关闭",
confirm: "确认",
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: "消息平台",
pluginLoadFailed:
"无法加载此插件的脚本。请检查网络请求(dashboard-plugins/…)以及服务器上的插件路径。",
pluginNotRegistered: "插件脚本未调用 register(),或执行出错。请打开浏览器控制台查看详情。",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "关闭导航",
closeModelTools: "关闭模型与工具",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "活跃会话:",
gatewayStatusLabel: "网关状态:",
gatewayStrip: {
failed: "启动失败",
off: "关闭",
running: "运行中",
starting: "启动中",
stopped: "已停止",
},
nav: {
analytics: "分析",
chat: "对话",
config: "配置",
cron: "定时任务",
documentation: "文档",
keys: "密钥",
logs: "日志",
models: "模型",
profiles: "多Agent配置",
plugins: "插件管理",
sessions: "会话",
skills: "技能",
},
modelToolsSheetSubtitle: "与工具",
modelToolsSheetTitle: "模型",
navigation: "导航",
openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航",
pluginNavSection: "插件",
sessionsActiveCount: "{count} 个活跃",
statusOverview: "状态概览",
system: "系统",
webUi: "管理面板",
},
status: {
actionFailed: "操作失败",
actionFinished: "已完成",
actions: "操作",
agent: "代理",
activeSessions: "活跃会话",
connected: "已连接",
connectedPlatforms: "已连接平台",
disconnected: "已断开",
error: "错误",
failed: "失败",
gateway: "网关",
gatewayFailedToStart: "网关启动失败",
lastUpdate: "最后更新",
noneRunning: "无",
notRunning: "未运行",
pid: "进程",
platformDisconnected: "已断开",
platformError: "错误",
recentSessions: "最近会话",
restartGateway: "重启网关",
restartingGateway: "正在重启网关…",
running: "运行中",
runningRemote: "运行中(远程)",
startFailed: "启动失败",
starting: "启动中",
startedInBackground: "已在后台启动 — 请查看日志",
stopped: "已停止",
updateHermes: "更新 Hermes",
updatingHermes: "正在更新 Hermes…",
waitingForOutput: "等待输出…",
},
sessions: {
title: "会话",
history: "历史",
overview: "概览",
searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话",
noMatch: "没有匹配的会话",
startConversation: "开始对话后将显示在此处",
noMessages: "暂无消息",
untitledSession: "无标题会话",
deleteSession: "删除会话",
confirmDeleteTitle: "删除会话?",
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
roles: {
user: "用户",
assistant: "助手",
system: "系统",
tool: "工具",
},
},
analytics: {
period: "时间范围:",
totalTokens: "总 Token 数",
totalSessions: "总会话数",
apiCalls: "API 调用",
dailyTokenUsage: "每日 Token 用量",
dailyBreakdown: "每日明细",
perModelBreakdown: "模型用量明细",
topSkills: "常用技能",
skill: "技能",
loads: "代理加载",
edits: "代理管理",
lastUsed: "最近使用",
input: "输入",
output: "输出",
total: "总计",
noUsageData: "该时间段暂无使用数据",
startSession: "开始会话后将在此显示分析数据",
date: "日期",
model: "模型",
tokens: "Token",
perDayAvg: "/天 平均",
acrossModels: "共 {count} 个模型",
inOut: "输入 {input} / 输出 {output}",
},
models: {
modelsUsed: "使用模型数",
estimatedCost: "预估费用",
tokens: "Token",
sessions: "会话",
avgPerSession: "平均/会话",
apiCalls: "API 调用",
toolCalls: "工具调用",
noModelsData: "该时间段暂无模型使用数据",
startSession: "开始会话后将在此显示模型数据",
},
logs: {
title: "日志",
autoRefresh: "自动刷新",
file: "文件",
level: "级别",
component: "组件",
lines: "行数",
noLogLines: "未找到日志记录",
},
cron: {
confirmDeleteMessage: "将从此计划移除该任务,此操作无法撤销。",
confirmDeleteTitle: "删除定时任务?",
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: "邮件",
},
},
profiles: {
newProfile: "新建多Agent配置",
name: "名称",
namePlaceholder: "例如:coder, writer 等",
nameRequired: "名称必填",
nameRule:
"仅允许小写字母、数字、下划线和短横线;首字符必须是字母或数字;最多 64 个字符。",
invalidName: "多Agent配置名称非法",
cloneFromDefault: "从默认多Agent配置克隆配置",
allProfiles: "多Agent配置列表",
noProfiles: "暂无多Agent配置。",
defaultBadge: "默认",
hasEnv: "已配置 env",
model: "模型",
skills: "技能",
rename: "重命名",
editSoul: "编辑 SOUL.md",
soulSection: "SOUL.md(人格 / 系统提示词)",
soulPlaceholder: "# 这个代理应当如何工作……",
saveSoul: "保存 SOUL",
soulSaved: "SOUL.md 已保存",
openInTerminal: "复制 CLI 命令",
commandCopied: "已复制到剪贴板",
copyFailed: "复制失败",
confirmDeleteTitle: "删除多Agent配置?",
confirmDeleteMessage:
"将永久删除多Agent配置 '{name}' — 包括配置、密钥、记忆、会话、技能、定时任务。此操作无法撤销。",
created: "已创建",
deleted: "已删除",
renamed: "已重命名",
},
pluginsPage: {
contextEngineLabel: "上下文引擎",
dashboardSlots: "面板插槽",
disableRuntime: "禁用",
enableAfterInstall: "安装后启用",
enableRuntime: "启用",
forceReinstall: "强制重装(先删除已有目录)",
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用",
installBtn: "安装",
installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方",
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
noDashboardTab: "无仪表盘标签",
openTab: "打开",
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml",
pluginListHeading: "已安装插件",
providerDefaults: "内置 / 默认",
providersHeading: "运行时提供方插件",
providersHint:
"写入 config.yamlmemory.provider(留空为内置)、context.engine。下次会话生效。",
refreshDashboard: "重新扫描仪表盘扩展",
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
rescanHeading: "SPA 插件注册表",
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
runtimeHeading: "网关运行时(YAML 插件)",
saveProviders: "保存提供方设置",
savedProviders: "提供方设置已保存。",
sourceBadge: "来源",
authRequired: "需要认证",
authRequiredHint: "运行此命令以完成认证:",
updateGit: "git pull",
versionBadge: "版本",
showInSidebar: "在侧边栏显示",
hideFromSidebar: "从侧边栏隐藏",
},
skills: {
title: "技能",
searchPlaceholder: "搜索技能和工具集...",
enabledOf: "已启用 {enabled}/{total}",
all: "全部",
categories: "分类",
filters: "筛选",
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
noSkillsMatch: "没有匹配的技能。",
skillCount: "{count} 个技能",
resultCount: "{count} 个结果",
noDescription: "暂无描述。",
toolsets: "工具集",
toolsetLabel: "{name} 工具集",
noToolsetsMatch: "没有匹配的工具集。",
setupNeeded: "需要配置",
disabledForCli: "CLI 已禁用",
more: "还有 {count} 个",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "筛选",
sections: "分类",
exportConfig: "导出配置为 JSON",
importConfig: "从 JSON 导入配置",
resetDefaults: "恢复默认值",
resetScopeTooltip: "将{scope}恢复为默认值",
confirmResetScope: "确定要将{scope}的所有设置恢复为默认值吗?此操作仅更新表单,在按下「保存」按钮前不会写入 config.yaml。",
resetScopeToast: "{scope}已恢复为默认值 — 请检查并保存以生效",
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: {
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
confirmClearMessage: "该变量的已存值将从 .env 文件中删除。无法在此界面撤销。",
confirmClearTitle: "清除此密钥?",
description: "管理存储在以下位置的 API 密钥和凭据",
hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项",
showLess: "显示更少",
showMore: "显示更多",
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: "复制",
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: "切换语言",
},
theme: {
title: "主题",
switchTheme: "切换主题",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"从真实会话历史中获得的 Hermes 可收集徽章。已知尚未达成的成就显示为「已发现」;秘密成就在首次出现匹配行为之前保持隐藏。",
scan_subtitle:
"正在扫描 Hermes 会话历史。在历史记录较多时,首次扫描可能需要 5–10 秒。",
},
actions: {
rescan: "重新扫描",
},
stats: {
unlocked: "已解锁",
unlocked_hint: "获得的徽章",
discovered: "已发现",
discovered_hint: "已知,但尚未获得",
secrets: "秘密",
secrets_hint: "在首次信号出现前保持隐藏",
highest_tier: "最高等级",
highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian",
latest: "最新",
latest_hint_empty: "多多运行 Hermes",
none_yet: "暂无",
},
state: {
unlocked: "已解锁",
discovered: "已发现",
secret: "秘密",
},
tier: {
target: "目标 {tier}",
hidden: "隐藏",
complete: "已完成",
objective: "目标",
},
progress: {
hidden: "隐藏",
},
scan: {
building_headline: "正在构建成就档案…",
building_detail:
"正在读取会话、工具调用、模型元数据和解锁状态。",
starting_headline: "正在开始成就扫描…",
progress_detail:
"已扫描 {scanned} / {total} 个会话 · {pct}%。随着更多历史流入,徽章会陆续解锁。",
idle_detail:
"正在读取会话、工具调用、模型元数据和解锁状态。徽章解锁后将在此显示。",
},
guide: {
tiers_header: "等级",
secret_header: "秘密成就",
secret_body:
"秘密成就会隐藏其确切触发条件。一旦 Hermes 检测到相关信号,卡片将变为「已发现」并显示其要求。",
scan_status_header: "扫描状态",
scan_status_body:
"Hermes 正在对本地历史进行一次扫描,之后卡片会自动出现。即使这需要几秒钟,也没有卡住。",
what_scanned_header: "扫描内容",
what_scanned_body:
"会话、工具调用、模型元数据、错误、成就和本地解锁状态。",
},
card: {
share_title: "分享此成就",
share_label: "分享 {name}",
share_text: "分享",
how_to_reveal: "如何揭示",
what_counts: "计入条件",
evidence_label: "证据",
evidence_session_fallback: "会话",
no_evidence: "暂无证据",
},
latest: {
header: "最近解锁",
},
empty: {
no_secrets_header: "本次扫描中已没有隐藏的秘密。",
no_secrets_body:
"提示:秘密通常源于异常失败或高级用户行为模式 —— 端口冲突、权限阻拦、缺少环境变量、YAML 错误、Docker 冲突、回滚或检查点使用、缓存命中,或在大量红色错误后做出的小小修复。",
},
filters: {
all_categories: "全部",
visibility_all: "全部",
visibility_unlocked: "已解锁",
visibility_discovered: "已发现",
visibility_secret: "秘密",
},
share: {
dialog_label: "分享成就",
header: "分享:{name}",
close: "关闭",
rendering: "渲染中…",
card_alt: "{name} 分享卡片",
error_generic: "发生错误。",
x_title: "在 X 中打开预填好的帖子",
x_button: "在 X 上分享",
copy_title: "复制图片以粘贴到你的帖子中",
copy_button: "复制图片",
copied: "已复制 ✓",
download_button: "下载 PNG",
hint:
"「在 X 上分享」会在新标签页中打开预填好的帖子。如果想附上 1200×630 的徽章,请先点击「复制图片」—— X 允许你直接粘贴到推文编辑器中。「下载 PNG」会将文件保存下来,可在任意位置使用。",
clipboard_unsupported:
"此浏览器不支持复制剪贴板图片 —— 请改用「下载」。",
tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤",
},
},
kanban: {
loading: "正在加载看板…",
loadFailed: "加载看板失败:",
loadFailedHint:
"后端会在首次读取时自动创建 kanban.db。如果问题持续,请检查仪表盘日志。",
board: "看板",
newBoard: "+ 新建看板",
newBoardTitle: "新建看板",
newBoardDescription:
"看板可以将不相关的工作流分开——每个项目、代码库或域一个看板。一个看板上的工作者不会看到另一个看板的任务。",
slug: "标识",
slugHint: "— 小写字母、连字符,例如 atm10-server",
displayName: "显示名称",
displayNameHint: "(可选)",
description: "描述",
descriptionHint: "(可选)",
icon: "图标",
iconHint: "(单个字符或表情)",
switchAfterCreate: "创建后切换到此看板",
cancel: "取消",
creating: "创建中…",
createBoard: "创建看板",
search: "搜索",
filterCards: "筛选卡片…",
tenant: "租户",
allTenants: "全部租户",
assignee: "负责人",
allProfiles: "全部配置",
showArchived: "显示已归档",
lanesByProfile: "按配置分组",
nudgeDispatcher: "触发调度器",
refresh: "刷新",
selected: "已选中",
complete: "完成",
archive: "归档",
apply: "应用",
clear: "清除",
createTask: "在此列创建任务",
noTasks: "— 无任务 —",
unassigned: "未分配",
untitled: "(无标题)",
loadingDetail: "加载中…",
addComment: "添加评论…(按回车提交)",
comment: "评论",
status: "状态",
workspace: "工作区",
skills: "技能",
createdBy: "创建者",
result: "结果",
comments: "评论",
events: "事件",
runHistory: "运行历史",
workerLog: "工作日志",
loadingLog: "正在加载日志…",
noWorkerLog:
"— 暂无工作日志(任务尚未启动或日志已被轮转)—",
noDescription: "— 无描述 —",
noComments: "— 无评论 —",
edit: "编辑",
save: "保存",
dependencies: "依赖",
parents: "父任务:",
children: "子任务:",
none: "无",
addParent: "— 添加父任务 —",
addChild: "— 添加子任务 —",
removeDependency: "移除依赖",
block: "阻塞",
unblock: "解除阻塞",
notifyHomeChannels: "通知主页频道",
diagnostics: "诊断",
hide: "隐藏",
show: "显示",
attention: "注意",
tasksNeedAttention: "个任务需要关注",
taskNeedsAttention: "1 个任务需要关注",
diagnostic: "诊断",
open: "打开",
close: "关闭 (Esc)",
reassignTo: "重新分配给:",
copied: "已复制",
copyCommand: "复制命令到剪贴板",
reclaim: "收回",
reassign: "重新分配",
renderingError: "看板标签页发生渲染错误",
reloadView: "重新加载视图",
wsAuthFailed:
"WebSocket 认证失败 — 请刷新页面以更新会话令牌。",
markDone: "将 {n} 个任务标记为完成?",
markArchived: "归档 {n} 个任务?",
warning: "警告",
phantomIds: "幽灵 ID",
active: "运行中",
ended: "已结束",
noProfile: "(无配置)",
showAllAttempts: "显示所有尝试",
sendingUpdates: "正在发送更新到",
sendNotifications: "发送完成 / 阻塞 / 放弃通知到",
archiveBoardConfirm:
"归档看板 '{name}'?它将被移动到 boards/_archived/ 以便稍后恢复。此看板上的任务将不再出现在 UI 中的任何地方。",
archiveBoardTitle: "归档此看板",
boardSwitcherHint: "看板可以将不相关的工作流分开",
taskCreatedWarning: "任务已创建,但:",
moveFailed: "移动失败:",
bulkFailed: "批量操作:",
completionBlockedHallucination: "⚠ 完成被阻塞 — 幽灵卡片 ID",
suspectedHallucinatedReferences: "⚠ 文本引用了幽灵卡片 ID",
pickProfileFirst: "请先选择一个配置。",
unblockedMessage: "已解除阻塞 {id}。任务已准备好进入下一轮调度。",
unblockFailed: "解除阻塞失败:",
reclaimedMessage: "已收回 {id}。任务已回到就绪状态。",
reclaimFailed: "收回失败:",
reassignedMessage: "已将 {id} 重新分配给 {profile}。",
reassignFailed: "重新分配失败:",
selectForBulk: "选择以进行批量操作",
clickToEdit: "点击编辑",
clickToEditAssignee: "点击编辑负责人",
emptyAssignee: "(留空 = 取消分配)",
columnLabels: {
triage: "待分类",
todo: "待办",
scheduled: "已调度",
ready: "就绪",
running: "进行中",
blocked: "阻塞",
done: "已完成",
archived: "已归档",
},
columnHelp: {
triage: "原始想法 — 规范制定者将完善规格",
todo: "等待依赖项或未分配",
scheduled: "等待已知的时间延迟或已调度的跟进",
ready: "依赖项已满足;分配一个配置文件以便调度",
running: "已被工作者认领 — 执行中",
blocked: "工作者请求人工输入",
done: "已完成",
archived: "已归档",
},
confirmDone:
"将此任务标记为完成?工作者将被释放,依赖的子任务将变为就绪。",
confirmArchive:
"归档此任务?它将从默认看板视图中消失。",
confirmBlocked:
"将此任务标记为阻塞?工作者将被释放。",
completionSummary:
"{label} 的完成摘要。这将作为任务结果存储。",
completionSummaryRequired:
"在将任务标记为完成之前,必须提供完成摘要。",
triagePlaceholder: "粗略想法 — AI 将完善规格…",
taskTitlePlaceholder: "新任务标题…",
specifier: "规范制定者",
assigneePlaceholder: "负责人",
priority: "优先级",
skillsPlaceholder:
"技能(可选,逗号分隔):翻译、github-code-review",
noParent: "— 无父任务 —",
workspacePathDir: "工作区路径(必填,例如 ~/projects/my-app",
workspacePathOptional:
"工作区路径(可选,留空则根据负责人推导)",
logTruncated: "(显示最后 100 KB — 完整日志位于 ",
logAt: "",
},
};
+260
View File
@@ -0,0 +1,260 @@
@import 'tailwindcss';
/* `fonts.css` must come BEFORE `globals.css`: as of @nous-research/ui 0.14.x,
`globals.css` only declares the `--font-*` CSS variables (Collapse, Rules
Compressed/Expanded, Mondwest). The `@font-face` registrations live in
`fonts.css`, so without this import the DS variables resolve to font
families the browser never loads and components fall back to a system
stack (Tabs, Segmented, Typography, Buttons, etc. all look unstyled). */
@import '@nous-research/ui/styles/fonts.css';
@import '@nous-research/ui/styles/globals.css';
/* Scan the published design-system bundle so its utility classes survive
Tailwind's JIT purge. */
@source '../node_modules/@nous-research/ui/dist';
/* ------------------------------------------------------------------ */
/* JetBrains Mono — bundled for the embedded TUI (/chat tab). */
/* Gives the terminal a proper monospace font even on systems where */
/* the user doesn't have one installed locally; xterm.js picks it up */
/* via ChatPage's `fontFamily` option. */
/* Apache-2.0. */
/* ------------------------------------------------------------------ */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Italic.woff2') format('woff2');
}
/* ------------------------------------------------------------------ */
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
/* canonical Hermes palette is the default — teal canvas + cream */
/* accent — without relying on leva/gsap at runtime. */
/* ------------------------------------------------------------------ */
:root {
/* LENS_0 — from design-language/src/ui/components/overlays/index.tsx.
These are the defaults for the `default` (Hermes Teal) dashboard theme;
ThemeProvider rewrites them as inline styles when a user switches themes. */
--foreground: color-mix(in srgb, #ffffff 0%, transparent);
--foreground-base: #ffffff;
--foreground-alpha: 0;
--midground: color-mix(in srgb, #ffe6cb 100%, transparent);
--midground-base: #ffe6cb;
--midground-alpha: 1;
--background: color-mix(in srgb, #041c1c 100%, transparent);
--background-base: #041c1c;
--background-alpha: 1;
/* Consumed by <Backdrop />; also theme-switchable. */
--warm-glow: rgba(255, 189, 56, 0.35);
--noise-opacity-mul: 1;
/* Typography tokens — rewritten by ThemeProvider. Defaults match the
system stack so themes that don't override look native. */
--theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--theme-font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo,
Consolas, monospace;
--theme-font-display: var(--theme-font-sans);
--theme-base-size: 15px;
--theme-line-height: 1.55;
--theme-letter-spacing: 0;
/* Layout tokens. */
--radius: 0.5rem;
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
}
/* Theme tokens cascade into the document root so every descendant inherits
the font stack, base size, and letter spacing without explicit calls. */
html {
font-family: var(--theme-font-sans);
font-size: var(--theme-base-size);
line-height: var(--theme-line-height);
letter-spacing: var(--theme-letter-spacing);
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
body {
font-family: var(--theme-font-sans);
min-height: 0;
height: 100%;
margin: 0;
overflow: hidden;
}
code, kbd, pre, samp, .font-mono, .font-mono-ui {
font-family: var(--theme-font-mono);
}
/* Density: scale the shadcn spacing utilities via a multiplier. The DS
components use `p-N` / `gap-N` / `space-*` classes which resolve against
Tailwind's spacing scale; multiplying `--spacing` at :root scales them
all proportionally in Tailwind v4. */
@theme inline {
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
--font-sans: var(--theme-font-sans);
--font-mono: var(--theme-font-mono);
}
#root {
min-height: 0;
height: 100%;
max-height: 100%;
overflow: hidden;
}
@media (max-width: 768px) {
html,
body,
#root {
min-height: 100dvh;
height: auto;
max-height: none;
overflow-x: hidden;
overflow-y: auto;
}
}
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
dashboard sizes. Keep in sync. */
small { font-size: 1.0625rem; }
code { font-size: 0.875rem; }
/* Shadcn-compat tokens.
The dashboard's page code predates the Nous DS and uses shadcn-style
utility classes (bg-card, text-muted-foreground, border-border, etc.)
extensively. Rather than rewrite every call site, we expose those
tokens on top of the Nous palette so classes continue to resolve. */
@theme inline {
/* Remap foreground to midground so `text-foreground` / `bg-foreground`
stay visible — in LENS_0, `--foreground` itself has alpha 0. */
--color-foreground: var(--midground);
--color-card: color-mix(in srgb, var(--midground-base) 4%, var(--background-base));
--color-card-foreground: var(--midground);
--color-primary: var(--midground);
--color-primary-foreground: var(--background-base);
--color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base));
--color-secondary-foreground: var(--midground);
--color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base));
/* Routes the shadcn `muted-foreground` slot through the DS semantic
text-secondary token (defaults to midground 80%) so legacy call
sites that use `text-muted-foreground` get a readable color
instead of the old 55%-transparent default. */
--color-muted-foreground: var(--color-text-secondary);
--color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base));
--color-accent-foreground: var(--midground);
--color-destructive: #fb2c36;
--color-destructive-foreground: #ffffff;
--color-success: #4ade80;
--color-warning: #ffbd38;
--color-border: color-mix(in srgb, var(--midground-base) 15%, transparent);
--color-input: color-mix(in srgb, var(--midground-base) 15%, transparent);
--color-ring: var(--midground);
--color-popover: color-mix(in srgb, var(--midground-base) 4%, var(--background-base));
--color-popover-foreground: var(--midground);
--radius-sm: calc(var(--theme-radius) - 4px);
--radius-md: calc(var(--theme-radius) - 2px);
--radius-lg: var(--theme-radius);
--radius-xl: calc(var(--theme-radius) + 4px);
}
/* Collapsed sidebar tooltip entrance — skipped when moving between items. */
@keyframes sidebar-tooltip-in {
from { opacity: 0; transform: translateY(-50%) translateX(-4px); }
to { opacity: 1; transform: translateY(-50%) translateX(0); }
}
/* Toast animations used by `components/Toast.tsx`. */
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(16px); }
}
/* Generic fade + dialog entrance used by popovers and confirm dialogs. */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Hide scrollbar utility — used by the header's overflow-x nav row. */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* Plus-lighter blend used by logos/titles for a subtle glow. */
.blend-lighter {
mix-blend-mode: plus-lighter;
}
/* System UI-monospace stack — distinct from `font-courier` (Courier
Prime), used for dense data readouts where the display font would
break the grid. Routes through the theme's mono stack so themes
with a different monospace (JetBrains Mono, IBM Plex Mono, etc.)
still apply here. */
.font-mono-ui {
font-family: var(--theme-font-mono);
}
/* Subtle grain overlay for badges. */
.grain {
position: relative;
}
.grain::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.12;
pointer-events: none;
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 /
2px 2px;
}
/* When a theme provides `assets.bg`, the backdrop's <div> renders it as
a CSS background; the default filler <img> is hidden to prevent
double-compositing. Unset → initial → empty, so the :not() selector
matches and the default image stays visible. */
:root:not([style*="--theme-asset-bg:"]) .theme-default-filler {
display: block;
}
:root[style*="--theme-asset-bg:"] .theme-default-filler {
display: none;
}
+1017
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
declare global {
interface Window {
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
/** @deprecated Older injected name; treated as on when true. */
__HERMES_DASHBOARD_TUI__?: boolean;
}
}
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
export function isDashboardEmbeddedChatEnabled(): boolean {
if (typeof window === "undefined") return false;
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
return window.__HERMES_DASHBOARD_TUI__ === true;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
*
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
* drives over stdio. The server-side transport abstraction
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
* onto either stdout or a WebSocket depending on how the client connected.
*
* const gw = new GatewayClient()
* await gw.connect()
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
export type GatewayEventName =
| "gateway.ready"
| "session.info"
| "message.start"
| "message.delta"
| "message.complete"
| "thinking.delta"
| "reasoning.delta"
| "reasoning.available"
| "status.update"
| "tool.start"
| "tool.progress"
| "tool.complete"
| "tool.generating"
| "clarify.request"
| "approval.request"
| "sudo.request"
| "secret.request"
| "background.complete"
| "error"
| "skin.changed"
| (string & {});
export interface GatewayEvent<P = unknown> {
type: GatewayEventName;
session_id?: string;
payload?: P;
}
export type ConnectionState =
| "idle"
| "connecting"
| "open"
| "closed"
| "error";
interface Pending {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
/** Wildcard listener key: subscribe to every event regardless of type. */
const ANY = "*";
export class GatewayClient {
private ws: WebSocket | null = null;
private reqId = 0;
private pending = new Map<string, Pending>();
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
private _state: ConnectionState = "idle";
private stateListeners = new Set<(s: ConnectionState) => void>();
get state(): ConnectionState {
return this._state;
}
private setState(s: ConnectionState) {
if (this._state === s) return;
this._state = s;
for (const cb of this.stateListeners) cb(s);
}
onState(cb: (s: ConnectionState) => void): () => void {
this.stateListeners.add(cb);
cb(this._state);
return () => this.stateListeners.delete(cb);
}
/** Subscribe to a specific event type. Returns an unsubscribe function. */
on<P = unknown>(
type: GatewayEventName,
cb: (ev: GatewayEvent<P>) => void,
): () => void {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
set.add(cb as (ev: GatewayEvent) => void);
return () => set!.delete(cb as (ev: GatewayEvent) => void);
}
/** Subscribe to every event (fires after type-specific listeners). */
onAny(cb: (ev: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, cb);
}
async connect(token?: string): Promise<void> {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
// Explicit ``token`` overrides the gate check (test-only path).
let authParamName: string;
let authParamValue: string;
if (token) {
authParamName = "token";
authParamValue = token;
} else if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
authParamName = "ticket";
authParamValue = ticket;
} else {
authParamName = "token";
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
if (!authParamValue) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
);
this.ws = ws;
// Register message + close BEFORE awaiting open — the server emits
// `gateway.ready` immediately after accept, so a listener attached
// after the open promise resolves can race past it and drop the
// initial skin payload.
ws.addEventListener("message", (ev) => {
try {
this.dispatch(JSON.parse(ev.data));
} catch {
/* malformed frame — ignore */
}
});
ws.addEventListener("close", () => {
this.setState("closed");
this.rejectAllPending(new Error("WebSocket closed"));
});
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("error", onError);
this.setState("open");
resolve();
};
const onError = () => {
ws.removeEventListener("open", onOpen);
this.setState("error");
reject(new Error("WebSocket connection failed"));
};
ws.addEventListener("open", onOpen, { once: true });
ws.addEventListener("error", onError, { once: true });
});
}
close() {
this.ws?.close();
this.ws = null;
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined;
if (id !== undefined && this.pending.has(id)) {
const p = this.pending.get(id)!;
this.pending.delete(id);
clearTimeout(p.timer);
const err = msg.error as { message?: string } | undefined;
if (err) p.reject(new Error(err.message ?? "request failed"));
else p.resolve(msg.result);
return;
}
if (msg.method !== "event") return;
const params = (msg.params ?? {}) as GatewayEvent;
if (typeof params.type !== "string") return;
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
}
private rejectAllPending(err: Error) {
for (const p of this.pending.values()) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
/** Send a JSON-RPC request. Rejects on error response or timeout. */
request<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<T> {
if (!this.ws || this._state !== "open") {
return Promise.reject(
new Error(`gateway not connected (state=${this._state})`),
);
}
const id = `w${++this.reqId}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(new Error(`request timed out: ${method}`));
}
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => resolve(v as T),
reject,
timer,
});
try {
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
} catch (e) {
clearTimeout(timer);
this.pending.delete(id);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}
+23
View File
@@ -0,0 +1,23 @@
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const clone = structuredClone(obj);
const parts = path.split(".");
let cur: Record<string, unknown> = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
cur[parts[i]] = {};
}
cur = cur[parts[i]] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return clone;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/chat": "chat",
"/sessions": "sessions",
"/analytics": "analytics",
"/models": "models",
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/plugins": "plugins",
"/profiles": "profiles",
"/config": "config",
"/env": "keys",
"/docs": "documentation",
};
export function resolvePageTitle(
pathname: string,
t: Translations,
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/") {
return t.app.nav.sessions;
}
const plugin = pluginTabs.find((p) => p.path === normalized);
if (plugin) {
return plugin.label;
}
const key = BUILTIN[normalized];
if (key) {
return t.app.nav[key];
}
// Derive title from pathname: "/profiles" → "Profiles"
const segment = normalized.slice(1);
if (segment) {
return segment.charAt(0).toUpperCase() + segment.slice(1);
}
return t.app.webUi;
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` — covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, …). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
export const themedFont = "font-mondwest";
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
export const themedBody = "font-mondwest normal-case";
/** Mondwest brand chrome — uppercase section headers and nav labels. */
export const themedChrome = "font-mondwest text-display";
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
if (delta < 172800) return "yesterday";
return `${Math.floor(delta / 86400)}d ago`;
}
/** Relative time from an ISO-8601 timestamp string. */
export function isoTimeAgo(iso: string): string {
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
if (delta < 0 || Number.isNaN(delta)) return "unknown";
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}
+25
View File
@@ -0,0 +1,25 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import { SystemActionsProvider } from "./contexts/SystemActions";
import { I18nProvider } from "./i18n";
import { exposePluginSDK } from "./plugins";
import { ThemeProvider } from "./themes";
import { HERMES_BASE_PATH } from "./lib/api";
// Expose the plugin SDK before rendering so plugins loaded via <script>
// can access React, components, etc. immediately.
exposePluginSDK();
createRoot(document.getElementById("root")!).render(
<BrowserRouter basename={HERMES_BASE_PATH || undefined}>
<I18nProvider>
<ThemeProvider>
<SystemActionsProvider>
<App />
</SystemActionsProvider>
</ThemeProvider>
</I18nProvider>
</BrowserRouter>,
);
+601
View File
@@ -0,0 +1,601 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
BarChart3,
Brain,
Cpu,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AnalyticsResponse,
AnalyticsDailyEntry,
AnalyticsModelEntry,
AnalyticsSkillEntry,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatDate(day: string): string {
try {
const d = new Date(day + "T00:00:00");
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return day;
}
}
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
function useTableSort<T>(
data: T[],
defaultKey: keyof T & string,
defaultDir: "asc" | "desc" = "desc",
) {
const [sortKey, setSortKey] = useState<string>(defaultKey);
const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir);
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
const aVal = a[sortKey as keyof T];
const bVal = b[sortKey as keyof T];
// Nulls always last regardless of direction
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
if (aVal === bVal) return 0;
const cmp = aVal > bVal ? 1 : -1;
return sortDir === "asc" ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const toggle = useCallback(
(key: string) => {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
},
[sortKey],
);
return { sorted, sortKey, sortDir, toggle };
}
function SortHeader({
label,
col,
sortKey,
sortDir,
toggle,
className,
}: {
label: string;
col: string;
sortKey: string;
sortDir: "asc" | "desc";
toggle: (key: string) => void;
className?: string;
}) {
const active = col === sortKey;
return (
<th
onClick={() => toggle(col)}
className={`cursor-pointer select-none ${className ?? ""}`}
>
<span className="inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 hover:bg-muted/40 transition-colors">
{label}
{active ? (
sortDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
)}
</span>
</th>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const maxTokens = Math.max(
...daily.map((d) => d.input_tokens + d.output_tokens),
1,
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyTokenUsage}
</CardTitle>
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
{t.analytics.output}
</div>
</div>
</CardHeader>
<CardContent>
<div
className="flex items-end gap-[2px]"
style={{ height: CHART_HEIGHT_PX }}
>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round(
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
);
const outputH = Math.round(
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
);
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
<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="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>
{t.analytics.input}: {formatTokens(d.input_tokens)}
</div>
<div>
{t.analytics.output}: {formatTokens(d.output_tokens)}
</div>
<div>
{t.analytics.total}: {formatTokens(total)}
</div>
</div>
</div>
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/70"
style={{
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
</span>
</div>
</CardContent>
</Card>
);
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc");
if (daily.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.input} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.output} col="output_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((d) => (
<tr
key={d.day}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4 font-medium">
{formatDate(d.day)}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">
{formatTokens(d.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc");
if (models.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.perModelBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.tokens} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr
key={m.model}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">
{formatTokens(m.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc");
if (skills.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.analytics.loads} col="view_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.edits} col="manage_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.total} col="total_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.lastUsed} col="last_used_at" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((skill) => (
<tr
key={skill.skill}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{skill.skill}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.view_count}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.manage_count}
</td>
<td className="text-right py-2 px-4">{skill.total_count}</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Gated on `dashboard.show_token_analytics` (default off). When off the
// page renders an explanation card instead of fetching analytics — the
// local token counts exclude auxiliary calls and provider retries, so
// they diverge from provider billing in ways that mislead users.
const [showTokens, setShowTokens] = useState<boolean | null>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
setShowTokens(dash.show_token_analytics === true);
})
.catch(() => setShowTokens(false));
}, []);
const load = useCallback(() => {
if (!showTokens) return;
setLoading(true);
setError(null);
api
.getAnalytics(days)
.then(setData)
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days, showTokens]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
</div>
),
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
<PluginSlot name="analytics:top" />
{showTokens === false && (
<Card>
<CardContent className="py-12">
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
<h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
Token analytics hidden
</h2>
<p>
The token, cost, and per-day analytics on this page are a
local debug estimate. They only count successful main-agent
responses with a usable <span className="font-mono">usage</span>{" "}
block, and silently exclude auxiliary calls (context
compression, title generation, vision, session search, web
extract, smart approvals, MCP routing, plugin LLM access)
plus provider-side retries and fallback attempts. Cache
writes are missing entirely.
</p>
<p>
On models with heavy auxiliary traffic (Kimi K2.6, MiniMax
M2.7) the local total can be 10x100x lower than what your
provider bills. Hiding these numbers is safer than letting
them look authoritative.
</p>
<p>
Check your provider dashboard (OpenRouter, Anthropic, etc.)
for actual usage and billing. To re-enable the local debug
estimate anyway, set{" "}
<span className="font-mono">
dashboard.show_token_analytics: true
</span>{" "}
in <a href="/config" className="underline">Config</a>.
</p>
</div>
</CardContent>
</Card>
)}
{showTokens && loading && !data && (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
)}
{showTokens && error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{showTokens && data && (
<>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="py-6">
<Stats
items={[
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.analytics.totalSessions,
value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
},
{
label: t.analytics.apiCalls,
value: String(
data.totals.total_api_calls ??
data.daily.reduce((sum, d) => sum + d.sessions, 0),
),
},
]}
/>
</CardContent>
</Card>
<TokenBarChart daily={data.daily} />
</div>
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
<SkillTable skills={data.skills.top_skills} />
</>
)}
{data &&
data.daily.length === 0 &&
data.by_model.length === 0 &&
data.skills.top_skills.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-text-tertiary">
{t.analytics.startSession}
</p>
</div>
</CardContent>
</Card>
)}
<PluginSlot name="analytics:bottom" />
</div>
);
}
+889
View File
@@ -0,0 +1,889 @@
/**
* ChatPage — embeds `hermes --tui` inside the dashboard.
*
* <div host> (dashboard chrome) .
* └─ <div wrapper> (rounded, dark bg, padded — the "terminal window" .
* look that gives the page a distinct visual identity) .
* └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
* │ onData keystrokes → WebSocket → PTY master .
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
* │ write(data) PTY output bytes → VT100 parser .
* ▼ .
* WebSocket /api/pty?token=<session> .
* ▼ .
* FastAPI pty_ws (hermes_cli/web_server.py) .
* ▼ .
* POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent .
*/
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Button } from "@nous-research/ui/ui/components/button";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { ChatSidebar } from "@/components/ChatSidebar";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
function buildWsUrl(
authParam: [string, string],
resume: string | null,
channel: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
// ``["ticket", <minted>]`` in gated mode. The server-side helper
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
// (subscriber). Generated once per mount so a tab refresh starts a fresh
// channel — the previous PTY child terminates with the old WS, and its
// channel auto-evicts when no subscribers remain.
function generateChannelId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
// Colors for the terminal body. Matches the dashboard's dark teal canvas
// with cream foreground — we intentionally don't pick monokai or a loud
// theme, because the TUI's skin engine already paints the content; the
// terminal chrome just needs to sit quietly inside the dashboard.
const TERMINAL_THEME = {
background: "#0d2626",
foreground: "#f0e6d2",
cursor: "#f0e6d2",
cursorAccent: "#0d2626",
selectionBackground: "#f0e6d244",
};
/**
* CSS width for xterm font tiers.
*
* Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often
* keeps `window.innerWidth` at the full desktop value while the *drawn* layout
* is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
*/
function terminalTierWidthPx(host: HTMLElement | null): number {
if (typeof window === "undefined") return 1280;
const fromHost = host?.clientWidth ?? 0;
if (fromHost > 2) return Math.round(fromHost);
const doc = document.documentElement?.clientWidth ?? 0;
const vv = window.visualViewport;
const inner = window.innerWidth;
const vvw = vv?.width ?? inner;
const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
return Math.max(1, Math.round(layout));
}
function terminalFontSizeForWidth(layoutWidthPx: number): number {
if (layoutWidthPx < 300) return 7;
if (layoutWidthPx < 360) return 8;
if (layoutWidthPx < 420) return 9;
if (layoutWidthPx < 520) return 10;
if (layoutWidthPx < 720) return 11;
if (layoutWidthPx < 1024) return 12;
return 14;
}
function terminalLineHeightForWidth(layoutWidthPx: number): number {
return layoutWidthPx < 1024 ? 1.02 : 1.15;
}
export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Exposed to the main metrics-sync effect so it can refit the terminal
// the moment `isActive` flips back to true (display:none → display:flex
// collapses the host's box, so ResizeObserver never fires on return).
const syncMetricsRef = useRef<(() => void) | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Lazy-init: the missing-token check happens at construction so the effect
// body doesn't have to setState (React 19's set-state-in-effect rule).
// In gated (OAuth) mode the server intentionally omits the session token —
// the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
// so a missing token there is expected, not an error.
const [banner, setBanner] = useState<string | null>(() =>
typeof window !== "undefined" &&
!window.__HERMES_SESSION_TOKEN__ &&
!window.__HERMES_AUTH_REQUIRED__
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
: null,
);
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Raw state for the mobile side-sheet + a derived value that force-
// closes whenever the chat tab isn't active. The *derived* value is
// what side-effects (body-scroll lock, keydown listener, portal render)
// key on — that way switching to another tab triggers the effect's
// cleanup, releasing the scroll-lock on /sessions etc. Returning to
// /chat re-runs the effect (derived flips back to true) and re-locks.
// Keying on the raw state would leak the body.overflow="hidden" across
// tabs because the dep wouldn't change on tab switch.
const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false);
const mobilePanelOpen = isActive && mobilePanelOpenRaw;
const { setEnd } = usePageHeader();
const { t } = useI18n();
const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []);
const modelToolsLabel = useMemo(
() => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
[t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
const [narrow, setNarrow] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(max-width: 1023px)").matches
: false,
);
// The dashboard keeps ChatPage mounted persistently so the PTY survives tab
// switches. That is great for ordinary /chat navigation, but it means query
// param changes do NOT remount the component. Resume-in-chat from the
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
const resumeParam = searchParams.get("resume");
const channel = useMemo(() => generateChannelId(), [resumeParam]);
useEffect(() => {
if (!resumeParam) return;
let cancelled = false;
api
.getSessionLatestDescendant(resumeParam)
.then((res) => {
if (cancelled || !res.session_id || res.session_id === resumeParam) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("resume", res.session_id);
setSearchParams(next, { replace: true });
})
.catch(() => {
// Best-effort: old servers or missing sessions should not block chat.
});
return () => {
cancelled = true;
};
}, [resumeParam, searchParams, setSearchParams]);
useEffect(() => {
const mql = window.matchMedia("(max-width: 1023px)");
const sync = () => setNarrow(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!mobilePanelOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMobilePanel();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [mobilePanelOpen, closeMobilePanel]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setMobilePanelOpenRaw(false);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
useEffect(() => {
// When hidden (non-chat tab) we must not register the header button —
// another page owns the header's end slot at that point.
if (!isActive) {
setEnd(null);
return;
}
if (!narrow) {
setEnd(null);
return;
}
setEnd(
<Button
ghost
onClick={() => setMobilePanelOpenRaw(true)}
aria-expanded={mobilePanelOpen}
aria-controls="chat-side-panel"
className={cn(
"shrink-0 rounded border border-current/20",
"px-2 py-1 text-xs font-medium tracking-wide",
"text-text-secondary hover:text-midground hover:bg-midground/5",
)}
>
<span className="inline-flex items-center gap-1.5">
<PanelRight className="h-3 w-3 shrink-0" />
{modelToolsLabel}
</span>
</Button>,
);
return () => setEnd(null);
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
const handleCopyLast = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Send the slash as a burst, wait long enough for Ink's tokenizer to
// emit a keypress event for each character (not coalesce them into a
// paste), then send Return as its own event. The timing here is
// empirical — 100ms is safely past Node's default stdin coalescing
// window and well inside UI responsiveness.
ws.send("/copy");
setTimeout(() => {
const s = wsRef.current;
if (s && s.readyState === WebSocket.OPEN) s.send("\r");
}, 100);
setCopyState("copied");
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500);
termRef.current?.focus();
};
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const token = window.__HERMES_SESSION_TOKEN__;
const gated = !!window.__HERMES_AUTH_REQUIRED__;
// Banner already initialised above; just bail before wiring xterm/WS.
// In gated mode the token is absent by design — buildWsAuthParam() mints
// a WS ticket instead, so don't bail; let the effect reach that path.
if (!token && !gated) {
return;
}
const tierW0 = terminalTierWidthPx(host);
const term = new Terminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontSize: terminalFontSizeForWidth(tierW0),
lineHeight: terminalLineHeightForWidth(tierW0),
letterSpacing: 0,
fontWeight: "400",
fontWeightBold: "700",
macOptionIsMeta: true,
// Hold Option (Alt on Linux/Windows) to force native text selection
// even when the inner Hermes TUI has enabled xterm mouse-events
// mode (CSI ?1000h family). Without this, click-and-drag in the
// chat canvas selects nothing and Cmd+C falls back to copying the
// entire visible buffer, which is rarely what the user wants.
// See #25720.
macOptionClickForcesSelection: true,
// Right-click selects the word under the pointer. xterm.js default
// is false; enabling it gives users a single-action selection
// path on top of the modifier-based bypass above.
rightClickSelectsWord: true,
// Browser-embedded chat runs the TUI in inline mode. Keep transcript
// history in xterm.js so the browser wheel can scroll it directly.
scrollback: 5000,
theme: TERMINAL_THEME,
});
termRef.current = term;
// --- Clipboard integration ---------------------------------------
//
// Three independent paths all route to the system clipboard:
//
// 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler
// in useInputHandlers.ts turns Ctrl+C into a copy when the
// terminal has a selection, then emits an OSC 52 escape. Our
// OSC 52 handler below decodes that escape and writes to the
// browser clipboard — so the flow works just like it does in
// `hermes --tui`.
//
// 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that
// operates directly on xterm's selection, useful if the TUI
// ever stops listening (e.g. overlays / pickers) or if the user
// has selected with the mouse outside of Ink's selection model.
//
// 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds
// it to the terminal as keyboard input. xterm's paste() wraps
// it with bracketed-paste if the host has that mode enabled.
//
// OSC 52 reads (terminal asking to read the clipboard) are not
// supported — that would let any content the TUI renders exfiltrate
// the user's clipboard.
term.parser.registerOscHandler(52, (data) => {
// Format: "<targets>;<base64 | '?'>"
const semi = data.indexOf(";");
if (semi < 0) return false;
const payload = data.slice(semi + 1);
if (payload === "?" || payload === "") return false; // read/clear — ignore
try {
const binary = atob(payload);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder("utf-8").decode(bytes);
navigator.clipboard.writeText(text).catch((err) => {
// Most common reason: the Clipboard API requires a user gesture.
// This can fail when the OSC 52 response arrives outside the
// original keydown event's activation. Log to aid debugging.
console.warn("[dashboard clipboard] OSC 52 write failed:", err.message);
});
} catch {
console.warn("[dashboard clipboard] malformed OSC 52 payload");
}
return true;
});
const isMac =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
// Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C
// is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /
// konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists;
// without a selection it passes through to the TUI so agents can still
// react to the keypress.
// Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others.
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
if (copyModifier && ev.key.toLowerCase() === "c") {
const sel = term.getSelection();
if (sel) {
// Direct writeText inside the keydown handler preserves the user
// gesture — async round-trips through OSC 52 can lose activation
// and fail with "Document is not focused".
navigator.clipboard.writeText(sel).catch((err) => {
console.warn("[dashboard clipboard] direct copy failed:", err.message);
});
// Clear xterm.js's highlight after copy (matches gnome-terminal).
term.clearSelection();
ev.preventDefault();
return false;
}
// No selection → fall through so the TUI receives Ctrl+Shift+C
// (or the bare ev if the user used a different modifier).
}
if (pasteModifier && ev.key.toLowerCase() === "v") {
navigator.clipboard
.readText()
.then((text) => {
if (text) term.paste(text);
})
.catch((err) => {
console.warn("[dashboard clipboard] paste failed:", err.message);
});
ev.preventDefault();
return false;
}
return true;
});
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
// Dashboard chat should scroll the browser-side transcript, not send
// mouse-wheel protocol bytes through the PTY.
term.attachCustomWheelEventHandler((ev) => {
const delta = ev.deltaY;
if (!delta) {
return false;
}
const step = Math.max(1, Math.round(Math.abs(delta) / 50));
term.scrollLines(delta > 0 ? step : -step);
ev.preventDefault();
ev.stopPropagation();
return false;
});
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.loadAddon(new WebLinksAddon());
term.open(host);
// WebGL draws from a texture atlas sized with device pixels. On phones and
// in DevTools device mode that often produces *visually* much larger cells
// than `fontSize` suggests — users see "huge" text even at 79px settings.
// The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
// hosts. Wide layouts still get WebGL for crisp box-drawing.
const useWebgl = terminalTierWidthPx(host) >= 768;
if (useWebgl) {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch (err) {
console.warn(
"[hermes-chat] WebGL renderer unavailable; falling back to default",
err,
);
}
}
// Initial fit + resize observer. fit.fit() reads the container's
// current bounding box and resizes the terminal grid to match.
//
// The subtle bit: the dashboard has CSS transitions on the container
// (backdrop fade-in, rounded corners settling as fonts load). If we
// call fit() at mount time, the bounding box we measure is often 1-2
// cell widths off from the final size. ResizeObserver *does* fire
// when the container settles, but if the pixel delta happens to be
// smaller than one cell's width, fit() computes the same integer
// (cols, rows) as before and doesn't emit onResize — so the PTY
// never learns the final size. Users see truncated long lines until
// they resize the browser window.
//
// We force one extra fit + explicit RESIZE send after two animation
// frames. rAF→rAF guarantees one layout commit between the two
// callbacks, giving CSS transitions and font metrics time to finalize
// before we take the authoritative measurement.
let hostSyncRaf = 0;
const scheduleHostSync = () => {
if (hostSyncRaf) return;
hostSyncRaf = requestAnimationFrame(() => {
hostSyncRaf = 0;
syncTerminalMetrics();
});
};
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
const syncTerminalMetrics = () => {
// display:none hosts have clientWidth/Height = 0, which fit() turns
// into a 1x1 terminal. Skip entirely while hidden; the visibility
// effect below runs another fit as soon as the tab is shown again.
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return;
}
const w = terminalTierWidthPx(host);
const nextSize = terminalFontSizeForWidth(w);
const nextLh = terminalLineHeightForWidth(w);
const fontChanged =
term.options.fontSize !== nextSize ||
term.options.lineHeight !== nextLh;
if (fontChanged) {
term.options.fontSize = nextSize;
term.options.lineHeight = nextLh;
}
try {
fit.fit();
} catch {
return;
}
if (fontChanged && term.rows > 0) {
try {
term.refresh(0, term.rows - 1);
} catch {
/* ignore */
}
}
if (
fontChanged &&
wsRef.current &&
wsRef.current.readyState === WebSocket.OPEN
) {
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
}
};
syncMetricsRef.current = syncTerminalMetrics;
const scheduleSyncTerminalMetrics = () => {
if (metricsDebounce) clearTimeout(metricsDebounce);
metricsDebounce = setTimeout(() => {
metricsDebounce = null;
syncTerminalMetrics();
}, 60);
};
const ro = new ResizeObserver(() => scheduleHostSync());
ro.observe(host);
window.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
scheduleHostSync();
requestAnimationFrame(() => scheduleHostSync());
// Double-rAF authoritative fit. On the second frame the layout has
// committed at least once since mount; fit.fit() then reads the
// stable container size. We always send a RESIZE escape afterwards
// (even if fit's cols/rows didn't change, so the PTY has the same
// dims registered as our JS state — prevents a drift where Ink
// thinks the terminal is one col bigger than what's on screen).
let settleRaf1 = 0;
let settleRaf2 = 0;
settleRaf1 = requestAnimationFrame(() => {
settleRaf1 = 0;
settleRaf2 = requestAnimationFrame(() => {
settleRaf2 = 0;
syncTerminalMetrics();
});
});
// WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this
// awaits a single-use ticket via /api/auth/ws-ticket before opening;
// in loopback mode it resolves synchronously against the injected
// session token. The IIFE keeps the outer effect synchronous so its
// ``return cleanup`` stays at the top level; handlers + disposables
// are hoisted to ``let`` bindings the cleanup closes over.
let unmounting = false;
let onDataDisposable: { dispose(): void } | null = null;
let onResizeDisposable: { dispose(): void } | null = null;
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
setBanner(null);
// Send the initial RESIZE immediately so Ink has *a* size to lay
// out against on its first paint. The double-rAF block above will
// follow up with the authoritative measurement — at worst Ink
// reflows once after the PTY boots, which is imperceptible.
ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer));
}
};
ws.onclose = (ev) => {
wsRef.current = null;
if (unmounting) {
return;
}
if (ev.code === 4401) {
setBanner("Auth failed. Reload the page to refresh the session token.");
return;
}
if (ev.code === 4403) {
setBanner("Chat is only reachable from localhost.");
return;
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
return;
}
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
};
// Keystrokes → PTY.
//
// IMPORTANT:
// The embedded web chat has occasionally surfaced stray letters/digits
// in the input line after a turn completes. The most likely culprit is
// browser-side terminal control traffic being forwarded back into the
// PTY as if it were user text. SGR mouse tracking is the highest-risk
// path here: xterm.js emits raw CSI reports (`\x1b[<...`) that look like
// ordinary bytes to the backend.
//
// For the browser embed we prefer input stability over terminal-style
// mouse reporting, so we drop SGR mouse reports entirely instead of
// forwarding them into Hermes. Keyboard input, paste, and resize still
// behave normally.
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
onDataDisposable = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (SGR_MOUSE_RE.test(data)) {
return;
}
ws.send(data);
});
onResizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
}
});
})();
term.focus();
return () => {
unmounting = true;
syncMetricsRef.current = null;
onDataDisposable?.dispose();
onResizeDisposable?.dispose();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
"resize",
scheduleSyncTerminalMetrics,
);
ro.disconnect();
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1);
if (settleRaf2) cancelAnimationFrame(settleRaf2);
// Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode
// ticket fetch makes the open async). The cleanup runs at the outer
// effect's top level so it can't reach into that scope — close via
// the ref instead. ``?.`` covers the race where unmount fires before
// the ticket fetch resolves and ``wsRef.current`` was never assigned.
wsRef.current?.close();
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
copyResetRef.current = null;
}
};
}, [channel, resumeParam]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.
// ResizeObserver won't fire on that kind of style-driven box change —
// xterm thinks its grid is still whatever it was when the tab was
// hidden (or 0×0, if it was hidden before first fit). Force a refit
// after two animation frames so layout has committed.
//
// Focus handling: we only steal focus back into the terminal when
// nothing else inside ChatPage was holding it (typically the first
// activation after mount, where document.activeElement is <body>; or
// a return after the user had been typing in the terminal, where
// focus was already on the xterm textarea before the tab got hidden
// and has since fallen back to <body>). If the user had clicked
// into the sidebar (model picker, tool-call entry) before switching
// tabs, we must not yank focus away from wherever they left it when
// they come back — that's a surprise and an a11y foot-gun.
useEffect(() => {
if (!isActive) return;
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf1 = 0;
raf2 = requestAnimationFrame(() => {
raf2 = 0;
syncMetricsRef.current?.();
const host = hostRef.current;
const active = typeof document !== "undefined"
? document.activeElement
: null;
const focusIsElsewhereInChatPage =
active !== null &&
active !== document.body &&
host !== null &&
!host.contains(active);
if (!focusIsElsewhereInChatPage) {
termRef.current?.focus();
}
});
});
return () => {
if (raf1) cancelAnimationFrame(raf1);
if (raf2) cancelAnimationFrame(raf2);
};
}, [isActive]);
// Layout:
// outer flex column — sits inside the dashboard's content area
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
// terminal wrapper — rounded, dark, padded — the "terminal window"
// floating copy button — bottom-right corner, transparent with a
// subtle border; stays out of the way until hovered. Sends
// `/copy\n` to Ink, which emits OSC 52 → our clipboard handler.
// sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders
// model badge, tool-call list, model picker. Best-effort: if the
// sidecar fails to connect the terminal pane keeps working.
//
// Mobile model/tools sheet is portaled to `document.body` so it stacks
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed`
// descendants below those layers (see Toast.tsx).
const mobileModelToolsPortal =
isActive &&
narrow &&
portalRoot &&
createPortal(
<>
{mobilePanelOpen && (
<Button
ghost
aria-label={t.app.closeModelTools}
onClick={closeMobilePanel}
className={cn(
"fixed inset-0 z-[55] p-0 block",
"bg-black/60 backdrop-blur-sm",
)}
/>
)}
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className={cn(
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
"border-l border-current/20 text-midground",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
"[background:var(--component-sidebar-background)]",
"[clip-path:var(--component-sidebar-clip-path)]",
"[border-image:var(--component-sidebar-border-image)]",
mobilePanelOpen
? "translate-x-0"
: "pointer-events-none translate-x-full",
)}
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
)}
>
<Typography
mondwest
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.modelToolsSheetTitle}
<br />
{t.app.modelToolsSheetSubtitle}
</Typography>
<Button
ghost
size="icon"
onClick={closeMobilePanel}
aria-label={t.app.closeModelTools}
className="text-text-secondary hover:text-midground"
>
<X />
</Button>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
"border-t border-current/10",
)}
>
<ChatSidebar channel={channel} />
</div>
</div>
</>,
portalRoot,
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<PluginSlot name="chat:top" />
{mobileModelToolsPortal}
{banner && (
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
{banner}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
"p-2 sm:p-3",
)}
style={{
backgroundColor: TERMINAL_THEME.background,
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
<Button
ghost
onClick={handleCopyLast}
title="Copy last assistant response as raw markdown"
aria-label="Copy last assistant response"
className={cn(
"absolute z-10",
"normal-case tracking-normal font-normal",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"opacity-70 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME.foreground }}
>
<span className="inline-flex items-center gap-1.5">
<Copy className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"}
</span>
</span>
</Button>
</div>
{!narrow && (
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatSidebar channel={channel} />
</div>
</div>
)}
</div>
<PluginSlot name="chat:bottom" />
</div>
);
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}
+660
View File
@@ -0,0 +1,660 @@
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
FormInput,
RotateCcw,
Search,
Upload,
X,
Settings2,
FileText,
Settings,
Bot,
Monitor,
Palette,
Users,
Brain,
Package,
Lock,
Globe,
Mic,
Volume2,
Ear,
ClipboardList,
MessageCircle,
Wrench,
FileQuestion,
Filter,
Cloud,
Sparkles,
LayoutDashboard,
BookOpen,
Route,
History,
Shield,
FileOutput,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { AutoField } from "@/components/AutoField";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { Input } from "@nous-research/ui/ui/components/input";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
general: Settings,
agent: Bot,
terminal: Monitor,
display: Palette,
delegation: Users,
memory: Brain,
compression: Package,
security: Lock,
browser: Globe,
voice: Mic,
tts: Volume2,
stt: Ear,
logging: ClipboardList,
discord: MessageCircle,
auxiliary: Wrench,
bedrock: Cloud,
curator: Sparkles,
kanban: LayoutDashboard,
model_catalog: BookOpen,
openrouter: Route,
sessions: History,
tool_loop_guardrails: Shield,
tool_output: FileOutput,
updates: RefreshCw,
};
function CategoryIcon({
category,
className,
}: {
category: string;
className?: string;
}) {
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
return <Icon className={className ?? "h-4 w-4"} />;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<
string,
Record<string, unknown>
> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
null,
);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
const [yamlText, setYamlText] = useState("");
const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false);
const [configPath, setConfigPath] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string>("");
const [confirmReset, setConfirmReset] = useState(false);
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
if (!config || !schema) {
setEnd(null);
return;
}
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories;
if (t.config.categories[key]) return t.config.categories[key];
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
useEffect(() => {
api
.getConfig()
.then(setConfig)
.catch(() => {});
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api
.getDefaults()
.then(setDefaults)
.catch(() => {});
api
.getStatus()
.then((resp) => setConfigPath(resp.config_path))
.catch(() => {});
}, []);
// Set active category when categories load
useEffect(() => {
if (categoryOrder.length > 0 && !activeCategory) {
setActiveCategory(categoryOrder[0]);
}
}, [categoryOrder, activeCategory]);
// Load YAML when switching to YAML mode
useEffect(() => {
if (yamlMode) {
setYamlLoading(true);
api
.getConfigRaw()
.then((resp) => setYamlText(resp.yaml))
.catch(() => showToast(t.config.failedToLoadRaw, "error"))
.finally(() => setYamlLoading(false));
}
}, [yamlMode]);
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [
...new Set(
Object.values(schema).map((s) => String(s.category ?? "general")),
),
];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
}, [schema, categoryOrder]);
/* ---- Category field counts ---- */
const categoryCounts = useMemo(() => {
if (!schema) return {};
const counts: Record<string, number> = {};
for (const s of Object.values(schema)) {
const cat = String(s.category ?? "general");
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
}, [schema]);
/* ---- Search ---- */
const isSearching = searchQuery.trim().length > 0;
const lowerSearch = searchQuery.toLowerCase();
const searchMatchedFields = useMemo(() => {
if (!isSearching || !schema) return [];
return Object.entries(schema).filter(([key, s]) => {
const label = key.split(".").pop() ?? key;
const humanLabel = label.replace(/_/g, " ");
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "")
.toLowerCase()
.includes(lowerSearch) ||
String(s.description ?? "")
.toLowerCase()
.includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
/* ---- Active tab fields ---- */
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory,
);
}, [schema, activeCategory, isSearching]);
/* ---- Handlers ---- */
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
showToast(t.config.configSaved, "success");
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleYamlSave = async () => {
setYamlSaving(true);
try {
await api.saveConfigRaw(yamlText);
showToast(t.config.yamlConfigSaved, "success");
api
.getConfig()
.then(setConfig)
.catch(() => {});
} catch (e) {
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
} finally {
setYamlSaving(false);
}
};
const handleReset = () => {
if (!defaults || !config) return;
// Scope the reset to what the user is currently looking at:
// - search mode → the matched fields
// - form mode → the active category's fields
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
// the button sits next to the category tabs and users reasonably assumed
// "reset this tab", not "wipe my entire config.yaml".
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
setConfirmReset(true);
};
const executeReset = () => {
if (!defaults || !config) return;
setConfirmReset(false);
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
const scopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
let next: Record<string, unknown> = config;
for (const [key] of scopedFields) {
next = setNestedValue(next, key, getNestedValue(defaults, key));
}
setConfig(next);
showToast(
t.config.resetScopeToast.replace("{scope}", scopeLabel),
"success",
);
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-config.json";
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result as string);
setConfig(imported);
showToast(t.config.configImported, "success");
} catch {
showToast(t.config.invalidJson, "error");
}
};
reader.readAsText(file);
};
/* ---- Loading ---- */
if (!config || !schema) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (
fields: [string, Record<string, unknown>][],
showCategory = false,
) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection =
!showCategory &&
section &&
section !== lastSection &&
section !== activeCategory;
lastSection = section;
lastCat = cat;
return (
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<CategoryIcon
category={cat}
className="h-4 w-4 text-muted-foreground"
/>
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
<div className="py-1">
<AutoField
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
</div>
</div>
);
});
};
return (
<div className="flex flex-col gap-4">
<PluginSlot name="config:top" />
<Toast toast={toast} />
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
{configPath ?? t.config.configPath}
</code>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
<Button
ghost
size="icon"
onClick={handleExport}
title={t.config.exportConfig}
aria-label={t.config.exportConfig}
>
<Download />
</Button>
<Button
ghost
size="icon"
onClick={() => fileInputRef.current?.click()}
title={t.config.importConfig}
aria-label={t.config.importConfig}
>
<Upload />
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
{!yamlMode &&
(() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace(
"{scope}",
resetScopeLabel,
);
return (
<Button
ghost
size="icon"
onClick={handleReset}
title={resetTitle}
aria-label={resetTitle}
>
<RotateCcw />
</Button>
);
})()}
<div className="w-px h-5 bg-border mx-1" />
<Button
size="sm"
outlined={!yamlMode}
onClick={() => setYamlMode(!yamlMode)}
prefix={yamlMode ? <FormInput /> : <Code />}
>
{yamlMode ? t.common.form : "YAML"}
</Button>
{yamlMode ? (
<Button
size="sm"
className="uppercase"
onClick={handleYamlSave}
disabled={yamlSaving}
>
{yamlSaving ? t.common.saving : t.common.save}
</Button>
) : (
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving}
>
{saving ? t.common.saving : t.common.save}
</Button>
)}
</div>
</div>
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{t.config.rawYaml}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
spellCheck={false}
/>
)}
</CardContent>
</Card>
) : (
<div className="flex flex-col sm:flex-row gap-4">
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.config.filters}
</span>
</div>
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.config.sections}
</div>
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<ListItem
key={cat}
active={isActive}
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
>
<CategoryIcon
category={cat}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="flex-1 truncate">
{prettyCategoryName(cat)}
</span>
<span
className={`text-xs tabular-nums ${
isActive
? "text-text-secondary"
: "text-text-tertiary"
}`}
>
{categoryCounts[cat] || 0}
</span>
</ListItem>
);
})}
</div>
</div>
</div>
</aside>
<div className="flex-1 min-w-0">
{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" />
{t.config.searchResults}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{searchMatchedFields.length}{" "}
{t.config.fields.replace(
"{s}",
searchMatchedFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
</p>
) : (
renderFields(searchMatchedFields, true)
)}
</CardContent>
</Card>
) : (
/* Active category */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<CategoryIcon
category={activeCategory}
className="h-4 w-4"
/>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{activeFields.length}{" "}
{t.config.fields.replace(
"{s}",
activeFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{renderFields(activeFields)}
</CardContent>
</Card>
)}
</div>
</div>
)}
<PluginSlot name="config:bottom" />
<ConfirmDialog
open={confirmReset}
onCancel={() => setConfirmReset(false)}
onConfirm={executeReset}
title={t.config.confirmResetScope.replace(
"{scope}",
isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory),
)}
description={`This will reset ${
(isSearching ? searchMatchedFields : activeFields).length
} field(s) to their default values.`}
destructive
confirmLabel={t.config.resetDefaults}
/>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength
? value.slice(0, maxLength) + "..."
: value;
}
function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
function getJobTitle(job: CronJob): string {
const name = getJobName(job);
if (name) return name;
const prompt = getJobPrompt(job);
if (prompt) return truncateText(prompt, 60);
const script = asText(job.script);
if (script) return truncateText(script, 60);
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
);
}
function getJobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
}
function getJobProfile(job: CronJob): string {
return asText(job.profile) || asText(job.profile_name) || "default";
}
function getJobKey(job: CronJob): string {
return `${getJobProfile(job)}:${job.id}`;
}
function splitJobKey(key: string): { profile: string; id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { profile: "default", id: key };
return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
}
function profileLabel(profile: string): string {
return profile === "default" ? "default" : profile;
}
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
paused: "warning",
error: "destructive",
completed: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
const loadJobs = useCallback(() => {
api
.getCronJobs(selectedProfile)
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, [selectedProfile, showToast, t.common.loading]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => setProfiles([]));
}, []);
useEffect(() => {
loadJobs();
}, [loadJobs]);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setCreating(true);
try {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
},
createProfile,
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
setName("");
setDeliver("local");
setCreateModalOpen(false);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = getJobState(job) === "paused";
const profile = getJobProfile(job);
if (isPaused) {
await api.resumeCronJob(job.id, profile);
showToast(
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
} else {
await api.pauseCronJob(job.id, profile);
showToast(
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
}
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id, getJobProfile(job));
showToast(
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const { profile, id } = splitJobKey(key);
const job = jobs.find((j) => getJobKey(j) === key);
try {
await api.deleteCronJob(id, profile);
showToast(
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
// Put "Create" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
t.cron.confirmDeleteMessage
}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
{/* Create job modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
role="dialog"
aria-modal="true"
aria-labelledby="create-cron-title"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setCreateModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-cron-title"
className="font-mondwest text-display text-base tracking-wider"
>
{t.cron.newJob}
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-profile">Profile</Label>
<Select
id="cron-profile"
value={createProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
<Input
id="cron-name"
autoFocus
placeholder={t.cron.namePlaceholder}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={t.cron.promptPlaceholder}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length})
</H2>
<div className="grid gap-1 min-w-[220px]">
<Label htmlFor="cron-profile-filter">Profile</Label>
<Select
id="cron-profile-filter"
value={selectedProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
<SelectOption value="all">All profiles</SelectOption>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
</div>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.cron.noJobs}
</CardContent>
</Card>
)}
{jobs.map((job) => {
const state = getJobState(job);
const promptText = getJobPrompt(job);
const title = getJobTitle(job);
const hasName = Boolean(getJobName(job));
const deliver = asText(job.deliver);
const profile = getJobProfile(job);
const jobKey = getJobKey(job);
return (
<Card key={jobKey}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{title}
</span>
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
{state}
</Badge>
<Badge tone="outline">{profileLabel(profile)}</Badge>
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">
{truncateText(promptText, 100)}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
<span>
{t.cron.next}: {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="text-xs text-destructive mt-1">
{job.last_error}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title={state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
state === "paused" ? "text-success" : "text-warning"
}
>
{state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(jobKey)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
<PluginSlot name="cron:bottom" />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useLayoutEffect } from "react";
import { ExternalLink } from "lucide-react";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn } from "@/lib/utils";
import { PluginSlot } from "@/plugins";
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
const DS_BUTTON_OUTLINED_LINK_CN = cn(
"group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
"px-[.9em_.75em] py-[1.25em] gap-2",
"leading-0 font-bold tracking-[0.2em] uppercase",
"text-midground bg-transparent shadow-midground",
"shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
);
export default function DocsPage() {
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
setEnd(
<a
href={HERMES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={DS_BUTTON_OUTLINED_LINK_CN}
>
<ExternalLink className="size-3.5" />
{t.app.openDocumentation}
</a>,
);
return () => {
setEnd(null);
};
}, [setEnd, t]);
return (
<div
className={cn(
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
"pt-1 sm:pt-2",
)}
>
<PluginSlot name="docs:top" />
<iframe
title={t.app.nav.documentation}
src={HERMES_DOCS_URL}
className={cn(
"min-h-0 w-full min-w-0 flex-1",
"rounded-sm border border-current/20",
// Docusaurus paints over a transparent <html> / <body> and
// relies on the browser's canvas color (light by default) to
// fill the viewport. Inheriting the dashboard's dark color
// scheme makes that canvas dark, so the docs body text — which
// is tuned for a light canvas — becomes near-invisible. Force a
// light color scheme + white background on the iframe element so
// the docs render cleanly regardless of the active dashboard
// theme or the user's prefers-color-scheme.
"[color-scheme:light] bg-white",
)}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
referrerPolicy="no-referrer-when-downgrade"
/>
<PluginSlot name="docs:bottom" />
</div>
);
}
+918
View File
@@ -0,0 +1,918 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
Eye,
EyeOff,
ExternalLink,
KeyRound,
MessageSquare,
Pencil,
Save,
Settings,
Trash2,
X,
Zap,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "@/lib/api";
import type { EnvVarInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Provider grouping */
/* ------------------------------------------------------------------ */
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
// Nous Portal first
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
// Then alphabetical by display name
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
];
function getProviderGroup(key: string): string {
for (const g of PROVIDER_GROUPS) {
if (key.startsWith(g.prefix)) return g.name;
}
return "Other";
}
function getProviderPriority(groupName: string): number {
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
return entry?.priority ?? 99;
}
interface ProviderGroup {
name: string;
priority: number;
entries: [string, EnvVarInfo][];
hasAnySet: boolean;
}
const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
provider: Zap,
tool: KeyRound,
messaging: MessageSquare,
setting: Settings,
};
/* ------------------------------------------------------------------ */
/* EnvVarRow — single key edit row */
/* ------------------------------------------------------------------ */
function EnvVarRow({
varKey,
info,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
compact = false,
}: {
varKey: string;
info: EnvVarInfo;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed
? revealed[varKey]
: (info.redacted_value ?? "---");
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-xs">
{varKey}
</span>
<span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
</div>
);
}
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-xs">
{varKey}
</Label>
<span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
</div>
);
}
// Full expanded row for set keys or keys being edited
return (
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-xs">{varKey}</Label>
<Badge tone={info.is_set ? "success" : "outline"}>
{info.is_set ? t.common.set : t.env.notSet}
</Badge>
</div>
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="text-xs text-muted-foreground">{info.description}</p>
{info.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{info.tools.map((tool) => (
<Badge
key={tool}
tone="secondary"
className="text-xs py-0 px-1.5"
>
{tool}
</Badge>
))}
</div>
)}
{!isEditing && (
<div className="flex items-center gap-2">
<div
className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed
? "bg-background text-foreground select-all"
: "bg-muted/30 text-muted-foreground"
}`}
>
{info.is_set ? displayValue : "---"}
</div>
{info.is_set && (
<Button
ghost
size="icon"
onClick={() => onReveal(varKey)}
title={isRevealed ? t.env.hideValue : t.env.showValue}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{info.is_set ? t.common.replace : t.common.set}
</Button>
{info.is_set && (
<Button
size="sm"
outlined
destructive
prefix={<Trash2 />}
onClick={() => onClear(varKey)}
disabled={saving === varKey || clearDialogOpen}
>
{saving === varKey ? "..." : t.common.clear}
</Button>
)}
</div>
)}
{isEditing && (
<div className="flex items-center gap-2">
<Input
autoFocus
type="text"
value={edits[varKey]}
onChange={(e) =>
setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
}
placeholder={
info.is_set
? t.env.replaceCurrentValue.replace(
"{preview}",
info.redacted_value ?? "---",
)
: t.env.enterValue
}
className="flex-1 font-mono-ui text-xs"
/>
<Button
size="sm"
onClick={() => onSave(varKey)}
prefix={<Save />}
disabled={saving === varKey || !edits[varKey]}
>
{saving === varKey ? "..." : t.common.save}
</Button>
<Button
size="sm"
outlined
prefix={<X />}
onClick={() => onCancelEdit(varKey)}
>
{t.common.cancel}
</Button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* ProviderGroupCard — groups API key + base URL per provider */
/* ------------------------------------------------------------------ */
function ProviderGroupCard({
group,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
group: ProviderGroup;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const { t } = useI18n();
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(
([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
);
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
const other = group.entries.filter(
([k]) =>
!k.endsWith("_API_KEY") &&
!k.endsWith("_TOKEN") &&
!k.endsWith("_BASE_URL"),
);
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
const configuredCount = group.entries.filter(
([, info]) => info.is_set,
).length;
// Get a representative URL for "Get key" link
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
return (
<div className="border border-border">
{/* Header — always visible */}
<ListItem
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="justify-between gap-3 px-4 py-3 hover:bg-primary/5"
>
<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" />
)}
<span className="font-semibold text-sm tracking-wide">
{group.name === "Other" ? t.common.other : group.name}
</span>
{hasAnyConfigured && (
<Badge tone="success" className="text-xs">
{configuredCount} {t.common.set.toLowerCase()}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{keyUrl && (
<a
href={keyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-xs text-text-tertiary">
{t.env.keysCount
.replace("{count}", String(group.entries.length))
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
</span>
</div>
</ListItem>
{expanded && (
<div className="border-t border-border px-4 py-3 grid gap-2">
{apiKeys.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{baseUrls.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{other.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function EnvPage() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle } = usePageHeader();
useEffect(() => {
api
.getEnvVars()
.then(setVars)
.catch(() => {});
}, []);
// Scroll-to sub-nav in the page header
const sections = useMemo(() => {
const items: { id: string; label: string }[] = [
{ id: "section-oauth", label: "OAuth" },
{ id: "section-providers", label: "Providers" },
];
if (vars) {
const categories = ["tool", "messaging", "setting"];
const CATEGORY_LABELS: Record<string, string> = {
tool: "Tools",
messaging: "Messaging",
setting: "Settings",
};
for (const cat of categories) {
const hasEntries = Object.values(vars).some(
(info) => info.category === cat,
);
if (hasEntries) {
items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
}
}
}
return items;
}, [vars]);
useLayoutEffect(() => {
if (!vars) {
setAfterTitle(null);
return;
}
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
setAfterTitle(
<nav
className="flex shrink-0 flex-nowrap items-center gap-1"
aria-label="Jump to section"
>
{sections.map((s) => (
<button
key={s.id}
type="button"
onClick={() => scrollTo(s.id)}
className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
>
{s.label}
</button>
))}
</nav>,
);
return () => {
setAfterTitle(null);
};
}, [vars, sections, setAfterTitle]);
const handleSave = async (key: string) => {
const value = edits[key];
if (!value) return;
setSaving(key);
try {
await api.setEnvVar(key, value);
setVars((prev) =>
prev
? {
...prev,
[key]: {
...prev[key],
is_set: true,
redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
},
}
: prev,
);
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
} catch (e) {
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const keyClear = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? {
...prev,
[key]: { ...prev[key], is_set: false, redacted_value: null },
}
: prev,
);
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
throw e;
} finally {
setSaving(null);
}
},
[showToast, t.common.removed, t.common.failedToRemove],
),
});
const handleReveal = async (key: string) => {
if (revealed[key]) {
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
return;
}
try {
const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch {
showToast(`${t.common.failedToReveal} ${key}`, "error");
}
};
const cancelEdit = (key: string) => {
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
};
/* ---- Build provider groups ---- */
const { providerGroups, nonProviderGrouped } = useMemo(() => {
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
const providerEntries = Object.entries(vars).filter(
([, info]) =>
info.category === "provider" && (showAdvanced || !info.advanced),
);
// Group by provider
const groupMap = new Map<string, [string, EnvVarInfo][]>();
for (const entry of providerEntries) {
const groupName = getProviderGroup(entry[0]);
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
groupMap.get(groupName)!.push(entry);
}
const groups: ProviderGroup[] = Array.from(groupMap.entries())
.map(([name, entries]) => ({
name,
priority: getProviderPriority(name),
entries,
hasAnySet: entries.some(([, info]) => info.is_set),
}))
.sort((a, b) => a.priority - b.priority);
// 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 nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter(
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
);
const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set);
return {
label: CATEGORY_META_LABELS[cat] ?? cat,
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
category: cat,
setEntries,
unsetEntries,
totalEntries: entries.length,
};
});
return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced, t]);
if (!vars) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
const pendingClearKey = keyClear.pendingId;
const pendingKeyDescription =
pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="env:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={keyClear.isOpen}
onCancel={keyClear.cancel}
onConfirm={keyClear.confirm}
title={t.env.confirmClearTitle}
description={
pendingClearKey
? `${pendingClearKey}${pendingKeyDescription ? `${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
: t.env.confirmClearMessage
}
loading={keyClear.isDeleting}
/>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
{t.env.description} <code>~/.hermes/.env</code>
</p>
<p className="text-xs text-text-tertiary">
{t.env.changesNote}
</p>
</div>
<Button
size="sm"
outlined
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
</Button>
</div>
<div id="section-oauth">
<OAuthProvidersCard
onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")}
/>
</div>
<Card id="section-providers">
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
</div>
<CardDescription>
{t.env.providersConfigured
.replace("{configured}", String(configuredProviders))
.replace("{total}", String(totalProviders))}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-0 p-0">
{providerGroups.map((group) => (
<ProviderGroupCard
key={group.name}
group={group}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
</CardContent>
</Card>
{nonProviderGrouped.map((section) => {
if (section.totalEntries === 0) return null;
return (
<EnvCategoryCard
key={section.category}
section={section}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
);
})}
<PluginSlot name="env:bottom" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* EnvCategoryCard — keys / messaging / settings sections */
/* ------------------------------------------------------------------ */
function EnvCategoryCard({
section,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
section: {
category: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
setEntries: [string, EnvVarInfo][];
totalEntries: number;
unsetEntries: [string, EnvVarInfo][];
};
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const noneConfigured = section.setEntries.length === 0;
const [showAll, setShowAll] = useState(noneConfigured);
const { t } = useI18n();
const Icon = section.icon;
const hasContent = section.setEntries.length > 0 || showAll;
const rowProps = {
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen,
};
return (
<Card id={`section-${section.category}`}>
<CardHeader
className={`bg-card${hasContent ? " border-b border-border" : ""}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-base">{section.label}</CardTitle>
</div>
{section.unsetEntries.length > 0 && (
<button
type="button"
onClick={() => setShowAll((open) => !open)}
aria-expanded={showAll}
className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
>
{showAll ? t.env.showLess : t.env.showMore}
</button>
)}
</div>
<CardDescription>
{section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
{t.common.configured}
</CardDescription>
</CardHeader>
{hasContent && (
<CardContent className="grid gap-3 overflow-hidden pt-4">
{section.setEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
{showAll &&
section.unsetEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
</CardContent>
)}
</Card>
);
}
+246
View File
@@ -0,0 +1,246 @@
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
const LINE_COUNTS = [50, 100, 200, 500] as const;
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
const upper = line.toUpperCase();
if (
upper.includes("ERROR") ||
upper.includes("CRITICAL") ||
upper.includes("FATAL")
)
return "error";
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
if (upper.includes("DEBUG")) return "debug";
return "info";
}
const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-text-tertiary",
};
const formatFilterLabel = (value: string) => value.toUpperCase();
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
const filterGroupClass =
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
const segmentedClass =
"w-fit max-w-full flex-wrap justify-start self-start";
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
const [component, setComponent] =
useState<(typeof COMPONENTS)[number]>("all");
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const fetchLogs = useCallback(() => {
setLoading(true);
setError(null);
api
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, 50);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
{formatFilterLabel(component)}
</Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={fetchLogs}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
{autoRefresh && (
<Badge tone="success" className="text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
autoRefresh,
component,
file,
level,
loading,
setAfterTitle,
setEnd,
t.common.live,
t.common.refresh,
t.logs.autoRefresh,
fetchLogs,
]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-4">
<PluginSlot name="logs:top" />
<div
role="toolbar"
aria-label={t.logs.title}
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
>
<FilterGroup label={t.logs.file} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={file}
onChange={setFile}
options={toSegmentOptions(FILES)}
/>
</FilterGroup>
<FilterGroup label={t.logs.level} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={level}
onChange={setLevel}
options={toSegmentOptions(LEVELS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.component} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={component}
onChange={setComponent}
options={toSegmentOptions(COMPONENTS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
}
options={LINE_COUNTS.map((n) => ({
value: String(n),
label: String(n),
}))}
/>
</FilterGroup>
</div>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
>
{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`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
<PluginSlot name="logs:bottom" />
</div>
);
}
+994
View File
@@ -0,0 +1,994 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import {
Brain,
ChevronDown,
Cpu,
DollarSign,
Eye,
RefreshCw,
Settings2,
Star,
Wrench,
X,
Zap,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
ModelsAnalyticsModelEntry,
ModelsAnalyticsResponse,
} from "@/lib/api";
import { timeAgo, cn, themedBody } from "@/lib/utils";
import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
// Must match _AUX_TASK_SLOTS in hermes_cli/web_server.py.
const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [
{ key: "vision", label: "Vision", hint: "Image analysis" },
{ key: "web_extract", label: "Web Extract", hint: "Page summarization" },
{ key: "compression", label: "Compression", hint: "Context compaction" },
{ key: "skills_hub", label: "Skills Hub", hint: "Skill search" },
{ key: "approval", label: "Approval", hint: "Smart auto-approve" },
{ key: "mcp", label: "MCP", hint: "MCP tool routing" },
{ key: "title_generation", label: "Title Gen", hint: "Session titles" },
{ key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" },
{ key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" },
{ key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" },
{ key: "curator", label: "Curator", hint: "Skill-usage review" },
] as const;
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 formatCost(n: number): string {
if (n >= 1) return `$${n.toFixed(2)}`;
if (n >= 0.01) return `$${n.toFixed(3)}`;
if (n > 0) return `$${n.toFixed(4)}`;
return "$0";
}
/** Short model name: strip vendor prefix like "openrouter/" or "anthropic/". */
function shortModelName(model: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(slashIdx + 1);
return model;
}
/** Extract vendor prefix from a model string like "anthropic/claude-opus-4.7" → "anthropic". */
function modelVendor(model: string, fallback?: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(0, slashIdx);
return fallback || "";
}
function TokenBar({
input,
output,
cacheRead,
reasoning,
}: {
input: number;
output: number;
cacheRead: number;
reasoning: number;
}) {
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
].filter((s) => s.value > 0);
return (
<div className="space-y-1.5">
{/* Stacked bar — segments fill proportionally to their share of total */}
<div className="relative flex min-h-[1.5rem] w-full items-stretch overflow-hidden">
{segments.map((s, i) => (
<div
key={i}
className={`${s.color} relative flex items-center transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
>
{/* Stepped fill pattern overlay */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"repeating-linear-gradient(to right, transparent 0 0.4rem, currentColor 0.4rem calc(0.4rem + 1px))",
}}
/>
</div>
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
{s.label} {formatTokens(s.value)}
</span>
))}
</div>
</div>
);
}
function CapabilityBadges({
capabilities,
}: {
capabilities: ModelsAnalyticsModelEntry["capabilities"];
}) {
const hasAny =
capabilities.supports_tools ||
capabilities.supports_vision ||
capabilities.supports_reasoning ||
capabilities.model_family;
if (!hasAny) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{capabilities.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{capabilities.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{capabilities.model_family && (
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-xs font-medium text-text-secondary">
{capabilities.model_family}
</span>
)}
</div>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Per-card "Use as" menu */
/* ──────────────────────────────────────────────────────────────────── */
function UseAsMenu({
provider,
model,
isMain,
mainAuxTask,
onAssigned,
}: {
provider: string;
model: string;
/** True when this card's model+provider match config.yaml's main slot. */
isMain: boolean;
/** If this model is assigned to a specific aux task, that task's key. */
mainAuxTask: string | null;
onAssigned(): void;
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const assign = async (
scope: "main" | "auxiliary",
task: string,
) => {
if (!provider || !model) {
setError("Missing provider/model");
return;
}
setBusy(true);
setError(null);
try {
await api.setModelAssignment({ scope, provider, model, task });
onAssigned();
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
// Close on outside click.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target && !target.closest?.("[data-use-as-menu]")) setOpen(false);
};
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [open]);
return (
<div className="relative" data-use-as-menu>
<Button
size="sm"
outlined
onClick={() => setOpen((v) => !v)}
disabled={busy}
className="h-6 px-2 text-xs uppercase"
prefix={busy ? <Spinner /> : null}
>
Use as <ChevronDown className="h-3 w-3" />
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg">
<button
type="button"
onClick={() => assign("main", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span className="flex items-center gap-2">
<Star className="h-3 w-3" />
Main model
</span>
{isMain && (
<span className="text-display text-xs tracking-wider text-primary">
current
</span>
)}
</button>
<div className="border-t border-border/50 px-3 py-1.5 text-display text-xs tracking-wider text-text-tertiary">
Auxiliary task
</div>
<button
type="button"
onClick={() => assign("auxiliary", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span>All auxiliary tasks</span>
</button>
{AUX_TASKS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => assign("auxiliary", t.key)}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
<span>{t.label}</span>
{mainAuxTask === t.key && (
<span className="text-display text-xs tracking-wider text-primary">
current
</span>
)}
</button>
))}
{error && (
<div className="px-3 py-2 text-xs text-destructive border-t border-border/50">
{error}
</div>
)}
</div>
)}
</div>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* ModelCard */
/* ──────────────────────────────────────────────────────────────────── */
function ModelCard({
entry,
rank,
main,
aux,
onAssigned,
showTokens,
}: {
entry: ModelsAnalyticsModelEntry;
rank: number;
main: { provider: string; model: string } | null;
aux: AuxiliaryTaskAssignment[];
onAssigned(): void;
showTokens: boolean;
}) {
const { t } = useI18n();
const provider = entry.provider || modelVendor(entry.model);
const totalTokens = entry.input_tokens + entry.output_tokens;
const caps = entry.capabilities;
const isMain =
!!main &&
main.provider === provider &&
main.model === entry.model;
// First aux task currently using this model (if any).
const mainAuxTask =
aux.find(
(a) => a.provider === provider && a.model === entry.model,
)?.task ?? null;
return (
<Card
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-text-tertiary text-xs font-mono">
#{rank}
</span>
<CardTitle className="text-sm font-mono-ui truncate">
{shortModelName(entry.model)}
</CardTitle>
{isMain && (
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-primary">
<Star className="h-2.5 w-2.5" /> main
</span>
)}
{mainAuxTask && (
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-purple-600 dark:text-purple-400">
aux · {mainAuxTask}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
{provider && (
<Badge tone="secondary" className="text-xs">
{provider}
</Badge>
)}
{caps.context_window && caps.context_window > 0 && (
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.context_window)} ctx
</span>
)}
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.max_output_tokens)} out
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{showTokens ? (
<div className="text-right">
<div className="text-xs font-mono font-semibold">
{formatTokens(totalTokens)}
</div>
<div className="text-xs text-text-tertiary">
{t.models.tokens}
</div>
</div>
) : (
entry.sessions > 0 && (
<div className="text-right">
<div className="text-xs font-mono font-semibold">
{entry.sessions}
</div>
<div className="text-xs text-text-tertiary">
{t.models.sessions}
</div>
</div>
)
)}
<UseAsMenu
provider={provider}
model={entry.model}
isMain={isMain}
mainAuxTask={mainAuxTask}
onAssigned={onAssigned}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
{showTokens && (
<>
<TokenBar
input={entry.input_tokens}
output={entry.output_tokens}
cacheRead={entry.cache_read_tokens}
reasoning={entry.reasoning_tokens}
/>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<div className="font-mono font-semibold">{entry.sessions}</div>
<div className="text-xs text-text-tertiary">
{t.models.sessions}
</div>
</div>
<div className="text-center">
<div className="font-mono font-semibold">
{formatTokens(entry.avg_tokens_per_session)}
</div>
<div className="text-xs text-text-tertiary">
{t.models.avgPerSession}
</div>
</div>
<div className="text-center">
<div className="font-mono font-semibold">
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
</div>
<div className="text-xs text-text-tertiary">
{t.models.apiCalls}
</div>
</div>
</div>
</>
)}
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border/30 pt-2">
<div className="flex items-center gap-3">
{showTokens && entry.estimated_cost > 0 && (
<span className="flex items-center gap-0.5">
<DollarSign className="h-2.5 w-2.5" />
{formatCost(entry.estimated_cost)}
</span>
)}
{showTokens && entry.tool_calls > 0 && (
<span className="flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5" />
{entry.tool_calls} {t.models.toolCalls}
</span>
)}
</div>
{entry.last_used_at > 0 && (
<span>{timeAgo(entry.last_used_at)}</span>
)}
</div>
<CapabilityBadges capabilities={entry.capabilities} />
</CardContent>
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Model Settings panel (top of page) */
/* ──────────────────────────────────────────────────────────────────── */
type PickerTarget =
| { kind: "main" }
| { kind: "aux"; task: string };
function AuxiliaryTasksModal({
aux,
refreshKey,
onSaved,
onClose,
}: {
aux: AuxiliaryModelsResponse | null;
refreshKey: number;
onSaved(): void;
onClose(): void;
}) {
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [resetBusy, setResetBusy] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const modalRef = useModalBehavior({ open: true, onClose });
const resetAllAux = async () => {
setConfirmReset(false);
setResetBusy(true);
try {
await api.setModelAssignment({
scope: "auxiliary",
task: "__reset__",
provider: "",
model: "",
});
onSaved();
} finally {
setResetBusy(false);
}
};
return (
<div
ref={modalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="aux-modal-title"
>
<div className={cn(themedBody, "relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={onClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<div className="flex items-center justify-between gap-3 pr-8">
<h2
id="aux-modal-title"
className="font-mondwest text-display text-base tracking-wider"
>
Auxiliary Tasks
</h2>
<Button
size="sm"
outlined
onClick={() => setConfirmReset(true)}
disabled={resetBusy}
className="h-6 text-xs uppercase"
prefix={resetBusy ? <Spinner /> : null}
>
Reset all to auto
</Button>
</div>
<p className="text-xs text-text-secondary mt-2">
Auxiliary tasks handle side-jobs like vision, session search, and
compression. <span className="font-mono">auto</span> means
&quot;use the main model&quot;. Override per-task when you want a
cheap/fast model for a specific job.
</p>
</header>
<div className="flex-1 overflow-y-auto p-5 space-y-1">
{AUX_TASKS.map((t) => {
const cur = aux?.tasks.find((a) => a.task === t.key);
const isAuto =
!cur || cur.provider === "auto" || !cur.provider;
return (
<div
key={t.key}
className="flex items-center justify-between gap-3 px-3 py-2 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium">{t.label}</span>
<span className="text-xs text-text-tertiary">
{t.hint}
</span>
</div>
<div className="text-xs font-mono text-text-secondary truncate">
{isAuto
? "auto (use main model)"
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
</div>
</div>
<Button
size="sm"
outlined
onClick={() => setPicker({ kind: "aux", task: t.key })}
className="h-6 text-xs uppercase"
>
Change
</Button>
</div>
);
})}
</div>
{picker && picker.kind === "aux" && (
<ModelPickerDialog
key={`picker-${refreshKey}`}
loader={api.getModelOptions}
alwaysGlobal
title={`Set Auxiliary: ${
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
picker.task
}`}
onApply={async ({ provider, model }) => {
await api.setModelAssignment({
scope: "auxiliary",
task: picker.task,
provider,
model,
});
onSaved();
}}
onClose={() => setPicker(null)}
/>
)}
<ConfirmDialog
open={confirmReset}
onCancel={() => setConfirmReset(false)}
onConfirm={() => void resetAllAux()}
title="Reset auxiliary models"
description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set."
destructive
confirmLabel="Reset all"
loading={resetBusy}
/>
</div>
</div>
);
}
function ModelSettingsPanel({
aux,
refreshKey,
onSaved,
}: {
aux: AuxiliaryModelsResponse | null;
refreshKey: number;
onSaved(): void;
}) {
const [auxModalOpen, setAuxModalOpen] = useState(false);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const mainProv = aux?.main.provider ?? "";
const mainModel = aux?.main.model ?? "";
const applyAssignment = async ({
scope,
task,
provider,
model,
}: {
scope: "main" | "auxiliary";
task: string;
provider: string;
model: string;
}) => {
await api.setModelAssignment({ scope, task, provider, model });
onSaved();
};
// Count how many aux tasks have overrides
const auxOverrideCount = aux?.tasks.filter(
(a) => a.provider && a.provider !== "auto",
).length ?? 0;
return (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0 pb-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle>
<span className="max-w-full min-w-0 text-xs text-text-secondary [overflow-wrap:anywhere]">
applies to new sessions
</span>
</div>
</CardHeader>
<CardContent className="min-w-0 space-y-3 pt-3">
{/* Main row */}
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Star className="h-3 w-3 text-primary" />
<span className="text-display text-xs font-medium tracking-wider">
Main model
</span>
</div>
<div className="text-xs font-mono text-text-secondary truncate">
{mainProv || "(unset)"}
{mainProv && mainModel && " · "}
{mainModel || "(unset)"}
</div>
</div>
<Button
size="sm"
onClick={() => setPicker({ kind: "main" })}
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Change
</Button>
</div>
{/* Auxiliary tasks summary + open modal */}
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Cpu className="h-3 w-3 text-text-tertiary" />
<span className="text-display text-xs font-medium tracking-wider">
Auxiliary tasks
</span>
</div>
<div className="text-xs font-mono text-text-secondary truncate">
{auxOverrideCount > 0
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
: `${AUX_TASKS.length} tasks · all auto`}
</div>
</div>
<Button
size="sm"
outlined
onClick={() => setAuxModalOpen(true)}
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Configure
</Button>
</div>
{picker && (
<ModelPickerDialog
key={`picker-${refreshKey}`}
loader={api.getModelOptions}
alwaysGlobal
title="Set Main Model"
onApply={async ({ provider, model }) => {
await applyAssignment({
scope: "main",
task: "",
provider,
model,
});
}}
onClose={() => setPicker(null)}
/>
)}
{auxModalOpen && (
<AuxiliaryTasksModal
aux={aux}
refreshKey={refreshKey}
onSaved={onSaved}
onClose={() => setAuxModalOpen(false)}
/>
)}
</CardContent>
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Page */
/* ──────────────────────────────────────────────────────────────────── */
export default function ModelsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<ModelsAnalyticsResponse | null>(null);
const [aux, setAux] = useState<AuxiliaryModelsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveKey, setSaveKey] = useState(0);
// Gate the token/cost UI on `dashboard.show_token_analytics`. See
// hermes_cli/config.py for the rationale: the numbers exclude auxiliary
// calls and retries, so they're misleading next to provider billing.
const [showTokens, setShowTokens] = useState(false);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
setShowTokens(dash.show_token_analytics === true);
})
.catch(() => {
// Default to hidden on any failure — safer than showing wrong numbers.
setShowTokens(false);
});
}, []);
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
api.getModelsAnalytics(days),
api.getAuxiliaryModels().catch(() => null),
])
.then(([models, auxData]) => {
setData(models);
setAux(auxData);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
const onAssigned = useCallback(() => {
// Reload aux state after any assignment change.
api
.getAuxiliaryModels()
.then(setAux)
.catch(() => {});
setSaveKey((k) => k + 1);
}, []);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-6">
<PluginSlot name="models:top" />
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
<ModelSettingsPanel
aux={aux}
refreshKey={saveKey}
onSaved={onAssigned}
/>
{data && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="min-w-0 py-6">
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
<Stats
className="min-w-0"
items={
showTokens
? [
{
label: t.models.modelsUsed,
value: String(data.totals.distinct_models),
},
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.models.estimatedCost,
value: formatCost(data.totals.total_estimated_cost),
},
{
label: t.analytics.totalSessions,
value: String(data.totals.total_sessions),
},
]
: [
{
label: t.models.modelsUsed,
value: String(data.totals.distinct_models),
},
{
label: t.analytics.totalSessions,
value: String(data.totals.total_sessions),
},
]
}
/>
</div>
{!showTokens && (
<p className="mt-4 text-xs text-text-tertiary leading-relaxed">
Token & cost analytics are hidden because the local counts
exclude auxiliary calls (compression, vision, web extract,
) and provider retries, so they diverge from your provider
bill. Enable{" "}
<span className="font-mono">dashboard.show_token_analytics</span>{" "}
in <a href="/config" className="underline">Config</a> to
show the local debug estimate anyway.
</p>
)}
</CardContent>
</Card>
)}
</div>
{loading && !data && (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
)}
{error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{data && (
<>
{data.models.length > 0 ? (
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.models.map((m, i) => (
<ModelCard
key={`${m.model}:${m.provider}`}
entry={m}
rank={i + 1}
main={aux?.main ?? null}
aux={aux?.tasks ?? []}
onAssigned={onAssigned}
showTokens={showTokens}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<Cpu className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.models.noModelsData}</p>
<p className="text-xs mt-1 text-text-tertiary">
{t.models.startSession}
</p>
</div>
</CardContent>
</Card>
)}
</>
)}
<PluginSlot name="models:bottom" />
</div>
);
}
+580
View File
@@ -0,0 +1,580 @@
import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { cn } from "@/lib/utils";
import { usePageHeader } from "@/contexts/usePageHeader";
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
const [installBusy, setInstallBusy] = useState(false);
const [rescanBusy, setRescanBusy] = useState(false);
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
const [contextSel, setContextSel] = useState("compressor");
const [providerBusy, setProviderBusy] = useState(false);
const [rowBusy, setRowBusy] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle } = usePageHeader();
const loadHub = useCallback(() => {
return api
.getPluginsHub()
.then((h) => {
setHub(h);
const p = h.providers;
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
setContextSel(p.context_engine || "compressor");
})
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
useEffect(() => {
setLoading(true);
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
useEffect(() => {
setAfterTitle(
<Button
ghost
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
aria-label={t.pluginsPage.refreshDashboard}
>
{rescanBusy ? <Spinner /> : <RefreshCw />}
</Button>,
);
return () => setAfterTitle(null);
}, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
const onInstall = async () => {
const id = installId.trim();
if (!id) {
showToast(t.pluginsPage.installHint, "error");
return;
}
setInstallBusy(true);
try {
const r = await api.installAgentPlugin({
identifier: id,
force: installForce,
enable: installEnable,
});
showToast(`${r.plugin_name ?? id} installed`, "success");
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
setInstallId("");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setInstallBusy(false);
}
};
const onRescan = async () => {
setRescanBusy(true);
try {
const rc = await api.rescanPlugins();
showToast(
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
"success",
);
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
} finally {
setRescanBusy(false);
}
};
const onSaveProviders = async () => {
setProviderBusy(true);
try {
await api.savePluginProviders({
memory_provider:
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
context_engine: contextSel,
});
showToast(t.pluginsPage.savedProviders, "success");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Save failed", "error");
} finally {
setProviderBusy(false);
}
};
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
setRowBusy(name);
try {
await fn();
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed", "error");
} finally {
setRowBusy(null);
}
};
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
return (
<div className="flex flex-col gap-4">
<PluginSlot name="plugins:top" />
<div className={cn("flex w-full flex-col gap-8")}>
{providers && (
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.providersHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
<div className="grid gap-2 min-w-0">
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
<Select
id="mem-provider"
className="w-full"
value={memorySel}
onValueChange={setMemorySel}
>
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
{`(${t.pluginsPage.providerDefaults})`}
</SelectOption>
{providers.memory_options.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2 min-w-0">
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
<Select
id="ctx-engine"
className="w-full"
value={contextSel}
onValueChange={setContextSel}
>
<SelectOption value="compressor">compressor</SelectOption>
{providers.context_options
.filter((o) => o.name !== "compressor")
.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
</div>
<Button
className="w-fit uppercase"
size="sm"
disabled={providerBusy}
onClick={() => void onSaveProviders()}
prefix={providerBusy ? <Spinner /> : undefined}
>
{t.common.save}
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.installHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input
className="font-mono-ui lowercase"
id="install-url"
placeholder="owner/repo or https://..."
spellCheck={false}
value={installId}
onChange={(e) => setInstallId(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-8">
<div className="flex items-center gap-3">
<Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.forceReinstall}
</span>
</div>
<div className="flex items-center gap-3">
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.enableAfterInstall}
</span>
</div>
</div>
<Button
className="w-fit uppercase"
size="sm"
disabled={installBusy}
onClick={() => void onInstall()}
prefix={installBusy ? <Spinner /> : undefined}
>
{t.pluginsPage.installBtn}
</Button>
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.rescanHint}
</p>
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.removeHint}
</p>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.pluginListHeading}
</h3>
{loading ? (
<div className="flex items-center gap-2 py-8 text-xs text-text-tertiary">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : rows.length === 0 ? (
<p className="text-xs text-text-tertiary">{t.common.noResults}</p>
) : (
<ul className="flex flex-col gap-3">
{rows.map((row: HubAgentPluginRow) => (
<li key={row.name}>
<PluginRowCard
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
/>
</li>
))}
</ul>
)}
</div>
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.orphanHeading}
</h3>
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
{hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-xs text-text-secondary" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path}
{!m.tab?.hidden ? (
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
<ExternalLink className="h-3 w-3 opacity-65" />
{t.pluginsPage.openTab}
</Link>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
</div>
);
}
interface PluginRowCardProps {
row: HubAgentPluginRow;
rowBusy: string | null;
setRuntimeLoading: (
name: string,
fn: () => Promise<unknown>,
) => Promise<void>;
showToast: (msg: string, variant: "success" | "error") => void;
t: Translations;
}
function PluginRowCard(props: PluginRowCardProps) {
const {
row,
rowBusy,
setRuntimeLoading,
showToast,
t,
} = props;
const dm = row.dashboard_manifest;
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
const busy = rowBusy === row.name;
const [confirmRemove, setConfirmRemove] = useState(false);
const badgeTone =
row.runtime_status === "enabled"
? "success"
: row.runtime_status === "disabled"
? "destructive"
: "outline";
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-4 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<span className="truncate font-semibold">{row.name}</span>
<Badge tone="outline">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<Badge tone="outline">v{row.version || "—"}</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{row.runtime_status === "enabled" ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
) : (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
)}
{tabPath ? (
<Link
className={cn(
"inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10",
"font-mondwest text-display text-xs tracking-[0.1em]",
)}
to={tabPath}
>
{t.pluginsPage.openTab}
</Link>
) : null}
{row.can_update_git ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.updateAgentPlugin(row.name);
showToast(t.pluginsPage.updateGit, "success");
});
}}
>
{busy ? <Spinner /> : null}
{t.pluginsPage.updateGit}
</Button>
) : null}
{row.has_dashboard_manifest ? (
<Button
disabled={busy}
ghost
size="sm"
title={row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.setPluginVisibility(row.name, !row.user_hidden);
});
}}
>
{row.user_hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
</Button>
) : null}
{row.can_remove ? (
<Button
destructive
disabled={busy}
ghost
size="sm"
onClick={() => setConfirmRemove(true)}
>
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
) : null}
</div>
</div>
{row.description ? (
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
{row.description}
</p>
) : null}
{dm?.slots?.length ? (
<p className="text-xs tracking-[0.05em] text-text-tertiary">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p>
) : null}
{row.auth_required ? (
<CommandBlock
label={t.pluginsPage.authRequiredHint}
code={row.auth_command}
/>
) : null}
{!row.has_dashboard_manifest && !dm ? (
<p className="text-xs italic text-text-disabled">
{t.pluginsPage.noDashboardTab}
</p>
) : null}
</CardContent>
<ConfirmDialog
open={confirmRemove}
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
void setRuntimeLoading(row.name, async () => {
await api.removeAgentPlugin(row.name);
showToast(`${row.name} removed`, "success");
});
}}
title={t.pluginsPage.removeConfirm}
description={`This will remove the "${row.name}" plugin from your agent.`}
destructive
confirmLabel={t.common.delete}
/>
</Card>
);
}
+559
View File
@@ -0,0 +1,559 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
ChevronDown,
Pencil,
Terminal,
Trash2,
Users,
X,
} from "lucide-react";
import spinners from "unicode-animations";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
function ProfilesLoadingSpinner() {
const { frames, interval } = spinners.braille;
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
const id = window.setInterval(
() => setFrameIndex((i) => (i + 1) % frames.length),
interval,
);
return () => window.clearInterval(id);
}, [frames.length, interval]);
return (
<span
aria-hidden
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
>
{frames[frameIndex]}
</span>
);
}
export default function ProfilesPage() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newName, setNewName] = useState("");
const [cloneFromDefault, setCloneFromDefault] = useState(true);
const [creating, setCreating] = useState(false);
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
// Inline rename state
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
const [renameTo, setRenameTo] = useState("");
// Inline SOUL editor state
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null);
const [soulText, setSoulText] = useState("");
const [soulSaving, setSoulSaving] = useState(false);
// Tracks the latest SOUL request so out-of-order responses don't overwrite
// newer state when the user switches profiles or closes the editor.
const activeSoulRequest = useRef<string | null>(null);
const load = useCallback(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch((e) => showToast(`${t.status.error}: ${e}`, "error"))
.finally(() => setLoading(false));
}, [showToast, t.status.error]);
useEffect(() => {
load();
}, [load]);
const handleCreate = async () => {
const name = newName.trim();
if (!name) {
showToast(t.profiles.nameRequired, "error");
return;
}
if (!PROFILE_NAME_RE.test(name)) {
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
return;
}
setCreating(true);
try {
await api.createProfile({ name, clone_from_default: cloneFromDefault });
showToast(`${t.profiles.created}: ${name}`, "success");
setNewName("");
setCreateModalOpen(false);
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handleRenameSubmit = async () => {
if (!renamingFrom) return;
const target = renameTo.trim();
if (!target || target === renamingFrom) {
setRenamingFrom(null);
setRenameTo("");
return;
}
if (!PROFILE_NAME_RE.test(target)) {
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
return;
}
try {
await api.renameProfile(renamingFrom, target);
showToast(
`${t.profiles.renamed}: ${renamingFrom}${target}`,
"success",
);
setRenamingFrom(null);
setRenameTo("");
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const openSoulEditor = useCallback(
async (name: string) => {
if (editingSoulFor === name) {
activeSoulRequest.current = null;
setEditingSoulFor(null);
return;
}
setEditingSoulFor(name);
setSoulText("");
activeSoulRequest.current = name;
try {
const soul = await api.getProfileSoul(name);
if (activeSoulRequest.current === name) {
setSoulText(soul.content);
}
} catch (e) {
if (activeSoulRequest.current === name) {
showToast(`${t.status.error}: ${e}`, "error");
}
}
},
[editingSoulFor, showToast, t.status.error],
);
const handleSaveSoul = async (name: string) => {
setSoulSaving(true);
try {
await api.updateProfileSoul(name, soulText);
showToast(`${t.profiles.soulSaved}: ${name}`, "success");
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
} finally {
setSoulSaving(false);
}
};
const handleCopyTerminalCommand = async (name: string) => {
let cmd: string;
try {
const res = await api.getProfileSetupCommand(name);
cmd = res.command;
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
return;
}
try {
await navigator.clipboard.writeText(cmd);
showToast(`${t.profiles.commandCopied}: ${cmd}`, "success");
} catch {
showToast(`${t.profiles.copyFailed}: ${cmd}`, "error");
}
};
const profileDelete = useConfirmDelete<string>({
onDelete: useCallback(
async (name: string) => {
try {
await api.deleteProfile(name);
showToast(`${t.profiles.deleted}: ${name}`, "success");
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[load, showToast, t.profiles.deleted, t.status.error],
),
});
const pendingName = profileDelete.pendingId;
// Put "Create" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading]);
if (loading) {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex items-center justify-center py-24"
>
<span className="sr-only">{t.common.loading}</span>
<ProfilesLoadingSpinner />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={profileDelete.isOpen}
onCancel={profileDelete.cancel}
onConfirm={profileDelete.confirm}
title={t.profiles.confirmDeleteTitle}
description={
pendingName
? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
: t.profiles.confirmDeleteMessage
}
loading={profileDelete.isDeleting}
/>
{/* Create profile modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) =>
e.target === e.currentTarget && setCreateModalOpen(false)
}
role="dialog"
aria-modal="true"
aria-labelledby="create-profile-title"
>
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setCreateModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-profile-title"
className="font-mondwest text-display text-base tracking-wider"
>
{t.profiles.newProfile}
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="profile-name">{t.profiles.name}</Label>
<Input
id="profile-name"
autoFocus
placeholder={t.profiles.namePlaceholder}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
aria-invalid={
newName.trim() !== "" &&
!PROFILE_NAME_RE.test(newName.trim())
}
/>
<p className="text-xs text-muted-foreground">
{t.profiles.nameRule}
</p>
</div>
<div className="flex items-center gap-2.5">
<Checkbox
checked={cloneFromDefault}
id="clone-from-default"
onCheckedChange={(checked) =>
setCloneFromDefault(checked === true)
}
/>
<Label
className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
htmlFor="clone-from-default"
>
{t.profiles.cloneFromDefault}
</Label>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</div>
)}
{/* List */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
{t.profiles.allProfiles} ({profiles.length})
</H2>
{profiles.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.profiles.noProfiles}
</CardContent>
</Card>
)}
{profiles.map((p) => {
const isRenaming = renamingFrom === p.name;
const isEditingSoul = editingSoulFor === p.name;
return (
<Card key={p.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{isRenaming ? (
<Input
autoFocus
value={renameTo}
onChange={(e) => setRenameTo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") setRenamingFrom(null);
}}
aria-invalid={
renameTo.trim() !== "" &&
renameTo.trim() !== p.name &&
!PROFILE_NAME_RE.test(renameTo.trim())
}
className="max-w-xs"
/>
) : (
<span className="font-medium text-sm truncate">
{p.name}
</span>
)}
{p.is_default && (
<Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
)}
{p.has_env && (
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
)}
</div>
{isRenaming &&
(() => {
const trimmed = renameTo.trim();
const invalid =
trimmed !== "" &&
trimmed !== p.name &&
!PROFILE_NAME_RE.test(trimmed);
return (
<p
className={
"text-xs mb-1 " +
(invalid
? "text-destructive"
: "text-muted-foreground")
}
>
{invalid
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
: t.profiles.nameRule}
</p>
);
})()}
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
{p.model && (
<span>
{t.profiles.model}: {p.model}
{p.provider ? ` (${p.provider})` : ""}
</span>
)}
<span>
{t.profiles.skills}: {p.skill_count}
</span>
<span className="font-mono truncate max-w-[28rem]">
{p.path}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{isRenaming ? (
<>
<Button size="sm" onClick={handleRenameSubmit}>
{t.common.save}
</Button>
<Button
size="sm"
ghost
onClick={() => setRenamingFrom(null)}
>
{t.common.cancel}
</Button>
</>
) : (
<>
<Button
ghost
size="icon"
title={t.profiles.editSoul}
aria-label={t.profiles.editSoul}
onClick={() => openSoulEditor(p.name)}
>
{isEditingSoul ? (
<ChevronDown className="h-4 w-4" />
) : (
<span aria-hidden className="text-xs font-bold">
S
</span>
)}
</Button>
<Button
ghost
size="icon"
title={t.profiles.openInTerminal}
aria-label={t.profiles.openInTerminal}
onClick={() => handleCopyTerminalCommand(p.name)}
>
<Terminal className="h-4 w-4" />
</Button>
{!p.is_default && (
<Button
ghost
size="icon"
title={t.profiles.rename}
aria-label={t.profiles.rename}
onClick={() => {
setRenamingFrom(p.name);
setRenameTo(p.name);
}}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{!p.is_default && (
<Button
ghost
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => profileDelete.requestDelete(p.name)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</>
)}
</div>
</CardContent>
{isEditingSoul && (
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
<Label
htmlFor={`soul-editor-${p.name}`}
className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
>
{t.profiles.soulSection}
</Label>
<textarea
id={`soul-editor-${p.name}`}
className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={t.profiles.soulPlaceholder}
value={soulText}
onChange={(e) => setSoulText(e.target.value)}
/>
<div>
<Button
size="sm"
className="uppercase"
onClick={() => handleSaveSoul(p.name)}
disabled={soulSaving}
>
{soulSaving ? t.common.saving : t.common.save}
</Button>
</div>
</div>
)}
</Card>
);
})}
</div>
</div>
);
}
+936
View File
@@ -0,0 +1,936 @@
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
Database,
MessageSquare,
Search,
Trash2,
Clock,
Terminal,
Globe,
MessageCircle,
Hash,
X,
Play,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Input } from "@nous-research/ui/ui/components/input";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{
cli: { icon: Terminal, color: "text-primary" },
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
whatsapp: { icon: Globe, color: "text-success" },
cron: { icon: Clock, color: "text-warning" },
};
/** Render an FTS5 snippet with highlighted matches.
* The backend wraps matches in >>> and <<< delimiters. */
function SnippetHighlight({ snippet }: { snippet: string }) {
const parts: React.ReactNode[] = [];
const regex = />>>(.*?)<<</g;
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = regex.exec(snippet)) !== null) {
if (match.index > last) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
{match[1]}
</mark>,
);
last = regex.lastIndex;
}
if (last < snippet.length) {
parts.push(snippet.slice(last));
}
return (
<p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
{parts}
</p>
);
}
function ToolCallBlock({
toolCall,
}: {
toolCall: { id: string; function: { name: string; arguments: string } };
}) {
const [open, setOpen] = useState(false);
const { t } = useI18n();
let args = toolCall.function.arguments;
try {
args = JSON.stringify(JSON.parse(args), null, 2);
} catch {
// keep as-is
}
return (
<div className="mt-2 border border-warning/20 bg-warning/5">
<ListItem
onClick={() => setOpen(!open)}
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
aria-expanded={open}
className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning"
>
{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="text-warning/50 ml-auto">{toolCall.id}</span>
</ListItem>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
</pre>
)}
</div>
);
}
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 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
const isHit = (() => {
if (!highlight || !msg.content) return false;
const content = msg.content.toLowerCase();
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
return terms.some((term) => content.includes(term));
})();
// Split search query into terms for inline highlighting
const highlightTerms =
isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined;
return (
<div
className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`}
data-search-hit={isHit || undefined}
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge tone="warning" className="text-xs py-0 px-1.5">
{t.common.match}
</Badge>
)}
{msg.timestamp && (
<span className="text-xs text-text-tertiary">
{timeAgo(msg.timestamp)}
</span>
)}
</div>
{msg.content &&
(msg.role === "system" ? (
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{msg.content}
</div>
) : (
<Markdown content={msg.content} highlightTerms={highlightTerms} />
))}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-1">
{msg.tool_calls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
/** Message list with auto-scroll to first search hit. */
function MessageList({
messages,
highlight,
}: {
messages: SessionMessage[];
highlight?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!highlight || !containerRef.current) return;
// Scroll to first hit after render
const timer = setTimeout(() => {
const hit = containerRef.current?.querySelector("[data-search-hit]");
if (hit) {
hit.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
return () => clearTimeout(timer);
}, [messages, highlight]);
return (
<div
ref={containerRef}
className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2"
>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} highlight={highlight} />
))}
</div>
);
}
function SessionRow({
session,
snippet,
searchQuery,
isExpanded,
onToggle,
onDelete,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const navigate = useNavigate();
useEffect(() => {
if (isExpanded && messages === null && !loading) {
setLoading(true);
api
.getSessionMessages(session.id)
.then((resp) => setMessages(resp.messages))
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}
}, [isExpanded, session.id, messages, loading]);
const sourceInfo = (session.source
? SOURCE_CONFIG[session.source]
: null) ?? { icon: Globe, color: "text-muted-foreground" };
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</>
);
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{session.is_active && (
<Badge tone="success" className="shrink-0 text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()}
</span>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
{actionButtons}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:hidden">
{actionButtons}
</div>
</div>
</div>
{isExpanded && (
<div className="min-w-0 border-t border-border bg-background/50 p-4">
{loading && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{error && (
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t.sessions.noMessages}
</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
)}
</div>
)}
</div>
);
}
type SessionsView = "list" | "overview";
const PAGE_SIZE = 20;
function SessionsPagination({
className,
compact = false,
onPageChange,
page,
total,
}: SessionsPaginationProps) {
const { t } = useI18n();
const pageCount = Math.ceil(total / PAGE_SIZE);
return (
<div
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
>
{!compact && (
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
{t.common.of} {total}
</span>
)}
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="px-2 text-xs text-muted-foreground">
{t.common.page} {page + 1} {t.common.of} {pageCount}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => onPageChange(page + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<
SessionSearchResult[] | null
>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
return;
}
setAfterTitle(
<Badge tone="secondary" className="text-xs tabular-nums">
{total}
</Badge>,
);
return () => {
setAfterTitle(null);
};
}, [loading, setAfterTitle, total]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
api
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
.then((resp) => {
setSessions(resp.sessions);
setTotal(resp.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
useEffect(() => {
const loadOverview = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((r) => setOverviewSessions(r.sessions))
.catch(() => {});
};
loadOverview();
const id = setInterval(loadOverview, 5000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
setSearchResults(null);
setSearching(false);
return;
}
setSearching(true);
debounceRef.current = setTimeout(() => {
api
.searchSessions(search.trim())
.then((resp) => setSearchResults(resp.results))
.catch(() => setSearchResults(null))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const sessionDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
showToast(t.sessions.sessionDeleted, "success");
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
}
},
[
expandedId,
showToast,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
if (searchResults) {
for (const r of searchResults) {
snippetMap.set(r.session_id, r.snippet);
}
}
// When searching, filter sessions to those with FTS matches;
// when not searching, show all sessions
const filtered = searchResults
? sessions.filter((s) => snippetMap.has(s.id))
: sessions;
const platformEntries = status
? Object.entries(status.gateway_platforms ?? {})
: [];
const recentSessions = overviewSessions
.filter((s) => !s.is_active)
.slice(0, 5);
const isSearching = Boolean(search.trim());
const showOverviewTab =
platformEntries.length > 0 || recentSessions.length > 0;
const showList = view === "list" || isSearching || !showOverviewTab;
const showPagination = showList && !searchResults && total > PAGE_SIZE;
useEffect(() => {
if (isSearching) setView("list");
}, [isSearching]);
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
alerts.push({
message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatformEntries = platformEntries.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatformEntries) {
const stateLabel =
info.state === "fatal"
? t.status.platformError
: t.status.platformDisconnected;
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined,
});
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
<PluginSlot name="sessions:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={sessionDelete.isOpen}
onCancel={sessionDelete.cancel}
onConfirm={sessionDelete.confirm}
title={t.sessions.confirmDeleteTitle}
description={
pendingSession?.title && pendingSession.title !== "Untitled"
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
: t.sessions.confirmDeleteMessage
}
loading={sessionDelete.isDeleting}
/>
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Spinner className="shrink-0 text-[0.875rem] text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
tone={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-xs shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<Button
ghost
size="icon"
onClick={dismissLog}
className="shrink-0 text-text-secondary hover:text-foreground"
aria-label={t.common.close}
>
<X />
</Button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
{(showOverviewTab && !isSearching) || showList ? (
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
{showOverviewTab && !isSearching && (
<Segmented
className="w-fit shrink-0"
size="md"
value={view}
onChange={setView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
]}
/>
)}
{showList && (
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
{searching ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>
)}
</div>
{showPagination && (
<SessionsPagination
compact
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={setPage}
/>
)}
</div>
) : null}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-text-tertiary">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>
{showPagination && (
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
/>
)}
</>
)
) : (
<div className="flex min-w-0 flex-col gap-4">
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-mondwest normal-case min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-xs sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
<PluginSlot name="sessions:bottom" />
</div>
);
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
onPageChange: (page: number) => void;
page: number;
total: number;
}
+557
View File
@@ -0,0 +1,557 @@
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
X,
Cpu,
Globe,
Shield,
Eye,
Paintbrush,
Brain,
Blocks,
Code,
Zap,
Filter,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { cn } from "@/lib/utils";
import { Input } from "@nous-research/ui/ui/components/input";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Types & helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
"mlops/evaluation": "MLOps / Evaluation",
"mlops/inference": "MLOps / Inference",
"mlops/models": "MLOps / Models",
"mlops/training": "MLOps / Training",
"mlops/vector-databases": "MLOps / Vector DBs",
mcp: "MCP",
"red-teaming": "Red Teaming",
ocr: "OCR",
p5js: "p5.js",
ai: "AI",
ux: "UX",
ui: "UI",
};
function prettyCategory(
raw: string | null | undefined,
generalLabel: string,
): string {
if (!raw) return generalLabel;
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw
.split(/[-_/]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
const TOOLSET_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
computer: Cpu,
web: Globe,
security: Shield,
vision: Eye,
design: Paintbrush,
ai: Brain,
integration: Blocks,
code: Code,
automation: Zap,
};
function toolsetIcon(
name: string,
): React.ComponentType<{ className?: string }> {
const lower = name.toLowerCase();
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
if (lower.includes(key)) return icon;
}
return Wrench;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, tsets]) => {
setSkills(s);
setToolsets(tsets);
})
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, []);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
),
);
showToast(
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
"success",
);
} catch {
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
} finally {
setTogglingSkills((prev) => {
const next = new Set(prev);
next.delete(skill.name);
return next;
});
}
};
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
const searchMatchedSkills = useMemo(() => {
if (!isSearching) return [];
return skills.filter(
(s) =>
s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch),
);
}, [skills, isSearching, lowerSearch]);
const activeSkills = useMemo(() => {
if (isSearching) return [];
if (!activeCategory)
return [...skills].sort((a, b) => a.name.localeCompare(b.name));
return skills
.filter((s) =>
activeCategory === "__none__"
? !s.category
: s.category === activeCategory,
)
.sort((a, b) => a.name.localeCompare(b.name));
}, [skills, activeCategory, isSearching]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
const key = s.category || "__none__";
cats.set(key, (cats.get(key) || 0) + 1);
}
return [...cats.entries()]
.sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
})
.map(([key, count]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
count,
}));
}, [skills, t]);
const enabledCount = skills.filter((s) => s.enabled).length;
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 rounded-none pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(ts) =>
!search ||
ts.name.toLowerCase().includes(lowerSearch) ||
ts.label.toLowerCase().includes(lowerSearch) ||
ts.description.toLowerCase().includes(lowerSearch),
);
}, [toolsets, search, lowerSearch]);
/* ---- Loading ---- */
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex flex-col gap-4">
<PluginSlot name="skills:top" />
<Toast toast={toast} />
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">
<div className="flex flex-col rounded-none border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.skills.filters}
</span>
</div>
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
<PanelItem
icon={Package}
label={`${t.skills.all} (${skills.length})`}
active={view === "skills" && !isSearching}
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
/>
<PanelItem
icon={Wrench}
label={`${t.skills.toolsets} (${toolsets.length})`}
active={view === "toolsets"}
onClick={() => {
setView("toolsets");
setSearch("");
}}
/>
</div>
{view === "skills" &&
!isSearching &&
allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
{allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<ListItem
key={key}
active={isActive}
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className="rounded-none px-2 py-1 text-xs"
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-xs tabular-nums ${
isActive
? "text-text-secondary"
: "text-text-tertiary"
}`}
>
{count}
</span>
</ListItem>
);
})}
</div>
</div>
)}
</div>
</div>
</aside>
<div className="flex-1 min-w-0">
{isSearching ? (
<Card className="rounded-none">
<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" />
{t.skills.title}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{t.skills.resultCount
.replace("{count}", String(searchMatchedSkills.length))
.replace(
"{s}",
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">
{t.skills.noSkillsMatch}
</p>
) : (
<div className="grid gap-1">
{searchMatchedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* Skills list */
<Card className="rounded-none">
<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" />
{activeCategory
? prettyCategory(
activeCategory === "__none__" ? null : activeCategory,
t.common.general,
)
: t.skills.all}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", 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
? t.skills.noSkills
: t.skills.noSkillsMatch}
</p>
) : (
<div className="grid gap-1">
{activeSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : (
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
<Card className="rounded-none">
<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) => {
const TsIcon = toolsetIcon(ts.name);
const labelText =
ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() ||
ts.name;
return (
<Card key={ts.name} className="relative rounded-none">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<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
tone={ts.enabled ? "success" : "outline"}
className="text-xs"
>
{ts.enabled
? t.common.active
: t.common.inactive}
</Badge>
</div>
<p className="text-xs text-text-secondary mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-xs text-amber-300 mb-2">
{t.skills.setupNeeded}
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
tone="secondary"
className="text-xs font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-xs text-text-tertiary">
{ts.enabled
? t.skills.toolsetLabel.replace(
"{name}",
ts.name,
)
: t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
<PluginSlot name="skills:bottom" />
</div>
);
}
function SkillRow({
skill,
toggling,
onToggle,
noDescriptionLabel,
}: SkillRowProps) {
return (
<div 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={onToggle}
disabled={toggling}
/>
</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 || noDescriptionLabel}
</p>
</div>
</div>
);
}
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<ListItem
active={active}
onClick={onClick}
className={cn(
"rounded-none whitespace-nowrap px-2.5 py-1.5",
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
active && "bg-foreground/90 text-background hover:text-background",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</ListItem>
);
}
interface PanelItemProps {
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
skill: SkillInfo;
toggling: boolean;
}
+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from "react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
getPluginComponent,
getPluginLoadError,
onPluginRegistered,
} from "./registry";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
import type { Translations } from "@/i18n/types";
/** Renders a plugin tab once its bundle has called `register()`. */
export function PluginPage({ name }: { name: string }) {
const { t } = useI18n();
// Subscribe in render (via useSyncExternalStore) so we never miss
// `register()` if the script loads before a useEffect would run.
const Component = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginComponent(name) ?? null,
() => null,
);
const loadError = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginLoadError(name) ?? null,
() => null,
);
if (Component) {
return <Component />;
}
if (loadError) {
const message = formatPluginError(loadError, t);
return (
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-text-secondary",
)}
role="alert"
>
{message}
</div>
);
}
return (
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
)}
>
<Spinner className="shrink-0" />
<span>{t.common.loading}</span>
</div>
);
}
function formatPluginError(code: string, t: Translations): string {
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
return code;
}
+6
View File
@@ -0,0 +1,6 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { PluginPage } from "./PluginPage";
export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots";
export type { PluginManifest, RegisteredPlugin } from "./types";
+151
View File
@@ -0,0 +1,151 @@
/**
* Dashboard Plugin SDK + Registry
*
* Exposes React, UI components, hooks, and utilities on the window so
* that plugin bundles can use them without bundling their own copies.
*
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
* to register their tab component.
*/
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
} from "react";
import { api, fetchJSON } from "@/lib/api";
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Card, CardHeader, CardTitle, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Separator } from "@nous-research/ui/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
import { useI18n } from "@/i18n";
import { registerSlot, PluginSlot } from "./slots";
// ---------------------------------------------------------------------------
// Plugin registry — plugins call register() to add their component.
// ---------------------------------------------------------------------------
type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map();
const _loadErrors: Map<string, string> = new Map();
const _listeners: Set<RegistryListener> = new Set();
function _notify() {
for (const fn of _listeners) {
try { fn(); } catch { /* ignore */ }
}
}
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
export function notifyPluginRegistry() {
_notify();
}
/** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) {
_loadErrors.delete(name);
_registered.set(name, component);
_notify();
}
/** Get a registered component by plugin name. */
export function getPluginComponent(name: string): React.ComponentType | undefined {
return _registered.get(name);
}
export function getPluginLoadError(name: string): string | undefined {
return _loadErrors.get(name);
}
export function setPluginLoadError(name: string, message: string) {
_loadErrors.set(name, message);
_notify();
}
/** Subscribe to registry changes (returns unsubscribe fn). */
export function onPluginRegistered(fn: RegistryListener): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
/** Get current count of registered plugins. */
export function getRegisteredCount(): number {
return _registered.size;
}
// ---------------------------------------------------------------------------
// Expose SDK + registry on window
// ---------------------------------------------------------------------------
declare global {
interface Window {
__HERMES_PLUGIN_SDK__: unknown;
__HERMES_PLUGINS__: {
register: typeof registerPlugin;
registerSlot: typeof registerSlot;
};
}
}
export function exposePluginSDK() {
window.__HERMES_PLUGINS__ = {
register: registerPlugin,
registerSlot,
};
window.__HERMES_PLUGIN_SDK__ = {
// React core — plugins use these instead of importing react
React,
hooks: {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
},
// Hermes API client
api,
// Raw fetchJSON for plugin-specific endpoints
fetchJSON,
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
components: {
Card,
CardHeader,
CardTitle,
CardContent,
Badge,
Button,
Checkbox,
Input,
Label,
Select,
SelectOption,
Separator,
Tabs,
TabsList,
TabsTrigger,
PluginSlot,
},
// Utilities
utils: { cn, timeAgo, isoTimeAgo },
// Hooks
useI18n,
};
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Plugin slot registry.
*
* Plugins can inject components into named locations in the app shell
* (header-left, sidebar, backdrop, etc.) by calling
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
* from their JS bundle. Multiple plugins can populate the same slot — they
* render stacked in registration order.
*
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
* registry accepts any string so plugin ecosystems can define their own
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
* it knows about.
*/
import React, { Fragment, useEffect, useState } from "react";
/** Slot locations the built-in shell renders. Plugins declaring any of
* these in their manifest's `slots` field get wired in automatically.
*
* Shell-wide slots:
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
* - `header-left` — injected before the Hermes brand in the top bar
* - `header-right` — injected before the theme/language switchers
* - `header-banner` — injected below the top nav bar, full-width
* - `sidebar` — the cockpit sidebar rail (only rendered when
* `layoutVariant === "cockpit"`)
* - `pre-main` — rendered above the route outlet (inside `<main>`)
* - `post-main` — rendered below the route outlet (inside `<main>`)
* - `footer-left` — replaces the left footer cell content
* - `footer-right` — replaces the right footer cell content
* - `overlay` — fixed-position layer above everything else;
* useful for chrome (scanlines, vignettes) the
* theme's customCSS can't achieve alone
*
* Page-scoped slots (rendered inside a specific built-in page — use these
* to inject widgets, cards, or toolbars into existing pages without
* overriding the whole route):
* - `sessions:top` — top of /sessions page (above session list)
* - `sessions:bottom` — bottom of /sessions page
* - `analytics:top` — top of /analytics page
* - `analytics:bottom` — bottom of /analytics page
* - `logs:top` — top of /logs page (above filter toolbar)
* - `logs:bottom` — bottom of /logs page (below log viewer)
* - `cron:top` — top of /cron page
* - `cron:bottom` — bottom of /cron page
* - `skills:top` — top of /skills page
* - `skills:bottom` — bottom of /skills page
* - `plugins:top` — top of /plugins page
* - `plugins:bottom` — bottom of /plugins page
* - `config:top` — top of /config page
* - `config:bottom` — bottom of /config page
* - `env:top` — top of /env (Keys) page
* - `env:bottom` — bottom of /env (Keys) page
* - `docs:top` — top of /docs page (above the docs iframe)
* - `docs:bottom` — bottom of /docs page
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
* - `chat:bottom` — bottom of /chat page
*/
export const KNOWN_SLOT_NAMES = [
// Shell-wide
"backdrop",
"header-left",
"header-right",
"header-banner",
"sidebar",
"pre-main",
"post-main",
"footer-left",
"footer-right",
"overlay",
// Page-scoped
"sessions:top",
"sessions:bottom",
"analytics:top",
"analytics:bottom",
"logs:top",
"logs:bottom",
"cron:top",
"cron:bottom",
"skills:top",
"skills:bottom",
"plugins:top",
"plugins:bottom",
"config:top",
"config:bottom",
"env:top",
"env:bottom",
"docs:top",
"docs:bottom",
"chat:top",
"chat:bottom",
] as const;
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
type SlotListener = () => void;
interface SlotEntry {
plugin: string;
component: React.ComponentType;
}
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
const _slotListeners: Set<SlotListener> = new Set();
function _notifySlots() {
for (const fn of _slotListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Register a component for a slot. Called by plugin bundles via
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
*
* If the same (plugin, slot) pair is registered twice, the later call
* replaces the earlier one — this matches how React HMR expects plugin
* re-mounts to behave. */
export function registerSlot(
plugin: string,
slot: string,
component: React.ComponentType,
): void {
const existing = _slotRegistry.get(slot) ?? [];
const filtered = existing.filter((e) => e.plugin !== plugin);
filtered.push({ plugin, component });
_slotRegistry.set(slot, filtered);
_notifySlots();
}
/** Read current entries for a slot. Returns a copy so callers can't mutate
* registry state. */
export function getSlotEntries(slot: string): SlotEntry[] {
return (_slotRegistry.get(slot) ?? []).slice();
}
/** Subscribe to registry changes. Returns an unsubscribe function. */
export function onSlotRegistered(fn: SlotListener): () => void {
_slotListeners.add(fn);
return () => {
_slotListeners.delete(fn);
};
}
/** Clear a specific plugin's slot registrations. Useful for HMR /
* plugin reload flows — not wired in by default. */
export function unregisterPluginSlots(plugin: string): void {
let changed = false;
for (const [slot, entries] of _slotRegistry.entries()) {
const kept = entries.filter((e) => e.plugin !== plugin);
if (kept.length !== entries.length) {
changed = true;
if (kept.length === 0) _slotRegistry.delete(slot);
else _slotRegistry.set(slot, kept);
}
}
if (changed) _notifySlots();
}
interface PluginSlotProps {
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
name: string;
/** Optional content rendered when no plugins have claimed the slot.
* Useful for built-in defaults the plugin would replace. */
fallback?: React.ReactNode;
}
/** Render all components registered for a given slot, stacked in order.
*
* Component re-renders when the slot registry changes so plugins that
* arrive after initial mount show up without a manual refresh. */
export function PluginSlot({ name, fallback }: PluginSlotProps) {
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
useEffect(() => {
// Pick up anything registered between the initial `useState` call
// and the first effect tick, then subscribe for future changes.
setEntries(getSlotEntries(name));
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
return unsub;
}, [name]);
if (entries.length === 0) {
return fallback ? React.createElement(Fragment, null, fallback) : null;
}
return React.createElement(
Fragment,
null,
...entries.map((entry) =>
React.createElement(entry.component, { key: entry.plugin }),
),
);
}
+37
View File
@@ -0,0 +1,37 @@
/** Types for the dashboard plugin system. */
import type { ComponentType } from "react";
export interface PluginManifest {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
position?: string;
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
override?: string;
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
hidden?: boolean;
};
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
/**
* Optional Subresource Integrity hash (e.g. "sha384-..."). When set,
* the browser will refuse to execute the plugin bundle if its hash
* does not match. This protects against tampered plugin delivery.
*/
integrity?: string;
source: string;
}
export interface RegisteredPlugin {
manifest: PluginManifest;
component: ComponentType;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* usePlugins hook — discovers and loads dashboard plugins.
*
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
* 2. Injects CSS <link> tags for plugins that declare css
* 3. Loads plugin JS bundles via <script> tags
* 4. Waits for plugins to call register() and resolves them
*/
import { useState, useEffect, useRef } from "react";
import { api, HERMES_BASE_PATH } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types";
import {
getPluginComponent,
onPluginRegistered,
notifyPluginRegistry,
setPluginLoadError,
} from "./registry";
export function usePlugins() {
const [manifests, setManifests] = useState<PluginManifest[]>([]);
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
const [loading, setLoading] = useState(true);
const loadedScripts = useRef<Set<string>>(new Set());
// Fetch manifests on mount.
useEffect(() => {
api
.getPlugins()
.then((list) => {
setManifests(list);
if (list.length === 0) setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load plugin assets when manifests arrive.
useEffect(() => {
if (manifests.length === 0) return;
const injectedScripts: HTMLScriptElement[] = [];
for (const manifest of manifests) {
// Inject CSS if specified.
if (manifest.css) {
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = cssUrl;
document.head.appendChild(link);
}
}
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
// in-memory registry while the browser would otherwise never
// re-execute a previously cached <script> URL.
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script");
script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true;
// SRI integrity verification — defense against compromised plugin
// delivery. Plugin manifests can declare an integrity hash
// (e.g. "sha384-...") which the browser verifies before executing.
// Without this, a man-in-the-middle or compromised plugin server
// can substitute the JS bundle silently. Opt-in: when no integrity
// is declared in the manifest, behavior is unchanged.
if (manifest.integrity && typeof manifest.integrity === "string") {
script.integrity = manifest.integrity;
script.crossOrigin = "anonymous";
}
script.onerror = () => {
setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
};
document.body.appendChild(script);
injectedScripts.push(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [manifests]);
// Listen for plugin registrations and resolve them against manifests.
useEffect(() => {
function resolvePlugins() {
const resolved: RegisteredPlugin[] = [];
for (const manifest of manifests) {
const component = getPluginComponent(manifest.name);
if (component) {
resolved.push({ manifest, component });
}
}
setPlugins(resolved);
// If all plugins registered, stop loading early.
if (resolved.length === manifests.length && manifests.length > 0) {
setLoading(false);
}
}
resolvePlugins();
const unsub = onPluginRegistered(resolvePlugins);
return unsub;
}, [manifests]);
return { plugins, manifests, loading };
}
+437
View File
@@ -0,0 +1,437 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { BUILTIN_THEMES, defaultTheme } from "./presets";
import type {
DashboardTheme,
ThemeAssets,
ThemeColorOverrides,
ThemeComponentStyles,
ThemeDensity,
ThemeLayer,
ThemeLayout,
ThemeLayoutVariant,
ThemeListEntry,
ThemePalette,
ThemeTypography,
} from "./types";
import { api } from "@/lib/api";
/** LocalStorage key — pre-applied before the React tree mounts to avoid
* a visible flash of the default palette on theme-overridden installs. */
const STORAGE_KEY = "hermes-dashboard-theme";
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
// ---------------------------------------------------------------------------
// CSS variable builders
// ---------------------------------------------------------------------------
/** Turn a ThemeLayer into the two CSS expressions the DS consumes:
* `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
function layerVars(
name: "background" | "midground" | "foreground",
layer: ThemeLayer,
): Record<string, string> {
const pct = Math.round(layer.alpha * 100);
return {
[`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`,
[`--${name}-base`]: layer.hex,
[`--${name}-alpha`]: String(layer.alpha),
};
}
function paletteVars(palette: ThemePalette): Record<string, string> {
return {
...layerVars("background", palette.background),
...layerVars("midground", palette.midground),
...layerVars("foreground", palette.foreground),
"--warm-glow": palette.warmGlow,
"--noise-opacity-mul": String(palette.noiseOpacity),
};
}
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
compact: "0.85",
comfortable: "1",
spacious: "1.2",
};
function typographyVars(typo: ThemeTypography): Record<string, string> {
return {
"--theme-font-sans": typo.fontSans,
"--theme-font-mono": typo.fontMono,
"--theme-font-display": typo.fontDisplay ?? typo.fontSans,
"--theme-base-size": typo.baseSize,
"--theme-line-height": typo.lineHeight,
"--theme-letter-spacing": typo.letterSpacing,
};
}
function layoutVars(layout: ThemeLayout): Record<string, string> {
return {
"--radius": layout.radius,
"--theme-radius": layout.radius,
"--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1",
"--theme-density": layout.density,
};
}
/** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */
const OVERRIDE_KEY_TO_VAR: Record<keyof ThemeColorOverrides, string> = {
card: "--color-card",
cardForeground: "--color-card-foreground",
popover: "--color-popover",
popoverForeground: "--color-popover-foreground",
primary: "--color-primary",
primaryForeground: "--color-primary-foreground",
secondary: "--color-secondary",
secondaryForeground: "--color-secondary-foreground",
muted: "--color-muted",
mutedForeground: "--color-muted-foreground",
accent: "--color-accent",
accentForeground: "--color-accent-foreground",
destructive: "--color-destructive",
destructiveForeground: "--color-destructive-foreground",
success: "--color-success",
warning: "--color-warning",
border: "--color-border",
input: "--color-input",
ring: "--color-ring",
};
/** Keys we might have written on a previous theme — needed to know which
* properties to clear when a theme with fewer overrides replaces one
* with more. */
const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR);
function overrideVars(
overrides: ThemeColorOverrides | undefined,
): Record<string, string> {
if (!overrides) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(overrides)) {
if (!value) continue;
const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
/** Well-known named asset slots a theme may populate. Kept in sync with
* `_THEME_NAMED_ASSET_KEYS` in `hermes_cli/web_server.py`. */
const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const;
/** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`.
* Each bucket emits `--component-<bucket>-<kebab-prop>` CSS vars. */
const COMPONENT_BUCKETS = [
"card", "header", "footer", "sidebar", "tab",
"progress", "badge", "backdrop", "page",
] as const;
/** Camel → kebab (`clipPath` → `clip-path`). */
function toKebab(s: string): string {
return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
/** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped
* in `url(...)` when they look like a bare path/URL; raw CSS expressions
* (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */
function assetVars(assets: ThemeAssets | undefined): Record<string, string> {
if (!assets) return {};
const out: Record<string, string> = {};
const wrap = (v: string): string => {
const trimmed = v.trim();
if (!trimmed) return "";
// Already a CSS image/gradient/url/none — don't re-wrap.
if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) {
return trimmed;
}
// Bare path / http(s) URL / data: URL → wrap in url().
return `url("${trimmed.replace(/"/g, '\\"')}")`;
};
for (const key of NAMED_ASSET_KEYS) {
const val = assets[key];
if (typeof val === "string" && val.trim()) {
out[`--theme-asset-${key}`] = wrap(val);
out[`--theme-asset-${key}-raw`] = val;
}
}
if (assets.custom) {
for (const [key, val] of Object.entries(assets.custom)) {
if (typeof val !== "string" || !val.trim()) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue;
out[`--theme-asset-custom-${key}`] = wrap(val);
out[`--theme-asset-custom-${key}-raw`] = val;
}
}
return out;
}
/** Build `--component-<bucket>-<prop>` CSS vars from the componentStyles
* block. Values pass through untouched so themes can use any CSS expression. */
function componentStyleVars(
styles: ThemeComponentStyles | undefined,
): Record<string, string> {
if (!styles) return {};
const out: Record<string, string> = {};
for (const bucket of COMPONENT_BUCKETS) {
const props = (styles as Record<string, Record<string, string> | undefined>)[bucket];
if (!props) continue;
for (const [prop, value] of Object.entries(props)) {
if (typeof value !== "string" || !value.trim()) continue;
// Same guardrail as backend — camelCase or kebab-case alnum only.
if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue;
out[`--component-${bucket}-${toKebab(prop)}`] = value;
}
}
return out;
}
// Tracks keys we set on the previous theme so we can clear them when the
// next theme has fewer assets / component vars. Without this, switching
// from a richly-decorated theme to a plain one would leave stale vars.
let _PREV_DYNAMIC_VAR_KEYS: Set<string> = new Set();
/** ID for the injected <style> tag that carries a theme's customCSS.
* A single tag is reused + replaced on every theme switch. */
const CUSTOM_CSS_STYLE_ID = "hermes-theme-custom-css";
function applyCustomCSS(css: string | undefined) {
if (typeof document === "undefined") return;
let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!css || !css.trim()) {
if (el) el.remove();
return;
}
if (!el) {
el = document.createElement("style");
el.id = CUSTOM_CSS_STYLE_ID;
el.setAttribute("data-hermes-theme-css", "true");
document.head.appendChild(el);
}
el.textContent = css;
}
function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) {
if (typeof document === "undefined") return;
const root = document.documentElement;
const final: ThemeLayoutVariant = variant ?? "standard";
root.dataset.layoutVariant = final;
root.style.setProperty("--theme-layout-variant", final);
}
// ---------------------------------------------------------------------------
// Font stylesheet injection
// ---------------------------------------------------------------------------
function injectFontStylesheet(url: string | undefined) {
if (!url || typeof document === "undefined") return;
if (INJECTED_FONT_URLS.has(url)) return;
// Also skip if the page already has this href (e.g. SSR'd or persisted).
const existing = document.querySelector<HTMLLinkElement>(
`link[rel="stylesheet"][href="${CSS.escape(url)}"]`,
);
if (existing) {
INJECTED_FONT_URLS.add(url);
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
link.setAttribute("data-hermes-theme-font", "true");
document.head.appendChild(link);
INJECTED_FONT_URLS.add(url);
}
// ---------------------------------------------------------------------------
// Apply a full theme to :root
// ---------------------------------------------------------------------------
function applyTheme(theme: DashboardTheme) {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Clear any overrides from a previous theme before applying the new set.
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) {
root.style.removeProperty(prevKey);
}
const assetMap = assetVars(theme.assets);
const componentMap = componentStyleVars(theme.componentStyles);
_PREV_DYNAMIC_VAR_KEYS = new Set([
...Object.keys(assetMap),
...Object.keys(componentMap),
]);
const vars = {
...paletteVars(theme.palette),
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...assetMap,
...componentMap,
};
for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v);
}
injectFontStylesheet(theme.typography.fontUrl);
applyCustomCSS(theme.customCSS);
applyLayoutVariant(theme.layoutVariant);
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
});
/** All selectable themes (shown in the picker). Starts with just the
* built-ins; the API call below merges in user themes. */
const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
);
/** Full definitions for user themes keyed by name — the API provides
* these so custom YAMLs apply without a client-side stub. */
const [userThemeDefs, setUserThemeDefs] = useState<
Record<string, DashboardTheme>
>({});
// Resolve a theme name to a full DashboardTheme, falling back to default
// only when neither a built-in nor a user theme is found.
const resolveTheme = useCallback(
(name: string): DashboardTheme => {
return (
BUILTIN_THEMES[name] ??
userThemeDefs[name] ??
defaultTheme
);
},
[userThemeDefs],
);
// Re-apply on every themeName change, or when user themes arrive from
// the API (since the active theme might be a user theme whose definition
// hadn't loaded yet on first render).
useEffect(() => {
applyTheme(resolveTheme(themeName));
}, [themeName, resolveTheme]);
// Load server-side themes (built-ins + user YAMLs) once on mount.
useEffect(() => {
let cancelled = false;
api
.getThemes()
.then((resp) => {
if (cancelled) return;
if (resp.themes?.length) {
setAvailableThemes(
resp.themes.map((t) => ({
name: t.name,
label: t.label,
description: t.description,
definition: t.definition,
})),
);
// Index any definitions the server shipped (user themes).
const defs: Record<string, DashboardTheme> = {};
for (const entry of resp.themes) {
if (entry.definition) {
defs[entry.name] = entry.definition;
}
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setTheme = useCallback(
(name: string) => {
// Accept any name the server told us exists OR any built-in.
const knownNames = new Set<string>([
...Object.keys(BUILTIN_THEMES),
...availableThemes.map((t) => t.name),
...Object.keys(userThemeDefs),
]);
const next = knownNames.has(name) ? name : "default";
setThemeName(next);
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, next);
}
api.setTheme(next).catch(() => {});
},
[availableThemes, userThemeDefs],
);
const value = useMemo<ThemeContextValue>(
() => ({
theme: resolveTheme(themeName),
themeName,
availableThemes,
setTheme,
}),
[themeName, availableThemes, setTheme, resolveTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
const ThemeContext = createContext<ThemeContextValue>({
theme: defaultTheme,
themeName: "default",
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
setTheme: () => {},
});
interface ThemeContextValue {
availableThemes: ThemeListEntry[];
setTheme: (name: string) => void;
theme: DashboardTheme;
themeName: string;
}
+3
View File
@@ -0,0 +1,3 @@
export { ThemeProvider, useTheme } from "./context";
export { BUILTIN_THEMES, defaultTheme } from "./presets";
export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";
+215
View File
@@ -0,0 +1,215 @@
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
/**
* Built-in dashboard themes.
*
* Each theme defines its own palette, typography, and layout so switching
* themes produces visible changes beyond just color — fonts, density, and
* corner-radius all shift to match the theme's personality.
*
* Theme names must stay in sync with the backend's
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
*/
// ---------------------------------------------------------------------------
// Shared typography / layout presets
// ---------------------------------------------------------------------------
/** Default system stack — neutral, safe fallback for every platform. */
const SYSTEM_SANS =
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const SYSTEM_MONO =
'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace';
const DEFAULT_TYPOGRAPHY: ThemeTypography = {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
baseSize: "15px",
lineHeight: "1.55",
letterSpacing: "0",
};
const DEFAULT_LAYOUT: ThemeLayout = {
radius: "0.5rem",
density: "comfortable",
};
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
export const defaultTheme: DashboardTheme = {
name: "default",
label: "Hermes Teal",
description: "Classic dark teal — the canonical Hermes look",
palette: {
background: { hex: "#041c1c", alpha: 1 },
midground: { hex: "#ffe6cb", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(255, 189, 56, 0.35)",
noiseOpacity: 1,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
};
export const midnightTheme: DashboardTheme = {
name: "midnight",
label: "Midnight",
description: "Deep blue-violet with cool accents",
palette: {
background: { hex: "#0a0a1f", alpha: 1 },
midground: { hex: "#d4c8ff", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(167, 139, 250, 0.32)",
noiseOpacity: 0.8,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Inter", ${SYSTEM_SANS}`,
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap",
letterSpacing: "-0.005em",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0.75rem",
},
};
export const emberTheme: DashboardTheme = {
name: "ember",
label: "Ember",
description: "Warm crimson and bronze — forge vibes",
palette: {
background: { hex: "#1a0a06", alpha: 1 },
midground: { hex: "#ffd8b0", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(249, 115, 22, 0.38)",
noiseOpacity: 1,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Spectral", Georgia, "Times New Roman", serif`,
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0.25rem",
},
colorOverrides: {
destructive: "#c92d0f",
warning: "#f97316",
},
};
export const monoTheme: DashboardTheme = {
name: "mono",
label: "Mono",
description: "Clean grayscale — minimal and focused",
palette: {
background: { hex: "#0e0e0e", alpha: 1 },
midground: { hex: "#eaeaea", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(255, 255, 255, 0.1)",
noiseOpacity: 0.6,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"IBM Plex Sans", ${SYSTEM_SANS}`,
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0",
},
};
export const cyberpunkTheme: DashboardTheme = {
name: "cyberpunk",
label: "Cyberpunk",
description: "Neon green on black — matrix terminal",
palette: {
background: { hex: "#040608", alpha: 1 },
midground: { hex: "#9bffcf", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(0, 255, 136, 0.22)",
noiseOpacity: 1.2,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
fontMono: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=JetBrains+Mono:wght@400;700&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0",
},
colorOverrides: {
success: "#00ff88",
warning: "#ffd700",
destructive: "#ff0055",
},
};
export const roseTheme: DashboardTheme = {
name: "rose",
label: "Rosé",
description: "Soft pink and warm ivory — easy on the eyes",
palette: {
background: { hex: "#1a0f15", alpha: 1 },
midground: { hex: "#ffd4e1", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(249, 168, 212, 0.3)",
noiseOpacity: 0.9,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Fraunces", Georgia, serif`,
fontMono: `"DM Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Mono:wght@400;500&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "1rem",
},
};
/**
* Same look as ``defaultTheme`` but with a larger root font size, looser
* line-height, and ``spacious`` density so every rem-based size in the
* dashboard scales up. For users who find the default 15px UI too dense.
*/
export const defaultLargeTheme: DashboardTheme = {
name: "default-large",
label: "Hermes Teal (Large)",
description: "Hermes Teal with bigger fonts and roomier spacing",
palette: defaultTheme.palette,
typography: {
...DEFAULT_TYPOGRAPHY,
baseSize: "18px",
lineHeight: "1.65",
},
layout: {
...DEFAULT_LAYOUT,
density: "spacious",
},
};
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
"default-large": defaultLargeTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
cyberpunk: cyberpunkTheme,
rose: roseTheme,
};
+187
View File
@@ -0,0 +1,187 @@
/**
* Dashboard theme model.
*
* Themes customise three orthogonal layers:
*
* 1. `palette` — the 3-layer color triplet (background/midground/
* foreground) + warm-glow + noise opacity. The
* design-system cascade in `src/index.css` derives
* every shadcn-compat token (card, muted, border,
* primary, etc.) from this triplet via `color-mix()`.
* 2. `typography` — font families, base font size, line height,
* letter spacing. An optional `fontUrl` is injected
* as `<link rel="stylesheet">` so self-hosted and
* Google/Bunny/etc-hosted fonts both work.
* 3. `layout` — corner radius and density (spacing multiplier).
*
* Plus an optional `colorOverrides` escape hatch for themes that want to
* pin specific shadcn tokens to exact values (e.g. a pastel theme that
* needs a softer `destructive` red than the derived default).
*/
/** A color layer: hex base + alpha (01). */
export interface ThemeLayer {
alpha: number;
hex: string;
}
export interface ThemePalette {
/** Deepest canvas color (typically near-black). */
background: ThemeLayer;
/** Primary text + accent. Most UI chrome reads this. */
midground: ThemeLayer;
/** Top-layer highlight. In LENS_0 this is white @ alpha 0 — invisible by
* default but still drives `--color-ring`-style accents. */
foreground: ThemeLayer;
/** Warm vignette color for <Backdrop />, as an rgba() string. */
warmGlow: string;
/** Scalar multiplier (01.2) on the noise overlay. Lower for softer themes
* like Mono and Rosé, higher for grittier themes like Cyberpunk. */
noiseOpacity: number;
}
export interface ThemeTypography {
/** CSS font-family stack for sans-serif body copy. */
fontSans: string;
/** CSS font-family stack for monospace / code blocks. */
fontMono: string;
/** Optional display/heading font stack. Falls back to `fontSans`. */
fontDisplay?: string;
/** Optional external stylesheet URL (e.g. Google Fonts, Bunny Fonts,
* self-hosted .woff2 @font-face sheet). Injected as a <link> in <head>
* on theme switch. Same URL is never injected twice. */
fontUrl?: string;
/** Root font size (controls rem scale). Example: `"14px"`, `"16px"`. */
baseSize: string;
/** Default line-height. Example: `"1.5"`, `"1.65"`. */
lineHeight: string;
/** Default letter-spacing. Example: `"0"`, `"0.01em"`, `"-0.01em"`. */
letterSpacing: string;
}
export type ThemeDensity = "compact" | "comfortable" | "spacious";
export interface ThemeLayout {
/** Corner-radius token. Example: `"0"`, `"0.25rem"`, `"0.5rem"`,
* `"1rem"`. Maps to `--radius` and cascades into every component. */
radius: string;
/** Spacing multiplier. `compact` = 0.85, `comfortable` = 1.0 (default),
* `spacious` = 1.2. Applied via the `--spacing-mul` CSS var. */
density: ThemeDensity;
}
/** Overall layout variant the shell renders. `standard` = default single-
* column page layout. `cockpit` = reserves a left sidebar rail for a
* plugin slot (intended for HUD-style themes with persistent status panels).
* `tiled` = relaxes the main content max-width so pages can use the full
* viewport width. Themes set this; plugins react via CSS vars /
* `[data-layout-variant="..."]` selectors. */
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
/** Named hero/background assets a theme can populate. Each value is
* emitted as a CSS var (`--theme-asset-<name>`). The default shell
* consumes `bg` in `<Backdrop />` when present; other slots are
* plugin-facing — a cockpit sidebar plugin reads `--theme-asset-hero`
* to render its hero render without coupling to the theme name. */
export interface ThemeAssets {
/** Full-viewport background image URL, injected under the noise layer. */
bg?: string;
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
hero?: string;
/** Logo mark — header slot consumers use this. */
logo?: string;
/** Faction/brand crest — header-left decoration. */
crest?: string;
/** Secondary sidebar illustration. */
sidebar?: string;
/** Alternate header artwork. */
header?: string;
/** User-defined named assets. Keyed by [a-zA-Z0-9_-] only.
* Emitted as `--theme-asset-custom-<key>`. */
custom?: Record<string, string>;
}
/** Component-style override buckets. Each bucket's entries become CSS
* vars (`--component-<bucket>-<kebab-property>`) that shell components
* (Card, Backdrop, App header/footer, etc.) read. Values are plain CSS
* strings — we don't parse them, so themes can use `clip-path`,
* `border-image`, `background`, `box-shadow`, and anything else CSS
* accepts. */
export interface ThemeComponentStyles {
card?: Record<string, string>;
header?: Record<string, string>;
footer?: Record<string, string>;
sidebar?: Record<string, string>;
tab?: Record<string, string>;
progress?: Record<string, string>;
badge?: Record<string, string>;
backdrop?: Record<string, string>;
page?: Record<string, string>;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
card?: string;
cardForeground?: string;
popover?: string;
popoverForeground?: string;
primary?: string;
primaryForeground?: string;
secondary?: string;
secondaryForeground?: string;
muted?: string;
mutedForeground?: string;
accent?: string;
accentForeground?: string;
destructive?: string;
destructiveForeground?: string;
success?: string;
warning?: string;
border?: string;
input?: string;
ring?: string;
}
export interface DashboardTheme {
description: string;
label: string;
name: string;
palette: ThemePalette;
typography: ThemeTypography;
layout: ThemeLayout;
/** Overall shell layout. Defaults to `"standard"` when absent. */
layoutVariant?: ThemeLayoutVariant;
/** Named + custom asset URLs exposed as CSS vars on theme apply. */
assets?: ThemeAssets;
/** Raw CSS injected as a scoped `<style>` tag on theme apply, cleaned up
* on theme switch. Intended for selector-level chrome that's too
* expressive for componentStyles alone (e.g. `::before` pseudo-elements,
* complex animations, media queries). */
customCSS?: string;
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
}
/**
* Wire response shape for `GET /api/dashboard/themes`.
*
* The `themes` list is intentionally partial — built-in themes are fully
* defined in `presets.ts`; user themes carry their full definition so the
* client can apply them without a second round-trip.
*/
export interface ThemeListEntry {
description: string;
label: string;
name: string;
/** Full theme definition. Present for user-defined themes loaded from
* `~/.hermes/dashboard-themes/*.yaml`; undefined for built-ins (the
* client already has those in `BUILTIN_THEMES`). */
definition?: DashboardTheme;
}
export interface ThemeListResponse {
active: string;
themes: ThemeListEntry[];
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+108
View File
@@ -0,0 +1,108 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
/**
* In production the Python `hermes dashboard` server injects a one-shot
* session token into `index.html` (see `hermes_cli/web_server.py`). The
* Vite dev server serves its own `index.html`, so unless we forward that
* token, every protected `/api/*` call 401s.
*
* This plugin fetches the running dashboard's `index.html` on each dev page
* load, scrapes the `window.__HERMES_SESSION_TOKEN__` assignment, and
* re-injects it into the dev HTML. No-op in production builds.
*/
function hermesDevToken(): Plugin {
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
const EMBEDDED_RE =
/window\.__HERMES_DASHBOARD_EMBEDDED_CHAT__\s*=\s*(true|false)/;
const LEGACY_TUI_RE =
/window\.__HERMES_DASHBOARD_TUI__\s*=\s*(true|false)/;
return {
name: "hermes:dev-session-token",
apply: "serve",
async transformIndexHtml() {
try {
const res = await fetch(BACKEND, { headers: { accept: "text/html" } });
const html = await res.text();
const match = html.match(TOKEN_RE);
if (!match) {
console.warn(
`[hermes] Could not find session token in ${BACKEND}` +
`is \`hermes dashboard\` running? /api calls will 401.`,
);
return;
}
const embeddedMatch = html.match(EMBEDDED_RE);
const legacyMatch = html.match(LEGACY_TUI_RE);
const embeddedJs = embeddedMatch
? embeddedMatch[1]
: legacyMatch
? legacyMatch[1]
: "false";
return [
{
tag: "script",
injectTo: "head",
children:
`window.__HERMES_SESSION_TOKEN__="${match[1]}";` +
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};`,
},
];
} catch (err) {
console.warn(
`[hermes] Dashboard at ${BACKEND} unreachable — ` +
`start it with \`hermes dashboard\` or set HERMES_DASHBOARD_URL. ` +
`(${(err as Error).message})`,
);
}
},
};
}
export default defineConfig({
plugins: [react(), tailwindcss(), hermesDevToken()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
// When @nous-research/ui is symlinked via `file:../../design-language`,
// Node's module resolution would pick up shared deps from
// design-language/node_modules/*, giving us two copies + breaking
// hooks (useRef-of-null), webgl contexts, etc. Force everything that
// exists in BOTH places to use the dashboard's copy.
//
// Don't list packages here that only exist in the DS (nanostores,
// @nanostores/react) — Vite dedupe errors out when it can't find
// them at the project root.
dedupe: [
"react",
"react-dom",
"@react-three/fiber",
"@observablehq/plot",
"three",
"leva",
"gsap",
],
},
build: {
outDir: "../hermes_cli/web_dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": {
target: BACKEND,
ws: true,
},
// Same host as `hermes dashboard` must serve these; Vite has no
// dashboard-plugins/* files, so without this, plugin scripts 404
// or receive index.html in dev.
"/dashboard-plugins": BACKEND,
},
},
});