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
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:
+104
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+8887
File diff suppressed because it is too large
Load Diff
@@ -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.
Binary file not shown.
+1175
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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][];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 plugin’s script. Check the Network tab (dashboard-plugins/…) and the server’s plugin path.",
|
||||
pluginNotRegistered:
|
||||
"The plugin’s 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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { I18nProvider, useI18n, LOCALE_META } from "./context";
|
||||
export type { Locale, Translations } from "./types";
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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 5–10 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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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.yaml:memory.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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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.yaml:memory.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: ")",
|
||||
},
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 10x–100x 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>
|
||||
);
|
||||
}
|
||||
@@ -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 7–9px 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
"use the main model". 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="shrink-0">
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ThemeProvider, useTheme } from "./context";
|
||||
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 (0–1). */
|
||||
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 (0–1.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[];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user