Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Hermes Agent — Web UI
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
## Stack
- **Vite** + **React 19** + **TypeScript**
- **Tailwind CSS v4** with custom dark theme
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
## Development
```bash
# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm install
npm run dev
```
Open the **Vite URL** printed in the terminal (usually `http://localhost:5173`). That is the live-reload UI.
`hermes dashboard` on port 9119 serves the **built** bundle from `hermes_cli/web_dist/`, not the Vite dev server — changes in `web/src/` will not appear there until you run `npm run build` and restart the dashboard (or use `web --no-open` + Vite as above).
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build
```bash
npm run build
```
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
## Structure
```
src/
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
│ └── utils.ts # cn() helper for Tailwind class merging
├── pages/
│ ├── StatusPage # Agent status, active/recent sessions
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
│ └── EnvPage # API key management with save/clear
├── App.tsx # Main layout and navigation
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```
## Typography & contrast rules
Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.
### Text size floor
- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`.
- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read.
### Opacity floor on text
- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on `<span>`s, `<p>`s, labels, etc.
- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha.
- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`:
- `text-text-primary` — default body text.
- `text-text-secondary` — subtitles, meta, inactive nav.
- `text-text-tertiary` — small chrome labels, counts, footnotes.
- `text-text-disabled` — disabled states.
- `text-text-on-accent` — text on filled accent surfaces.
### Brand uppercase via `text-display`, not raw `uppercase`
- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**.
- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`.
- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated.
- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).
### Fonts
Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.
| Tier | Classes | Use for |
|------|---------|---------|
| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings |
| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** |
| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` |
| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` |
| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs |
- Do **not** put `themedBody` or `themedFont` on `<main>`, `App`, or other layout wrappers — it overrides component-scoped styles.
- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`.
- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed.
- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.
### Color tokens
- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`).
- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name.
- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text.
+36
View File
@@ -0,0 +1,36 @@
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,
},
rules: {
// Context providers and hook files commonly export both a component
// (the Provider) and a hook (useContext). Allow constant exports so
// these don't need to be split into separate files.
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// TODO: upgrade these react-hooks v7 rules from 'warn' to 'error' after
// refactoring set-state-in-effect, ref-as-instance-var, and manual
// memoization patterns in the web codebase.
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/static-components': 'warn',
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>Hermes Agent - Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"fix": "npm run lint:fix",
"preview": "vite preview",
"typecheck": "tsc -p . --noEmit",
"test": "vitest run",
"check": "npm run typecheck && npm run test && npm run lint"
},
"dependencies": {
"@hermes/shared": "file:../apps/shared",
"@nous-research/ui": "0.18.2",
"@observablehq/plot": "0.6.17",
"@react-three/fiber": "9.6.1",
"@tailwindcss/vite": "4.3.3",
"@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.42.2",
"qrcode": "1.5.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router": "8.3.0",
"tailwind-merge": "3.6.0",
"tailwindcss": "4.3.3",
"unicode-animations": "1.0.3"
},
"devDependencies": {
"@babel/core": "8.0.1",
"@rolldown/plugin-babel": "0.2.3",
"@types/babel__core": "7.20.5",
"@types/node": "22.20.1",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"babel-plugin-react-compiler": "1.0.0",
"eslint-plugin-react-refresh": "0.5.3",
"three": "0.180.0",
"typescript": "6.0.3",
"vite": "8.2.0",
"vitest": "4.1.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1395
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
/**
* 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);
// Loopback / --insecure mode: the auth gate is off, so /api/auth/me is a
// guaranteed 401. Don't fire the request at all — it only produces console
// noise ("Failed to load resource: 401") on every dashboard load.
const gated =
typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__;
useEffect(() => {
if (!gated) return;
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;
};
}, [gated]);
// Nothing to show in ungated mode — there is no logged-in identity.
if (!gated) return null;
if (hidden) return null;
if (error) {
return (
<div
className={cn(
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
className,
)}
>
{error}
</div>
);
}
if (!me) {
// Loading. Reserve the row height so the sidebar doesn't flicker
// when the data arrives.
return (
<div
className={cn(
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
className,
)}
aria-busy="true"
>
…
</div>
);
}
const handleLogout = () => {
void api.logout();
};
// Prefer display_name → email → truncated user_id. Contract V1 only
// populates user_id; the fallthroughs are forward-compat for a future
// Portal that adds a userinfo endpoint (OQ-C1 in the plan).
const label = me.display_name || me.email || truncateUserId(me.user_id);
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2",
"border-t border-current/10",
"text-[0.65rem] tracking-[0.05em]",
className,
)}
role="status"
aria-label={`Logged in as ${label}`}
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
{label}
</span>
<span className="truncate text-muted-foreground/70">
via {me.provider}
</span>
</div>
<button
type="button"
onClick={handleLogout}
className={cn(
"shrink-0 rounded p-1.5 text-muted-foreground/70",
"transition-colors hover:bg-current/10 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
)}
aria-label="Log out"
title="Log out"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
{description && <span className="text-xs text-text-secondary">{description}</span>}
</div>
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatScalar(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function NestedValueEditor({
fieldKey,
value,
onChange,
}: {
fieldKey: string;
value: unknown;
onChange: (v: unknown) => void;
}) {
if (isRecord(value)) {
return (
<div className="grid gap-2 border border-border p-2">
{Object.entries(value).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${subKey}`}
value={subVal}
onChange={(next) => onChange({ ...value, [subKey]: next })}
/>
</div>
))}
</div>
);
}
if (Array.isArray(value)) {
return (
<div className="grid gap-2">
{value.map((item, index) => (
<div key={`${fieldKey}.${index}`} className="grid gap-1">
<Label className="text-xs text-muted-foreground">Item {index + 1}</Label>
<NestedValueEditor
fieldKey={`${fieldKey}.${index}`}
value={item}
onChange={(next) =>
onChange(value.map((existing, i) => (i === index ? next : existing)))
}
/>
</div>
))}
</div>
);
}
return (
<Input
value={formatScalar(value)}
onChange={(e) => onChange(e.target.value)}
className="text-xs"
/>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) {
return (
<div className="grid gap-3 border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<NestedValueEditor fieldKey={schemaKey} value={value} onChange={onChange} />
</div>
);
}
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
{options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt || "(none)"}
</SelectOption>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}
+225
View File
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useState } from "react";
import { Clock, Wand2 } from "lucide-react";
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 { 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 { Badge } from "@nous-research/ui/ui/components/badge";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { api } from "@/lib/api";
import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api";
import { cn, themedBody } from "@/lib/utils";
interface AutomationBlueprintsProps {
profile: string;
/** Called after a blueprint is instantiated so the parent can refresh its job list. */
onCreated?: () => void;
}
/** Initial form values for a blueprint = each field's default (or ""). */
function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
const out: Record<string, string> = {};
for (const f of blueprint.fields) out[f.name] = f.default ?? "";
return out;
}
function FieldInput({
field,
value,
onChange,
}: {
field: AutomationBlueprintField;
value: string;
onChange: (v: string) => void;
}) {
if (field.type === "enum" || field.type === "weekdays") {
return (
<Select value={value} onValueChange={(v) => onChange(v)}>
{field.options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt}
</SelectOption>
))}
</Select>
);
}
if (field.type === "time") {
return (
<Input
type="time"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// text
return (
<Input
type="text"
value={value}
placeholder={field.help || field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
}
function BlueprintCard({
blueprint,
profile,
showToast,
onCreated,
}: {
blueprint: AutomationBlueprint;
profile: string;
showToast: (message: string, type: "error" | "success") => void;
onCreated?: () => void;
}) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
setSubmitting(true);
setError(null);
try {
const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile);
const when = job.schedule_display ? ` — ${job.schedule_display}` : "";
showToast(`${blueprint.title} scheduled${when}`, "success");
setOpen(false);
setValues(initialValues(blueprint));
onCreated?.();
} catch (e) {
// 422 from the API carries the slot-level validation message.
const msg = e instanceof Error ? e.message : String(e);
setError(msg.replace(/^\d+:\s*/, ""));
} finally {
setSubmitting(false);
}
}, [blueprint, values, profile, showToast, onCreated]);
return (
<Card className={cn("overflow-hidden", themedBody)}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
<span className="font-medium">{blueprint.title}</span>
</div>
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map((t) => (
<Badge key={t} tone="secondary">
{t}
</Badge>
))}
</div>
</div>
<Button
ghost={open}
size="sm"
onClick={() => setOpen((o) => !o)}
>
{open ? "Cancel" : "Set up"}
</Button>
</div>
{open && (
<div className="space-y-3 border-t pt-3">
{blueprint.fields.map((f) => (
<div key={f.name} className="space-y-1">
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
<FieldInput
field={f}
value={values[f.name] ?? ""}
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
/>
{f.help && f.type !== "text" ? (
<p className="text-xs opacity-60">{f.help}</p>
) : null}
</div>
))}
{error ? (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
) : null}
<div className="flex items-center gap-2">
<Button
onClick={() => void submit()}
disabled={submitting}
prefix={submitting ? <Spinner /> : <Clock />}
>
Schedule it
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
/**
* Automation Blueprints gallery — the form-where-there's-a-screen surface. Each blueprint
* card expands into an inline form (one field per typed slot); submitting POSTs
* to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
* via the same create_job path as everything else.
*/
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
const { toast, showToast } = useToast();
const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getAutomationBlueprints()
.then((r) => {
if (!cancelled) setBlueprints(r.blueprints);
})
.catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, []);
if (loadError) {
return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
}
if (blueprints === null) {
return (
<div className="flex items-center gap-2 opacity-70">
<Spinner className="h-4 w-4" /> Loading blueprints…
</div>
);
}
if (blueprints.length === 0) {
return <p className="opacity-70">No automation blueprints available.</p>;
}
return (
<>
<Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{blueprints.map((r) => (
<BlueprintCard
key={r.key}
blueprint={r}
profile={profile}
showToast={showToast}
onCreated={onCreated}
/>
))}
</div>
</>
);
}
export default AutomationBlueprints;
+260
View File
@@ -0,0 +1,260 @@
/**
* ChatSessionList — a ChatGPT-style conversation switcher that sits beside
* the embedded TUI on the dashboard Chat tab.
*
* It lists the most recent sessions for the active management profile and
* lets the user swap between them without leaving the Chat page. Selecting
* a row sets `/chat?resume=<id>`; ChatPage treats the resume target as part
* of the PTY identity, so the change tears down the current terminal child
* and respawns it resuming that conversation (see ChatPage.tsx). The
* "New session" action clears the resume param, which spawns a fresh PTY.
*
* Best-effort, like ChatSidebar: a failed fetch surfaces a small inline
* error with a retry affordance and the terminal pane keeps working.
*
* This is a navigation surface, NOT a session-management one — delete,
* rename, export, and bulk actions live on the Sessions page. Keeping this
* panel read-only (plus select / new) avoids duplicating that machinery and
* keeps the chat context focused on switching conversations quickly.
*/
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 { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { useI18n } from "@/i18n";
import { api, type SessionInfo } from "@/lib/api";
import { cn, timeAgo } from "@/lib/utils";
const SESSION_LIMIT = 30;
interface ChatSessionListProps {
/** Active resume target (the session currently shown in the terminal). */
activeSessionId: string | null;
/** Management profile from the dashboard switcher — scopes the listing. */
profile?: string;
className?: string;
/** Optional callback fired after a row is picked (e.g. close mobile sheet). */
onPicked?: () => void;
/**
* Starts a fresh chat. ChatPage supplies its `startFreshDashboardChat`,
* which clears `?resume` AND bumps the reconnect nonce so a brand-new PTY
* spawns even when the user is already on an unsaved fresh session. When
* omitted, we fall back to clearing the resume param ourselves.
*/
onNewChat?: () => void;
}
function rowLabel(session: SessionInfo, untitled: string): string {
const title = session.title?.trim();
if (title && title !== "Untitled") return title;
const preview = session.preview?.trim();
if (preview) return preview;
return untitled;
}
export function ChatSessionList({
activeSessionId,
profile,
className,
onPicked,
onNewChat,
}: ChatSessionListProps) {
const { t } = useI18n();
const [, setSearchParams] = useSearchParams();
const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bumped to force a refetch (after switching, on Refresh, on mount).
const [reloadNonce, setReloadNonce] = useState(0);
// `profile` is read inside the fetch; it's part of the scope key so a
// profile switch refetches. The empty-string fallback keeps the dep
// stable when no profile is selected (default profile).
const scopeKey = profile ?? "";
// Monotonic request token: only the most recent fetch is allowed to
// commit state, so a fast profile switch (or Refresh spam) can't land a
// stale list out of order.
const reqRef = useRef(0);
const load = useCallback(() => {
const myReq = ++reqRef.current;
setLoading(true);
setError(null);
api
.getSessions(SESSION_LIMIT, 0, scopeKey, "recent")
.then((res) => {
if (reqRef.current !== myReq) return;
setSessions(res.sessions);
})
.catch((e: Error) => {
if (reqRef.current !== myReq) return;
setError(e.message || "failed to load sessions");
})
.finally(() => {
if (reqRef.current === myReq) setLoading(false);
});
}, [scopeKey]);
useEffect(() => {
// Dashboard data surfaces fetch from an effect on mount + scope change;
// keep this local and explicit until the shared lint profile is updated
// for async loaders (matches FilesPage).
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
// `reloadNonce` is a manual refetch trigger (Refresh button / row pick).
}, [load, reloadNonce]);
const reload = useCallback(() => setReloadNonce((n) => n + 1), []);
// Picking a row sets `/chat?resume=<id>`. Re-picking the row already in
// the terminal is a no-op (avoids a needless PTY teardown).
const pick = useCallback(
(id: string) => {
onPicked?.();
if (id === activeSessionId) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("resume", id);
return next;
},
{ replace: false },
);
},
[activeSessionId, onPicked, setSearchParams],
);
// "New chat" prefers ChatPage's robust handler (clears resume + forces a
// PTY respawn even from an already-fresh session). Fallback: clear the
// resume param ourselves, which spawns a fresh PTY whenever one was being
// resumed. Session management (delete/rename/export) lives on the Sessions
// page; this panel only switches and starts conversations.
const startNew = useCallback(() => {
onPicked?.();
if (onNewChat) {
onNewChat();
return;
}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("resume");
return next;
},
{ replace: false },
);
}, [onNewChat, onPicked, setSearchParams]);
const content = useMemo(() => {
if (loading && sessions === null) {
return (
<div className="flex items-center justify-center gap-2 px-2 py-6 text-xs text-text-secondary">
<Spinner /> {t.common.loading}
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-start gap-2 px-2 py-4 text-xs">
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="wrap-break-word">{error}</span>
</div>
<Button size="sm" outlined onClick={reload} prefix={<RefreshCw />}>
{t.common.retry}
</Button>
</div>
);
}
if (!sessions || sessions.length === 0) {
return (
<div className="px-2 py-6 text-center text-xs text-text-secondary">
{t.sessions.noSessions}
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{sessions.map((s) => {
const isActive = s.id === activeSessionId;
return (
<ListItem
key={s.id}
onClick={() => pick(s.id)}
aria-current={isActive ? "true" : undefined}
className={cn(
"flex-col items-start gap-0.5 rounded px-2 py-1.5",
"normal-case tracking-normal",
isActive
? "bg-primary/10 text-foreground border-l-2 border-primary"
: "text-text-secondary hover:bg-midground/5 hover:text-foreground",
)}
>
<span className="w-full truncate text-sm font-medium">
{rowLabel(s, t.sessions.untitledSession)}
</span>
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
<span>{timeAgo(s.last_active)}</span>
{s.message_count > 0 && (
<>
<span aria-hidden>·</span>
<span>{s.message_count} msgs</span>
</>
)}
{s.source && s.source !== "cli" && (
<>
<span aria-hidden>·</span>
<span className="truncate">{s.source}</span>
</>
)}
</span>
</ListItem>
);
})}
</div>
);
}, [activeSessionId, error, loading, pick, reload, sessions, t]);
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col overflow-hidden",
className,
)}
>
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<span className="text-display text-xs tracking-wider text-text-tertiary">
{t.sessions.title}
</span>
<Button
ghost
size="icon"
onClick={reload}
aria-label={t.common.refresh}
title={t.common.refresh}
className="text-text-secondary hover:text-foreground"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<Button
outlined
size="sm"
onClick={startNew}
prefix={<MessageSquarePlus />}
className="mx-2 mb-2 justify-center"
>
{t.sessions.newChat}
</Button>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pb-1">
{content}
</div>
</aside>
);
}
+455
View File
@@ -0,0 +1,455 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EVENTS_CONNECT_TIMEOUT_MS } from "@/lib/events-reconnect";
const apiMocks = vi.hoisted(() => ({
buildWsUrl: vi.fn(async () => "ws://localhost/api/events?channel=chat-1"),
getModelInfo: vi.fn(async () => ({
capabilities: { supports_reasoning: false },
model: "test/model",
})),
}));
const gatewayMocks = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>();
return {
close: vi.fn(),
connect: vi.fn(async () => undefined),
handlers,
on: vi.fn((event: string, handler: (event: unknown) => void) => {
handlers.set(event, handler);
return () => handlers.delete(event);
}),
onState: vi.fn((handler: (state: string) => void) => {
handler("open");
return () => undefined;
}),
request: vi.fn(async () => ({ session_id: "sidecar-1" })),
};
});
const reloadMocks = vi.hoisted(() => ({
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true),
}));
vi.mock("@/lib/api", () => ({
api: { getModelInfo: apiMocks.getModelInfo },
buildWsUrl: apiMocks.buildWsUrl,
}));
vi.mock("@/lib/dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure:
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
}));
vi.mock("@/lib/gatewayClient", () => ({
GatewayClient: class {
close = gatewayMocks.close;
connect = gatewayMocks.connect;
on = gatewayMocks.on;
onState = gatewayMocks.onState;
request = gatewayMocks.request;
},
}));
vi.mock("@/components/ModelPickerDialog", () => ({
ModelPickerDialog: () => null,
}));
vi.mock("@/components/ModelReloadConfirm", () => ({
ModelReloadConfirm: () => null,
}));
vi.mock("@/components/ReasoningPicker", () => ({
ReasoningPicker: () => null,
}));
vi.mock("@nous-research/ui/ui/components/button", () => ({
Button: ({ children }: { children?: ReactNode }) => <button>{children}</button>,
}));
vi.mock("@nous-research/ui/ui/components/badge", () => ({
Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));
vi.mock("@nous-research/ui/ui/components/card", () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
type EventLike = { code?: number; data?: string };
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
private listeners = new Map<string, Array<(event: EventLike) => void>>();
readonly url: string;
closed = false;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
addEventListener(type: string, listener: (event: EventLike) => void) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
close() {
this.closed = true;
}
emit(type: string, event: EventLike) {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
let container: HTMLDivElement;
let root: Root;
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(ui));
}
beforeEach(() => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
apiMocks.buildWsUrl.mockReset();
apiMocks.buildWsUrl.mockResolvedValue(
"ws://localhost/api/events?channel=chat-1",
);
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
vi.stubGlobal("WebSocket", FakeWebSocket);
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});
describe("ChatSidebar event socket", () => {
it("routes loopback 4401 closes through stale-token recovery", async () => {
const { ChatSidebar } = await import("./ChatSidebar");
await render(<ChatSidebar channel="chat-1" />);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
expect(apiMocks.buildWsUrl).toHaveBeenCalledWith("/api/events", {
channel: "chat-1",
});
FakeWebSocket.instances[0].emit("close", { code: 4401 });
expect(
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
).toHaveBeenCalledWith(4401);
});
});
describe("ChatSidebar event socket reconnect", () => {
beforeEach(() => {
// Not loopback: exercise the gated-mode path so closes fall through to
// the reconnect logic instead of triggering a page reload.
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(false);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function renderSidebar() {
const { ChatSidebar } = await import("./ChatSidebar");
await render(<ChatSidebar channel="chat-1" />);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
}
/** Advance timers and flush the async `connect()` that fires on the tick. */
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
it("reconnects after a transient close", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(FakeWebSocket.instances).toHaveLength(1);
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
});
it("keeps retrying when reconnect URL construction fails", async () => {
await renderSidebar();
apiMocks.buildWsUrl
.mockRejectedValueOnce(new Error("ticket endpoint unavailable"))
.mockResolvedValue("ws://localhost/api/events?channel=chat-1");
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(container.textContent).toContain("reconnecting in 2s");
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3);
});
it("times out a stalled URL request and retries", async () => {
let resolveStalledRequest!: (url: string) => void;
await renderSidebar();
apiMocks.buildWsUrl
.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveStalledRequest = resolve;
}),
)
.mockResolvedValue("ws://localhost/api/events?channel=chat-1");
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000 + EVENTS_CONNECT_TIMEOUT_MS);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(container.textContent).toContain("reconnecting in 2s");
// A late ticket response from the timed-out attempt must not create a
// superseded socket alongside the scheduled replacement.
await act(async () => {
resolveStalledRequest("ws://localhost/api/events?channel=stale");
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(1);
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3);
});
it("times out a stalled WebSocket handshake and retries", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
await advance(EVENTS_CONNECT_TIMEOUT_MS);
expect(FakeWebSocket.instances[1].closed).toBe(true);
expect(container.textContent).toContain("reconnecting in 2s");
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("backs off exponentially across repeated failures", async () => {
await renderSidebar();
// 1s, then 2s, then 4s — a socket that never opens keeps backing off.
for (const [index, delay] of [1_000, 2_000, 4_000].entries()) {
await act(async () => {
FakeWebSocket.instances[index].emit("close", { code: 1006 });
});
// The previous (shorter) delay must not be enough to fire this one.
if (index > 0) {
await advance(delay - 1);
expect(FakeWebSocket.instances).toHaveLength(index + 1);
}
await advance(delay);
expect(FakeWebSocket.instances).toHaveLength(index + 2);
}
});
it("schedules only one retry when error and close both fire", async () => {
await renderSidebar();
// A failed socket emits `error` then `close`. Scheduling from both
// paths would queue two timers and leak the untracked one.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
await advance(29_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});
it("resets the backoff after a successful reconnect", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
// Reconnected — the next drop should start from 1s again, not 2s.
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
FakeWebSocket.instances[1].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("does not retry auth rejections", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 4403 });
});
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
it("does not retry a normal closure", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1000 });
});
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
it("gives up after the attempt cap instead of retrying forever", async () => {
await renderSidebar();
for (let i = 0; i < 40; i++) {
const socket =
FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
await act(async () => {
socket.emit("close", { code: 1006 });
});
await advance(30_000);
}
// 15 retries + the initial connection.
expect(FakeWebSocket.instances.length).toBeLessThanOrEqual(16);
});
it("clears its own banner on a successful reconnect", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(container.textContent).toContain("events feed disconnected");
await advance(1_000);
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
// Banner gone entirely — including the "reconnect events feed" button,
// which only renders while `error` is set.
expect(container.textContent).not.toContain("events feed");
});
it("does not clear a credential warning when the feed recovers", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
// A sidecar error lands while the events socket is still reconnecting.
// The banner is shared, so a blind `setError(null)` on reconnect would
// hide a real problem the user needs to see.
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
});
it("does not overwrite a sidecar error when the feed drops", async () => {
await renderSidebar();
// A sidecar error is already on the banner...
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
// ...when the events feed drops. `error` is that message's only home,
// so overwriting it loses the warning permanently — the feed's own
// banner would later clear itself to null and the sidecar never
// re-emits.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
// The disconnect message must not have replaced it. (Matching the
// banner text specifically — "reconnect events feed" is the button
// label, which is expected to be present whenever a banner shows.)
expect(container.textContent).not.toContain("events feed disconnected");
});
it("still reconnects while a foreign banner suppresses its message", async () => {
await renderSidebar();
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
// Declining to write the banner must not disable the retry itself.
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});
it("clears the reconnect timer and closes the socket on unmount", async () => {
await renderSidebar();
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(vi.getTimerCount()).toBeGreaterThan(0);
await act(async () => root.unmount());
expect(FakeWebSocket.instances[0].closed).toBe(true);
// The pending retry timer must be cleared, not merely neutered by the
// `unmounting` flag — a live timer keeps the effect closure alive.
expect(vi.getTimerCount()).toBe(0);
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
});
+584
View File
@@ -0,0 +1,584 @@
/**
* 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) — a lightweight
* session used only for connection state (the "live" badge) and
* credential warnings. Independent of the PTY pane's session by
* design. The model badge does NOT come from here: it reads the
* effective config model over REST (`/api/model/info`), and the model
* picker writes config over REST (`/api/model/set`) then offers a
* dashboard reload so the running chat adopts the new model.
*
* 2. **Event subscriber** (/api/events?channel=…) — passive, receives
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
* the dashboard fanned out. The sidebar uses it for `session.info`
* (live chat title) and `dashboard.new_session_requested`. The
* `channel` id ties this listener to the same chat tab's PTY child —
* see `ChatPage.tsx` for where the id is generated. Transient drops
* (gateway restart, network blip) auto-reconnect with exponential
* backoff; auth rejections are terminal. See `lib/events-reconnect`.
*
* 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 { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
import { ReasoningPicker } from "@/components/ReasoningPicker";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { api, buildWsUrl } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import {
EVENTS_CONNECT_TIMEOUT_MS,
EVENTS_DISCONNECTED_MESSAGE,
EVENTS_MAX_RECONNECT_ATTEMPTS,
eventsGaveUpMessage,
eventsReconnectDelayMs,
eventsReconnectingMessage,
eventsRejectedMessage,
isEventsAuthRejection,
isEventsFeedMessage,
shouldRetryEventsClose,
} from "@/lib/events-reconnect";
import { titleFromSessionInfoPayload } from "@/lib/chat-title";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
title?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
}
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;
/** Chat profile from the dashboard switcher / URL scope. */
profile?: string;
className?: string;
onDashboardNewSessionRequest?: () => void;
onSessionTitleChange?: (title: string | null) => void;
}
/** Build the ``session.create`` params for the sidecar session.
*
* Extracted from the effect below so the invariant — close_on_disconnect
* is set, source is "tool", and the profile is forwarded when present —
* can be tested without reading component source text. See
* ``chat-sidebar-session-params.test.ts``.
*/
export function sidecarSessionCreateParams(profile?: string): Record<string, unknown> {
return {
close_on_disconnect: true,
source: "tool",
...(profile ? { profile } : {}),
};
}
export function ChatSidebar({
channel,
profile,
className,
onDashboardNewSessionRequest,
onSessionTitleChange,
}: 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 [info, setInfo] = useState<SessionInfo>({});
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// The badge shows config.yaml's main model (`model.default`) via
// `/api/model/info` — the same value the Models page writes and a new chat
// session boots from. We deliberately don't use the sidecar's `session.info`
// model: that's a one-time snapshot of the throwaway sidecar agent taken when
// its session is created, and it never updates when the model is changed
// elsewhere, so the badge would go stale. Pass the chat profile explicitly so
// this card stays scoped to the PTY even if the global dashboard switcher
// changes while the chat is open.
const [effectiveModel, setEffectiveModel] = useState("");
// Whether the effective model supports reasoning effort — gates the
// ReasoningPicker. Read from the same `/api/model/info` capabilities the
// (currently unused) ModelInfoCard surfaces, so the dashboard exposes a
// control to *set* the level, not just a read-only "Reasoning" badge.
const [supportsReasoning, setSupportsReasoning] = useState(false);
// Bumped on model change/save so ReasoningPicker re-reads the saved effort
// (config is profile-scoped the same way the model badge is).
const [modelRefreshKey, setModelRefreshKey] = useState(0);
// Set after the picker saves a model and the user declines the reload: config
// is updated but the running session keeps its model until rebuilt.
const [modelNotice, setModelNotice] = useState<string | null>(null);
// Short name of a just-saved model awaiting confirm to reload (a fresh chat
// session is how the running chat adopts it; we confirm before discarding it).
const [pendingReloadModel, setPendingReloadModel] = useState<string | null>(
null,
);
const refreshEffectiveModel = useCallback(() => {
void api
.getModelInfo(profile)
.then((r) => {
if (r?.model) setEffectiveModel(String(r.model));
setSupportsReasoning(!!r?.capabilities?.supports_reasoning);
// Bump so ReasoningPicker re-reads the saved effort for the new model.
setModelRefreshKey((k) => k + 1);
})
.catch(() => {
// Best-effort: keep the last known label rather than blanking it.
});
}, [profile]);
// Profile or PTY channel change tears down both WebSockets. Bump `version`
// (same path as the manual Reconnect button) so the gateway client is
// recreated and the events feed resubscribes — otherwise the old events
// socket's close handler can leave a stale error banner after a switch.
const scopeKey = `${channel}\0${profile ?? ""}`;
const prevScopeKey = useRef<string | null>(null);
useEffect(() => {
if (prevScopeKey.current === null) {
prevScopeKey.current = scopeKey;
return;
}
if (prevScopeKey.current === scopeKey) return;
prevScopeKey.current = scopeKey;
setError(null);
setVersion((v) => v + 1);
}, [scopeKey]);
useEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setInfo({});
setError(null);
});
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
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);
}
});
// Create the sidecar session so the gateway surfaces session-scoped
// signals (connection state, credential warnings). It's independent of the
// PTY pane's session by design. The model picker no longer rides this
// session — it writes config.yaml over REST — so we don't track its id.
gw.connect()
.then(() => {
if (cancelled) {
return;
}
// close_on_disconnect: the gateway reaps this sidecar session (and its
// slash_worker subprocess) when the WS drops, instead of leaking it.
return gw.request<{ session_id: string }>("session.create", sidecarSessionCreateParams(profile));
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
}
});
return () => {
cancelled = true;
offState();
offSessionInfo();
offError();
gw.close();
};
// `profile` is read from render; scope changes bump `version` → new `gw`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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. `connect`
// keeps the outer effect synchronous so its ``return cleanup`` stays at
// the top level; `ws` is a closed-over binding the cleanup reads.
let unmounting = false;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectTimer: ReturnType<typeof setTimeout> | null = null;
let connectGeneration = 0;
let attempt = 0;
const clearConnectTimer = () => {
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
};
// The banner is shared with `info.credential_warning` and the JSON-RPC
// sidecar, and `error` is those messages' only home — the sidecar does
// not re-emit. So the events feed may only write over an empty banner
// or one of its own messages, and may only clear its own.
const surface = (msg: string) =>
!unmounting &&
setError((current) =>
isEventsFeedMessage(current) ? msg : (current ?? msg),
);
const clearEventsBanner = () =>
!unmounting &&
setError((current) => (isEventsFeedMessage(current) ? null : current));
// Single scheduling path. `close` always follows `error` for a failed
// socket, so scheduling from `error` too would queue two timers and
// leak the first — only the latest is tracked for cleanup.
const scheduleReconnect = () => {
if (unmounting || reconnectTimer) {
return;
}
if (attempt >= EVENTS_MAX_RECONNECT_ATTEMPTS) {
surface(eventsGaveUpMessage());
return;
}
const delay = eventsReconnectDelayMs(attempt);
attempt += 1;
surface(eventsReconnectingMessage(delay));
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, delay);
};
const connect = async () => {
if (unmounting) {
return;
}
const generation = ++connectGeneration;
let socket: WebSocket | null = null;
// Cover the whole connection attempt, including gated-mode ticket
// minting. A failed or hanging pre-socket request otherwise emits no
// WebSocket close event and permanently strands the retry loop at its
// last "reconnecting in ..." banner.
clearConnectTimer();
connectTimer = setTimeout(() => {
connectTimer = null;
if (unmounting || generation !== connectGeneration) {
return;
}
// Invalidate any late ticket result or socket event from this attempt
// before scheduling its replacement.
connectGeneration += 1;
if (socket && ws === socket) {
ws = null;
socket.close();
}
scheduleReconnect();
}, EVENTS_CONNECT_TIMEOUT_MS);
try {
// Re-minted every attempt: tickets are single-use with a short TTL,
// so a reconnect cannot replay the URL from the first connection.
const url = await buildWsUrl("/api/events", { channel });
if (unmounting || generation !== connectGeneration) {
return;
}
socket = new WebSocket(url);
ws = socket;
} catch {
if (unmounting || generation !== connectGeneration) {
return;
}
clearConnectTimer();
connectGeneration += 1;
scheduleReconnect();
return;
}
// A superseded socket's late close must not schedule a retry on top
// of the one that replaced it.
const isCurrent = () => ws === socket;
socket.addEventListener("open", () => {
if (!isCurrent()) {
return;
}
clearConnectTimer();
attempt = 0;
clearEventsBanner();
});
// `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.
socket.addEventListener("error", () => {
if (isCurrent()) {
surface(EVENTS_DISCONNECTED_MESSAGE);
}
});
socket.addEventListener("close", (ev) => {
if (!isCurrent()) {
return;
}
clearConnectTimer();
if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
return;
}
if (isEventsAuthRejection(ev.code)) {
surface(eventsRejectedMessage(ev.code));
return;
}
if (shouldRetryEventsClose(ev.code)) {
scheduleReconnect();
}
});
socket.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 === "session.info") {
const title = titleFromSessionInfoPayload(payload);
if (title !== undefined) {
onSessionTitleChange?.(title);
}
} else if (type === "dashboard.new_session_requested") {
onDashboardNewSessionRequest?.();
}
});
};
void connect();
return () => {
unmounting = true;
connectGeneration += 1;
clearConnectTimer();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
};
}, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]);
// Seed the badge on mount and re-read it whenever the sockets are rebuilt
// (a profile/channel switch bumps `version`).
useEffect(() => {
refreshEffectiveModel();
}, [refreshEffectiveModel, version]);
const reconnect = useCallback(() => {
setError(null);
setModelNotice(null);
setPendingReloadModel(null);
setVersion((v) => v + 1);
}, []);
// The picker writes config.yaml over REST and reloads — it doesn't ride the
// sidecar gateway session, so it's available whenever the sidebar is mounted.
const modelName = effectiveModel || info.model || "—";
const modelLabel = modelName.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",
className,
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-display text-xs tracking-wider text-text-tertiary">
model
</div>
<Button
ghost
size="sm"
onClick={() => setModelOpen(true)}
className={cn(
"max-w-full min-w-0 px-0 py-0",
"self-start normal-case tracking-normal text-sm font-medium",
"hover:underline disabled:no-underline",
)}
title={modelName === "—" ? "switch model" : modelName}
>
<span className="flex min-w-0 max-w-full items-center gap-1">
<span className="truncate">{modelLabel}</span>
<ChevronDown className="size-3.5 shrink-0 text-text-secondary" />
</span>
</Button>
</div>
<Badge tone={STATE_TONE[state]} className="shrink-0">
{STATE_LABEL[state]}
</Badge>
</Card>
{supportsReasoning && (
<Card className="py-0">
<ReasoningPicker
currentModel={modelName}
profile={profile}
refreshKey={modelRefreshKey}
onChanged={(effort) =>
setModelNotice(
`Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`,
)
}
/>
</Card>
)}
{modelNotice && (
<Card className="flex items-start gap-2 border-warning/40 bg-warning/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div className="wrap-break-word min-w-0 flex-1 text-text-secondary">
{modelNotice}
</div>
</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 events feed
</Button>
)}
</div>
</Card>
)}
{modelOpen && (
<ModelPickerDialog
// Same path the Models page uses (REST /api/model/set), not the
// sidecar config.set RPC, which didn't reliably land in the
// config.yaml the agent boots from. Always persisted (alwaysGlobal).
loader={() => api.getModelOptions(profile)}
alwaysGlobal
onApply={async ({ provider, model, confirmExpensiveModel }) => {
setModelNotice(null);
setPendingReloadModel(null);
const result = await api.setModelAssignment(
{
confirm_expensive_model: confirmExpensiveModel,
scope: "main",
provider,
model,
},
profile,
);
// confirm_required => the dialog shows the expensive-model prompt
// and calls back; don't announce until the user confirms.
if (!result.confirm_required) {
refreshEffectiveModel();
// Ask before reloading: applying the model starts a fresh chat.
setPendingReloadModel(model.split("/").slice(-1)[0]);
}
return result;
}}
onClose={() => {
setModelOpen(false);
refreshEffectiveModel();
}}
/>
)}
<ModelReloadConfirm
model={pendingReloadModel}
onCancel={() => {
const m = pendingReloadModel;
setPendingReloadModel(null);
setModelNotice(
`Model set to ${m}. Run /new or refresh the page to apply it to this chat.`,
);
}}
/>
</aside>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { AlertTriangle } from "lucide-react";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
interface ConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
destructive?: boolean;
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
export function ConfirmDialog({
cancelLabel = "Cancel",
confirmLabel = "Confirm",
description,
destructive = false,
loading = false,
onCancel,
onConfirm,
open,
title,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prevActive = document.activeElement as HTMLElement | null;
dialogRef.current
?.querySelector<HTMLButtonElement>("[data-confirm]")
?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
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, onCancel]);
if (!open) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby={description ? "confirm-dialog-desc" : undefined}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 p-4"
>
<div
ref={dialogRef}
className={cn(
themedBody,
"relative w-full max-w-md border border-border bg-card shadow-2xl",
)}
>
<div className="flex items-start gap-3 p-4 border-b border-border">
{destructive && (
<div aria-hidden className="mt-0.5 shrink-0 text-destructive">
<AlertTriangle className="h-4 w-4" />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<h2
id="confirm-dialog-title"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
{description && (
<p
id="confirm-dialog-desc"
className="text-xs text-muted-foreground leading-relaxed whitespace-pre-line"
>
{description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" outlined onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button
data-confirm
type="button"
destructive={destructive}
onClick={onConfirm}
disabled={loading}
>
{loading ? "…" : confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
);
}
@@ -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;
}
+537
View File
@@ -0,0 +1,537 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal as XtermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Terminal, X } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { useProfileScope } from "@/contexts/useProfileScope";
import { api } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import { cn, themedBody } from "@/lib/utils";
import { useTheme } from "@/themes";
type ConsoleFrame =
| {
type: "ready";
profile?: string;
prompt?: string;
}
| {
type: "output";
data?: string;
stream?: string;
}
| {
type: "error";
message?: string;
}
| {
type: "confirm_required";
command?: string;
message?: string;
prompt?: string;
}
| {
type: "complete";
status?: string;
prompt?: string;
}
| {
type: "clear";
}
| {
type: "pong";
};
type ConnectionState = "connecting" | "ready" | "running" | "closed" | "error";
interface HermesConsoleModalProps {
open: boolean;
onClose: () => void;
}
function buildTerminalTheme(background: string, foreground: string) {
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: "rgba(255, 255, 255, 0.25)",
black: "#000000",
red: "#ff5f67",
green: "#5fffb0",
yellow: "#ffd166",
blue: "#7aa2ff",
magenta: "#d597ff",
cyan: "#58e6ff",
white: foreground,
brightBlack: "#666666",
brightRed: "#ff8b90",
brightGreen: "#8dffc8",
brightYellow: "#ffe08a",
brightBlue: "#9dbaff",
brightMagenta: "#e4b7ff",
brightCyan: "#8ef0ff",
brightWhite: "#ffffff",
};
}
function normalizeTerminalText(text: string): string {
return text.replace(/\r?\n/g, "\r\n");
}
function writeLine(term: XtermTerminal, text = ""): void {
term.write(`${normalizeTerminalText(text)}\r\n`);
}
function writeBlock(term: XtermTerminal, text: string): void {
const normalized = normalizeTerminalText(text);
term.write(normalized.endsWith("\r\n") ? normalized : `${normalized}\r\n`);
}
function isPrintable(data: string): boolean {
return data >= " " || data === "\t";
}
export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) {
const modalRef = useModalBehavior({ open, onClose });
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<XtermTerminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const lineRef = useRef("");
const promptRef = useRef("hermes> ");
const inputPromptRef = useRef("hermes> ");
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef<number | null>(null);
const activeCommandRef = useRef(false);
const pendingCommandRef = useRef<string | null>(null);
const hasReadyFrameRef = useRef(false);
const [connectionState, setConnectionState] =
useState<ConnectionState>("connecting");
const [consoleProfile, setConsoleProfile] = useState("current");
const { profile } = useProfileScope();
const { theme } = useTheme();
const redrawInput = useCallback((line = lineRef.current) => {
const term = termRef.current;
if (!term) return;
lineRef.current = line;
term.write(`\r\x1b[2K${inputPromptRef.current}${line}`);
}, []);
const showPrompt = useCallback(() => {
const term = termRef.current;
if (!term) return;
lineRef.current = "";
historyIndexRef.current = null;
inputPromptRef.current = promptRef.current;
term.write(inputPromptRef.current);
}, []);
const sendFrame = useCallback((payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
ws.send(JSON.stringify(payload));
return true;
}, []);
const cancelCommand = useCallback(() => {
pendingCommandRef.current = null;
activeCommandRef.current = false;
sendFrame({ type: "cancel" });
}, [sendFrame]);
const submitLine = useCallback(
(rawLine: string) => {
const term = termRef.current;
if (!term) return;
const line = rawLine.trim();
term.write("\r\n");
lineRef.current = "";
historyIndexRef.current = null;
const pending = pendingCommandRef.current;
if (pending) {
const answer = line.toLowerCase();
if (answer === "y" || answer === "yes") {
pendingCommandRef.current = null;
activeCommandRef.current = true;
setConnectionState("running");
sendFrame({ type: "confirm", command: pending });
return;
}
cancelCommand();
return;
}
if (!line) {
showPrompt();
return;
}
historyRef.current = [...historyRef.current, line].slice(-200);
activeCommandRef.current = true;
setConnectionState("running");
if (!sendFrame({ type: "input", line })) {
activeCommandRef.current = false;
writeLine(term, "\x1b[31mConsole is not connected.\x1b[0m");
showPrompt();
}
},
[cancelCommand, sendFrame, showPrompt],
);
const recallHistory = useCallback(
(direction: -1 | 1) => {
const history = historyRef.current;
if (!history.length) return;
const current = historyIndexRef.current;
if (current === null) {
if (direction > 0) return;
historyIndexRef.current = history.length - 1;
} else {
const next = current + direction;
if (next < 0) historyIndexRef.current = 0;
else if (next >= history.length) {
historyIndexRef.current = null;
redrawInput("");
return;
} else {
historyIndexRef.current = next;
}
}
const idx = historyIndexRef.current;
redrawInput(idx === null ? "" : history[idx] ?? "");
},
[redrawInput],
);
const handleInputData = useCallback(
(data: string) => {
const term = termRef.current;
if (!term) return;
if (data === "\x1b[A") {
recallHistory(-1);
return;
}
if (data === "\x1b[B") {
recallHistory(1);
return;
}
for (const ch of data) {
if (ch === "\u0003") {
term.write("^C\r\n");
if (activeCommandRef.current || pendingCommandRef.current) {
cancelCommand();
} else {
showPrompt();
}
continue;
}
if (ch === "\u000c") {
term.clear();
showPrompt();
continue;
}
if (activeCommandRef.current) {
term.write("\x07");
continue;
}
if (ch === "\r" || ch === "\n") {
submitLine(lineRef.current);
continue;
}
if (ch === "\u007f" || ch === "\b") {
if (lineRef.current.length > 0) {
lineRef.current = lineRef.current.slice(0, -1);
term.write("\b \b");
}
continue;
}
if (ch === "\x1b") {
continue;
}
if (isPrintable(ch)) {
lineRef.current += ch;
term.write(ch);
}
}
},
[cancelCommand, recallHistory, showPrompt, submitLine],
);
const handleFrame = useCallback(
(frame: ConsoleFrame) => {
const term = termRef.current;
if (!term) return;
if (frame.type === "ready") {
const nextPrompt = frame.prompt || "hermes> ";
promptRef.current = nextPrompt;
inputPromptRef.current = nextPrompt;
hasReadyFrameRef.current = true;
setConsoleProfile(frame.profile || "current");
activeCommandRef.current = false;
setConnectionState("ready");
term.clear();
showPrompt();
return;
}
if (frame.type === "output") {
if (frame.data) writeBlock(term, frame.data);
return;
}
if (frame.type === "error") {
writeLine(term, `\x1b[31m${frame.message || "Command failed."}\x1b[0m`);
return;
}
if (frame.type === "confirm_required") {
pendingCommandRef.current = frame.command || "";
activeCommandRef.current = false;
setConnectionState("ready");
if (frame.message) {
writeLine(term, `\x1b[33m${frame.message}\x1b[0m`);
}
inputPromptRef.current = "Confirm? [y/N] ";
lineRef.current = "";
term.write(inputPromptRef.current);
return;
}
if (frame.type === "complete") {
activeCommandRef.current = false;
if (frame.prompt) promptRef.current = frame.prompt;
if (frame.status === "confirm_required") return;
if (frame.status === "exit") {
setConnectionState("closed");
wsRef.current?.close();
return;
}
if (frame.status === "timeout") {
writeLine(term, "\x1b[31mCommand timed out.\x1b[0m");
}
if (frame.status === "cancelled") {
writeLine(term, "\x1b[33mCancelled.\x1b[0m");
}
pendingCommandRef.current = null;
setConnectionState("ready");
showPrompt();
return;
}
if (frame.type === "clear") {
term.clear();
showPrompt();
}
},
[showPrompt],
);
useEffect(() => {
if (!open) return;
const host = hostRef.current;
if (!host) return;
let cancelled = false;
let resizeFrame = 0;
const term = new XtermTerminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
letterSpacing: 0,
macOptionIsMeta: true,
scrollback: 3000,
theme: buildTerminalTheme(
theme.terminalBackground ?? "#000000",
theme.terminalForeground ?? "#f0e6d2",
),
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.loadAddon(new WebLinksAddon());
term.open(host);
term.focus();
const fitTerminal = () => {
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return;
}
try {
fit.fit();
} catch {
/* fit can fail while the modal is closing */
}
};
const scheduleFit = () => {
if (resizeFrame) return;
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
fitTerminal();
});
};
const ro = new ResizeObserver(scheduleFit);
ro.observe(host);
scheduleFit();
const dataDisposable = term.onData(handleInputData);
setConnectionState("connecting");
setConsoleProfile(profile || "current");
hasReadyFrameRef.current = false;
writeLine(term, "\x1b[2mConnecting to Hermes Console...\x1b[0m");
void (async () => {
try {
const params = profile ? { profile } : undefined;
const url = await api.buildWsUrl("/api/console", params);
if (cancelled) return;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
setConnectionState("connecting");
};
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(String(ev.data)) as ConsoleFrame;
handleFrame(frame);
} catch {
writeLine(term, "\x1b[31mMalformed console frame.\x1b[0m");
}
};
ws.onerror = () => {
setConnectionState("error");
writeLine(term, "\x1b[31mConsole websocket error.\x1b[0m");
};
ws.onclose = (ev) => {
if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
return;
}
wsRef.current = null;
activeCommandRef.current = false;
pendingCommandRef.current = null;
if (cancelled) return;
setConnectionState(ev.code === 1000 ? "closed" : "error");
const reason = ev.reason ? ` ${ev.reason}` : "";
const message =
ev.code === 1006 && !hasReadyFrameRef.current
? "Console connection failed before the server handshake. Check that this dashboard is connected to a backend with /api/console."
: `Console closed (${ev.code}).${reason}`;
writeLine(term, `\x1b[31m${message}\x1b[0m`);
};
} catch (err) {
if (cancelled) return;
setConnectionState("error");
writeLine(term, `\x1b[31mConsole unavailable: ${err}\x1b[0m`);
}
})();
return () => {
cancelled = true;
dataDisposable.dispose();
ro.disconnect();
if (resizeFrame) cancelAnimationFrame(resizeFrame);
wsRef.current?.close();
wsRef.current = null;
term.dispose();
termRef.current = null;
lineRef.current = "";
pendingCommandRef.current = null;
activeCommandRef.current = false;
hasReadyFrameRef.current = false;
};
}, [handleFrame, handleInputData, open, profile, theme]);
useEffect(() => {
if (!open) return;
const term = termRef.current;
if (!term) return;
term.options.theme = buildTerminalTheme(
theme.terminalBackground ?? "#000000",
theme.terminalForeground ?? "#f0e6d2",
);
}, [open, theme]);
if (!open) return null;
const statusTone =
connectionState === "ready"
? "success"
: connectionState === "running"
? "warning"
: connectionState === "connecting"
? "secondary"
: "destructive";
return createPortal(
<div
ref={modalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-3 sm:p-4"
onClick={(event) => event.target === event.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="hermes-console-title"
>
<div
className={cn(
themedBody,
"relative flex h-[min(82dvh,760px)] w-full max-w-5xl flex-col border border-border bg-card shadow-2xl",
)}
>
<header className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-3">
<div className="flex h-9 w-9 items-center justify-center border border-border bg-background/60 text-primary">
<Terminal className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h2
id="hermes-console-title"
className="font-mondwest text-display text-base tracking-wider"
>
Hermes Console
</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge tone={statusTone}>{connectionState}</Badge>
<span className="font-mono">{consoleProfile}</span>
</div>
</div>
<Button
ghost
size="icon"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
aria-label="Close console"
>
<X />
</Button>
</header>
<div className="min-h-0 flex-1 bg-black">
<div
ref={hostRef}
className="h-full min-h-0 w-full overflow-hidden p-2 [&_.xterm]:h-full [&_.xterm-viewport]:!bg-transparent"
/>
</div>
</div>
</div>,
document.body,
);
}
+185
View File
@@ -0,0 +1,185 @@
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
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-sans text-display text-xs tracking-[0.08em]",
"hover:bg-accent hover:text-accent-foreground transition-colors",
selected ? "font-semibold text-foreground" : "text-muted-foreground",
)}
key={code}
onClick={() => {
setLocale(code);
setOpen(false);
}}
role="option"
type="button"
>
<span className="truncate">{meta.name}</span>
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
</button>
);
})}
</>
);
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
locale: Locale;
setLocale: (code: Locale) => void;
setOpen: (open: boolean) => void;
}
interface LanguageSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+383
View File
@@ -0,0 +1,383 @@
import { useMemo, type ReactNode } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
*
* `streaming` renders a blinking caret at the tail of the last block so it
* appears to hug the final character instead of wrapping onto a new line
* after a block element (paragraph/list/code/…).
*/
export function Markdown({
content,
highlightTerms,
streaming,
}: {
content: string;
highlightTerms?: string[];
streaming?: boolean;
}) {
const blocks = useMemo(() => parseBlocks(content), [content]);
const caret = streaming ? <StreamingCaret /> : null;
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block
key={i}
block={block}
highlightTerms={highlightTerms}
caret={caret && i === blocks.length - 1 ? caret : null}
/>
))}
{blocks.length === 0 && caret}
</div>
);
}
function StreamingCaret() {
return (
<span
aria-hidden
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
/>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({
block,
highlightTerms,
caret,
}: {
block: BlockNode;
highlightTerms?: string[];
caret?: ReactNode;
}) {
switch (block.type) {
case "code":
return (
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>
{block.content}
{caret}
</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return (
<Tag className={sizes[Tag]}>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</Tag>
);
}
case "hr":
return (
<>
<hr className="border-border" />
{caret}
</>
);
case "list": {
const Tag = block.ordered ? "ol" : "ul";
const last = block.items.length - 1;
return (
<Tag
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
>
{block.items.map((item, i) => (
<li key={i}>
<InlineContent text={item} highlightTerms={highlightTerms} />
{i === last ? caret : null}
</li>
))}
</Tag>
);
}
case "paragraph":
return (
<p>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</p>
);
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern =
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({
text,
highlightTerms,
}: {
text: string;
highlightTerms?: string[];
}) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return (
<HighlightedText
key={i}
text={node.content}
terms={highlightTerms}
/>
);
case "code":
return (
<code
key={i}
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
>
{node.content}
</code>
);
case "bold":
return (
<strong key={i} className="font-semibold">
<HighlightedText text={node.content} terms={highlightTerms} />
</strong>
);
case "italic":
return (
<em key={i}>
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
case "link": {
// Security: only render http(s)/mailto links. Other schemes
// (javascript:, data:, vbscript:) are dropped to plain text so a
// crafted link in agent/message content can't execute on click.
const href = node.href.trim();
if (!/^(https?:|mailto:)/i.test(href)) {
return (
<HighlightedText
key={i}
text={node.text}
terms={highlightTerms}
/>
);
}
return (
<a
key={i}
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
}
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning px-0.5">
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
@@ -0,0 +1,438 @@
// @vitest-environment jsdom
// Tests for the NS-656 memory-pressure banner: trigger selection,
// severity precedence, dismissal semantics, and escalation re-opening.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ReactNode } from "react";
import { I18nProvider } from "@/i18n";
import { MemoryPressureBanner } from "./MemoryPressureBanner";
import type {
StatusResponse,
MemoryPressureStatus,
DiskPressureStatus,
} from "@/lib/api";
let container: HTMLDivElement;
let root: Root;
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
async function rerender(ui: ReactNode) {
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
function statusWith(memory: MemoryPressureStatus | undefined): StatusResponse {
return { memory } as StatusResponse;
}
function statusWithDisk(
disk: DiskPressureStatus | undefined,
memory?: MemoryPressureStatus,
): StatusResponse {
return { memory, disk } as StatusResponse;
}
function banner(): HTMLElement | null {
return container.querySelector('[data-testid="memory-pressure-banner"]');
}
describe("MemoryPressureBanner", () => {
it("renders nothing for null status or healthy memory", async () => {
await render(<MemoryPressureBanner status={null} />);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "ok" })} />,
);
expect(banner()).toBeNull();
// Older gateways: no memory block at all.
await rerender(<MemoryPressureBanner status={statusWith(undefined)} />);
expect(banner()).toBeNull();
});
it("renders nothing for unknown pressure (absence of evidence)", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
});
it("shows the elevated warning", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()?.textContent).toContain("running low on memory");
});
it("shows the OOM-restart notice even when current pressure is ok", async () => {
await render(
<MemoryPressureBanner
status={statusWith({ pressure: "ok", last_boot_suspected_oom: true })}
/>,
);
expect(banner()?.textContent).toContain(
"restarted unexpectedly, most likely because it ran out of memory",
);
});
it("critical pressure outranks the OOM-restart notice", async () => {
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
last_boot_suspected_oom: true,
})}
/>,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("dismissal hides the banner and persists across re-renders", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()).toBeNull();
});
it("escalation to critical re-opens a dismissed banner", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("a NEW OOM restart (different boot_id) re-opens a dismissed OOM notice", async () => {
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Same boot: stays dismissed.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
expect(banner()).toBeNull();
// The gateway OOM-loops and boots again: new incident, banner returns.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T02:00:00+00:00",
})}
/>,
);
expect(banner()?.textContent).toContain("restarted unexpectedly");
});
it("a gateway reboot (boot_id change) re-opens a dismissed live-pressure banner", async () => {
// Edge case: dismiss critical, gateway restarts, next poll is STILL
// critical with no observed "ok" in between. The new boot is a new
// incident — without boot-scoped keys it would stay hidden (and mask
// the OOM notice too, since critical takes precedence).
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Same boot, still critical: stays dismissed.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
expect(banner()).toBeNull();
// Rebooted, still critical: new incident, banner returns.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T02:00:00+00:00",
})}
/>,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("recovery to ok resets a dismissed live-pressure banner for the next episode", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Confirmed recovery clears live dismissals...
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "ok" })} />,
);
expect(banner()).toBeNull();
// ...so the NEXT critical episode surfaces again.
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("unknown pressure (stale heartbeat) does NOT reset live dismissals", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()).toBeNull();
});
it("renders nothing for healthy or unknown disk", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "ok", free_mb: 5000 })}
/>,
);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWithDisk({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
});
it("shows the disk-critical warning with free-space detail", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 120 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
expect(banner()?.textContent).toContain("(120 MB free)");
});
it("shows the disk-elevated warning", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
expect(banner()?.textContent).toContain("disk is filling up");
});
it("disk critical outranks memory critical", async () => {
// Imminent data loss beats imminent restart.
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 100 },
{ pressure: "critical" },
)}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("memory OOM notice outranks disk elevated", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "ok", last_boot_suspected_oom: true },
)}
/>,
);
expect(banner()?.textContent).toContain("restarted unexpectedly");
});
it("dismissing a disk warning does not mask a later memory warning", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "elevated" },
)}
/>,
);
expect(banner()?.textContent).toContain("running low on memory");
});
it("disk escalation to critical re-opens a dismissed disk banner", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("disk recovery to ok resets disk dismissals for the next episode", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
// User frees space (or grows the disk)...
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "ok", free_mb: 8000 })}
/>,
);
expect(banner()).toBeNull();
// ...then the disk fills again: new episode surfaces.
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("disk recovery does NOT reset memory dismissals (and vice versa)", async () => {
// Dismiss a memory warning while disk is also elevated.
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "critical" },
)}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
// Trigger shown is memory critical (outranks disk elevated) — dismiss it.
await act(async () => dismiss.click());
// Disk warning is next in line and has its own key, so it surfaces...
expect(banner()?.textContent).toContain("disk is filling up");
const dismissDisk = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismissDisk.click());
expect(banner()).toBeNull();
// Disk recovers; memory still critical — its dismissal must survive.
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "ok", free_mb: 8000 },
{ pressure: "critical" },
)}
/>,
);
expect(banner()).toBeNull();
});
it("a gateway reboot (boot_id change) re-opens a dismissed disk banner", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 150 },
{ pressure: "ok", boot_id: "2026-08-13T01:00:00+00:00" },
)}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Restart with the disk still full: new boot, warning returns.
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 150 },
{ pressure: "ok", boot_id: "2026-08-13T02:00:00+00:00" },
)}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
});
+186
View File
@@ -0,0 +1,186 @@
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import type { StatusResponse } from "@/lib/api";
import { useI18n } from "@/i18n";
/**
* App-wide warning banner for resource trouble (NS-656): memory pressure
* and disk exhaustion.
*
* Triggers, worst-first:
* 1. Disk critical — the HERMES_HOME volume is nearly full. Worst because
* the failure mode is silent data loss (SQLite writes failing, sessions
* and config not persisting), not just a restart (OOF-2/OOF-107).
* 2. Memory critical — the gateway's heartbeat shows system memory in the
* `critical` band right now.
* 3. Post-mortem — the previous gateway life died uncleanly and its last
* heartbeat showed near-exhausted memory (`last_boot_suspected_oom`).
* This is a heuristic, not proof the OOM killer acted — copy says so.
* 4. Disk elevated / 5. memory elevated — early warnings.
*
* All of this previously died in server-side log files; a hosted agent
* could be OOM-killed hourly or fill its disk completely while the
* dashboard looked healthy.
*
* Dismissal semantics (session-scoped, sessionStorage):
* - EVERY dismissal key embeds the reporting boot (`boot_id`), so a gateway
* restart invalidates all of them. Without this, dismissing `critical`,
* rebooting, and coming back still-critical would hide the NEW incident —
* and mask the OOM notice too, since critical takes precedence. Disk
* entries share the scheme: disk state has no boot relationship, but
* re-surfacing a still-full disk after a restart is the desired behavior.
* - Within one boot, dismissal masks only the dismissed trigger; escalation
* (elevated → critical, in either domain) re-opens immediately, and a
* confirmed recovery (pressure back to "ok", not "unknown") clears that
* domain's live dismissals so the NEXT episode in the same boot surfaces
* again.
*/
const STORAGE_KEY = "memoryBannerDismissed";
const MEMORY_LIVE_TRIGGERS = ["critical", "elevated"];
const DISK_LIVE_TRIGGERS = ["disk_critical", "disk_elevated"];
function readDismissed(): string[] {
try {
const parsed: unknown = JSON.parse(
sessionStorage.getItem(STORAGE_KEY) ?? "[]",
);
// Pre-incident-key builds stored a bare trigger string; JSON.parse
// throws on those, landing in the catch — a clean reset, not a crash.
return Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [];
} catch {
return [];
}
}
function writeDismissed(entries: string[]) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* ignore */
}
}
function entryMatches(triggers: string[]) {
return (entry: string) =>
triggers.some((sev) => entry === sev || entry.startsWith(`${sev}:`));
}
export function MemoryPressureBanner({
status,
}: {
status: StatusResponse | null;
}) {
const { t } = useI18n();
const memory = status?.memory;
const disk = status?.disk;
const pressure = memory?.pressure;
const diskPressure = disk?.pressure;
const [dismissed, setDismissed] = useState<string[]>(readDismissed);
// Recovery reset (render-time state adjustment — the sanctioned React
// pattern for reacting to prop changes without an effect): once a
// domain's live pressure is demonstrably back to "ok", any dismissed
// live entries for that domain describe a PAST episode — drop them so
// the next one isn't silently hidden. "unknown" (stale/absent sample)
// is absence of evidence, not recovery, and clears nothing. Each domain
// recovers independently: a fixed disk must not un-dismiss a memory
// warning or vice versa. Cross-boot invalidation doesn't need handling
// here: boot_id is part of every dismissal key.
const [prevPressure, setPrevPressure] = useState(pressure);
const [prevDiskPressure, setPrevDiskPressure] = useState(diskPressure);
if (pressure !== prevPressure || diskPressure !== prevDiskPressure) {
setPrevPressure(pressure);
setPrevDiskPressure(diskPressure);
const recovered: Array<(entry: string) => boolean> = [];
if (pressure === "ok") recovered.push(entryMatches(MEMORY_LIVE_TRIGGERS));
if (diskPressure === "ok") recovered.push(entryMatches(DISK_LIVE_TRIGGERS));
if (recovered.length > 0) {
const isRecovered = (entry: string) =>
recovered.some((match) => match(entry));
if (dismissed.some(isRecovered)) {
const next = dismissed.filter((entry) => !isRecovered(entry));
writeDismissed(next);
setDismissed(next);
}
}
}
// Active triggers, worst-first. Disk critical outranks memory critical:
// imminent data loss beats imminent restart. Dismissal cascades — hiding
// the top trigger surfaces the next one rather than silencing everything.
const activeTriggers: string[] = [];
if (diskPressure === "critical") activeTriggers.push("disk_critical");
if (memory?.pressure === "critical") activeTriggers.push("critical");
if (memory?.last_boot_suspected_oom) activeTriggers.push("oom_restart");
if (diskPressure === "elevated") activeTriggers.push("disk_elevated");
if (memory?.pressure === "elevated") activeTriggers.push("elevated");
// Every dismissal is scoped to the reporting boot: `boot_id` changes on
// each gateway life, so restarts invalidate prior dismissals of ANY kind.
// A missing boot_id (degraded payload / pre-NS-656 image) degrades to a
// shared per-severity bucket — old behavior, never a crash.
const keyFor = (trig: string) => `${trig}:${memory?.boot_id ?? "unknown"}`;
const trigger =
activeTriggers.find((trig) => !dismissed.includes(keyFor(trig))) ?? null;
const dismissKey = trigger ? keyFor(trigger) : null;
if (!trigger || !dismissKey) return null;
const dismiss = () => {
setDismissed((prev) => {
const next = prev.includes(dismissKey) ? prev : [...prev, dismissKey];
writeDismissed(next);
return next;
});
};
const critical = trigger === "critical" || trigger === "disk_critical";
const diskFreeLabel =
disk?.free_mb != null ? ` (${Math.round(disk.free_mb)} MB free)` : "";
const message =
trigger === "disk_critical"
? `${
t.app.diskCriticalBanner ??
"Your agent's disk is almost full. New messages, memories, and settings may fail to save."
}${diskFreeLabel}`
: trigger === "disk_elevated"
? `${
t.app.diskElevatedBanner ??
"Your agent's disk is filling up. Consider clearing old sessions or expanding its storage."
}${diskFreeLabel}`
: trigger === "oom_restart"
? (t.app.memoryOomRestartBanner ??
"Your agent restarted unexpectedly, most likely because it ran out of memory. Long sessions and many concurrent tasks increase memory use.")
: critical
? (t.app.memoryCriticalBanner ??
"Your agent is almost out of memory and may restart. Consider closing idle sessions or upgrading its memory.")
: (t.app.memoryElevatedBanner ??
"Your agent is running low on memory.");
return (
<div
role="alert"
data-testid="memory-pressure-banner"
className={`flex items-center gap-2 border-b px-4 py-1.5 text-xs ${
critical
? "border-red-500/40 bg-red-500/10 text-red-300"
: "border-amber-500/40 bg-amber-500/10 text-amber-300"
}`}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">{message}</span>
<button
type="button"
aria-label={t.app.dismiss ?? "Dismiss"}
onClick={dismiss}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
currentModel: string;
/** Bumped after config saves to trigger re-fetch */
refreshKey?: number;
}
export function ModelInfoCard({
currentModel,
refreshKey = 0,
}: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
if (!currentModel) return;
// Re-fetch when model changes OR when refreshKey bumps (after save)
const fetchKey = `${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
setLoading(true);
api
.getModelInfo()
.then(setInfo)
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [currentModel, refreshKey]);
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Spinner className="text-xs" />
Loading model info…
</div>
);
}
if (!info || !info.model || info.effective_context_length <= 0) return null;
const caps = info.capabilities;
const hasCaps = caps && Object.keys(caps).length > 0;
return (
<div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
<span className="font-medium">Context Window</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500 text-xs">
(override — auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-text-tertiary text-xs">
auto-detected
</span>
)}
</div>
</div>
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lightbulb className="h-3.5 w-3.5" />
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
</span>
</div>
)}
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
<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>
);
}
+680
View File
@@ -0,0 +1,680 @@
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 { ConfirmDialog } from "@/components/ConfirmDialog";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, RefreshCw, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { fuzzyRank } from "@/lib/fuzzy";
import { queryMatchesProviderOnly } from "@/lib/model-picker-filter";
import { modelSearchText } from "@/lib/model-search-text";
/**
* 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 applies the choice via
* `config.set`, so expensive-model confirmation can happen before switch.
*
* 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 ExpensiveModelConfirmResponse {
confirm_message?: string;
confirm_required?: boolean;
warning?: string;
}
interface ConfigSetResponse extends ExpensiveModelConfirmResponse {
value?: string;
}
interface PendingExpensiveConfirm {
message: string;
model: string;
persistGlobal: boolean;
provider: string;
}
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?(options?: { refresh?: boolean }): Promise<ModelOptionsResponse>;
onApply?(args: {
confirmExpensiveModel?: boolean;
provider: string;
model: string;
persistGlobal: boolean;
}):
| Promise<ExpensiveModelConfirmResponse | void>
| ExpensiveModelConfirmResponse
| 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 [refreshing, setRefreshing] = useState(false);
const [pendingConfirm, setPendingConfirm] =
useState<PendingExpensiveConfirm | null>(null);
const closedRef = useRef(false);
const applyOptions = (r: ModelOptionsResponse) => {
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug((prev) => {
if (prev && next.some((p) => p.slug === prev)) return prev;
return (next.find((p) => p.is_current) ?? next[0])?.slug ?? "";
});
setSelectedModel("");
};
const requestOptions = (refresh = false) =>
standalone
? (loader as (options?: { refresh?: boolean }) => Promise<ModelOptionsResponse>)({
refresh,
})
: (gw as GatewayClient).request<ModelOptionsResponse>(
"model.options",
{
...(sessionId ? { session_id: sessionId } : {}),
...(refresh ? { refresh: true } : {}),
// Dashboard picker mirrors the TUI: full provider universe with
// setup warnings. The backend now defaults to the configured
// subset (#56974), so opt into unconfigured rows explicitly.
include_unconfigured: true,
},
);
const refreshOptions = () => {
setError(null);
setRefreshing(true);
requestOptions(true)
.then((r) => {
if (closedRef.current) return;
applyOptions(r);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (closedRef.current) return;
setRefreshing(false);
});
};
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
requestOptions()
.then((r) => {
if (closedRef.current) return;
applyOptions(r);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (closedRef.current) return;
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 trimmedQuery = query.trim();
// Fuzzy-ranked providers: match on name + slug + the provider's model ids so
// typing a model name surfaces its provider (preserves the prior behaviour
// where a model match also revealed its provider).
//
// With no query, float providers that actually have models to the top
// (stable within each group). A fresh install lists ~40 providers and only
// a couple are configured — burying "OpenRouter · 37 models" under a wall
// of "0 models" rows made the picker feel broken.
const filteredProviders = useMemo(() => {
const ranked = fuzzyRank(
providers,
trimmedQuery,
(p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`,
).map((r) => r.item);
if (trimmedQuery) return ranked;
const withModels = ranked.filter((p) => (p.models ?? []).length > 0);
const withoutModels = ranked.filter((p) => (p.models ?? []).length === 0);
return [...withModels, ...withoutModels];
}, [providers, trimmedQuery]);
// A query that matched the SELECTED provider by name/slug (not its models)
// located that provider — it shouldn't also hide that provider's models
// just because their ids don't share a substring with the provider name
// (e.g. typing "aws" to find "AWS Build" then finding zero of its Claude
// model ids contain "aws"). Fall back to an unfiltered model list in that
// case; a query that also matches a model id keeps filtering normally.
const queryMatchesSelectedProviderOnly = useMemo(
() => queryMatchesProviderOnly(selectedProvider, models, trimmedQuery),
[trimmedQuery, selectedProvider, models],
);
// Fuzzy-ranked models carrying the matched character positions so the model
// list can highlight why each entry matched. modelSearchText adds aliases
// for brand-less wire ids (e.g. Kimi Coding `k3` ↔ search "kimi").
const filteredModels = useMemo(
() =>
fuzzyRank(
models,
queryMatchesSelectedProviderOnly ? "" : trimmedQuery,
modelSearchText,
).map((r) => ({
model: r.item,
// Positions may land in alias suffixes — keep only in-id highlights.
positions: r.positions.filter((i) => i >= 0 && i < r.item.length),
})),
[models, trimmedQuery, queryMatchesSelectedProviderOnly],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const applySelection = async (
confirmExpensiveModel = false,
forced?: PendingExpensiveConfirm,
) => {
const providerSlug = forced?.provider ?? selectedProvider?.slug ?? "";
const model = forced?.model ?? selectedModel;
const shouldPersistGlobal = forced?.persistGlobal ?? persistGlobal;
if (!providerSlug || !model || applying) return;
if (standalone && onApply) {
setApplying(true);
try {
const result = await onApply({
confirmExpensiveModel,
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (gw && sessionId) {
setApplying(true);
try {
const global = shouldPersistGlobal ? " --global" : "";
const result = await gw.request<ConfigSetResponse>("config.set", {
confirm_expensive_model: confirmExpensiveModel,
key: "model",
session_id: sessionId,
value: `${model} --provider ${providerSlug}${global}`,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (onSubmit) {
const global = shouldPersistGlobal ? " --global" : "";
onSubmit(`/model ${model} --provider ${providerSlug}${global}`);
onClose();
}
};
const confirm = () => {
if (!canConfirm) return;
void applySelection();
};
// 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 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={trimmedQuery}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
void applySelection(false, {
provider: selectedProvider?.slug ?? "",
model: m,
persistGlobal,
message: "",
});
}}
/>
</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={refreshOptions}
disabled={applying || loading || refreshing}
>
{refreshing ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh Models
</Button>
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>
<Button onClick={confirm} disabled={!canConfirm}>
{applying ? <Spinner /> : "Switch"}
</Button>
</div>
</footer>
</div>
<ConfirmDialog
open={!!pendingConfirm}
title="Expensive Model Warning"
description={pendingConfirm?.message}
destructive
confirmLabel="Switch anyway"
cancelLabel="Cancel"
loading={applying}
onCancel={() => setPendingConfirm(null)}
onConfirm={() => {
const pending = pendingConfirm;
if (!pending) return;
setPendingConfirm(null);
void applySelection(true, pending);
}}
/>
</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: { model: string; positions: number[] }[];
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(({ model: m, positions }) => {
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">
<HighlightedText text={m} positions={positions} />
</span>
{isCurrent && <CurrentTag />}
</ListItem>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-display text-xs tracking-wider text-primary shrink-0">
current
</span>
);
}
/**
* Render `text` with the characters at `positions` emphasised, so users can
* see which characters their fuzzy query matched. Positions are indices into
* `text`; out-of-range indices are ignored.
*/
function HighlightedText({
text,
positions,
}: {
text: string;
positions: number[];
}) {
if (!positions.length) {
return <>{text}</>;
}
const hit = new Set(positions);
return (
<>
{Array.from(text).map((ch, i) =>
hit.has(i) ? (
<mark
key={i}
className="bg-transparent text-primary font-semibold underline underline-offset-2"
>
{ch}
</mark>
) : (
<span key={i}>{ch}</span>
),
)}
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { ConfirmDialog } from "@/components/ConfirmDialog";
/**
* Confirm + full-page reload after a model change.
*
* Changing the main model persists to config.yaml, but the RUNNING chat keeps
* its model until its session is rebuilt. A full reload (fresh PTY session that
* boots its agent from the just-saved config) is the reliable way to apply it —
* the in-place hot-swap and partial remount both proved unreliable. We confirm
* first because the reload starts a fresh chat (the current one stays resumable
* in Sessions and the agent's memory is kept).
*
* Shared by the chat sidebar picker and the Models page so both behave
* identically. `model` is the short model name awaiting confirmation, or null
* when the dialog is closed.
*/
export function ModelReloadConfirm({
model,
description,
onCancel,
}: {
model: string | null;
/** Override the default body copy (e.g. the Models-page phrasing). */
description?: string;
onCancel: () => void;
}) {
return (
<ConfirmDialog
open={model !== null}
title="Switch model?"
description={
description ??
`Switching to ${model ?? ""} starts a fresh chat. Your current chat stays in your Sessions list and the agent's memory is kept. Reload now to apply it?`
}
confirmLabel="Reload"
onConfirm={() => window.location.reload()}
onCancel={onCancel}
/>
);
}
+210
View File
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
// OAuthLoginModal expiry behaviour: when the sign-in window lapses locally,
// the modal must surface the backend poller's actionable message when it has
// one, keep polling when the backend still considers the session pending
// (clock skew), and fall back to guidance naming the common cause instead of
// the old bare "Session expired".
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/i18n";
const apiMocks = vi.hoisted(() => ({
cancelOAuthSession: vi.fn(async () => ({ ok: true })),
pollOAuthSession: vi.fn(),
startOAuthLogin: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
api: {
cancelOAuthSession: apiMocks.cancelOAuthSession,
pollOAuthSession: apiMocks.pollOAuthSession,
startOAuthLogin: apiMocks.startOAuthLogin,
},
}));
vi.mock("@/lib/clipboard", () => ({
copyTextToClipboard: vi.fn(async () => true),
}));
import { OAuthLoginModal } from "./OAuthLoginModal";
let container: HTMLDivElement;
let root: Root;
const provider = {
cli_command: "hermes login nous",
disconnectable: true,
docs_url: "https://example.com/nous",
flow: "device_code" as const,
id: "nous",
name: "Nous Portal",
status: { logged_in: false },
};
function deviceStart(expiresIn: number) {
return {
expires_in: expiresIn,
flow: "device_code",
poll_interval: 5,
session_id: "sess-1",
user_code: "ABCD-EFGH",
verification_url: "https://portal.example/device",
};
}
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
/** Fast-forward the countdown (1s ticks) until it lapses. */
async function exhaustCountdown(seconds: number) {
for (let i = 0; i <= seconds; i++) {
await act(async () => {
vi.advanceTimersByTime(1000);
});
}
await act(async () => {});
}
beforeEach(() => {
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
apiMocks.startOAuthLogin.mockReset();
apiMocks.pollOAuthSession.mockReset();
apiMocks.cancelOAuthSession.mockReset().mockResolvedValue({ ok: true });
// The steady state during the countdown: still pending.
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "pending",
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
root?.unmount();
container?.remove();
});
describe("OAuthLoginModal local expiry", () => {
it("surfaces the backend error_message when the session lapsed", async () => {
apiMocks.startOAuthLogin.mockResolvedValue(deviceStart(3));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
// The final poll flips to the backend's enriched message.
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "error",
error_message:
"Timed out waiting for device authorization. Portal sign-in is required before the device code can be approved.",
});
await exhaustCountdown(5);
await act(async () => {
vi.advanceTimersByTime(2000);
});
await act(async () => {});
expect(container.textContent).toContain(
"Portal sign-in is required",
);
});
it("keeps polling when the backend still reports pending (clock skew)", async () => {
apiMocks.startOAuthLogin.mockResolvedValue(deviceStart(3));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
await exhaustCountdown(5);
await act(async () => {});
// No error box: the modal is still in the polling phase.
expect(container.textContent).toContain("ABCD-EFGH");
expect(apiMocks.pollOAuthSession.mock.calls.length).toBeGreaterThan(0);
});
it("falls back to guidance naming the stalled-tab cause (pkce flow, no poll)", async () => {
apiMocks.startOAuthLogin.mockResolvedValue({
auth_url: "https://portal.example/auth",
expires_in: 3,
flow: "pkce",
session_id: "sess-1",
});
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
await exhaustCountdown(6);
await act(async () => {});
expect(container.textContent).toContain("stalled in the opened tab");
// PKCE has no poll endpoint call at expiry.
expect(apiMocks.pollOAuthSession).not.toHaveBeenCalled();
});
it("a Retry after expiry starts fresh instead of insta-lapsing", async () => {
apiMocks.startOAuthLogin.mockResolvedValueOnce(deviceStart(2));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "error",
error_message: "expired_token: code expired",
});
await exhaustCountdown(4);
await act(async () => {
vi.advanceTimersByTime(2000);
});
await act(async () => {});
expect(container.textContent).toContain("expired_token");
// Retry: new start response, fresh countdown.
apiMocks.startOAuthLogin.mockResolvedValueOnce(deviceStart(60));
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "pending",
});
const retry = [...container.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Retry"),
);
expect(retry).toBeTruthy();
await act(async () => retry!.click());
await act(async () => {});
// Well within the new window: still showing the code, no error.
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(container.textContent).toContain("ABCD-EFGH");
});
});
+427
View File
@@ -0,0 +1,427 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, X, Check, Copy } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
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 { copyTextToClipboard } from "@/lib/clipboard";
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 [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">(
"idle",
);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);
const copyResetTimer = 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);
if (copyResetTimer.current !== null)
window.clearTimeout(copyResetTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// When the sign-in window lapses locally, the backend poller usually has
// the real story (e.g. "Portal sign-in is required before the device code
// can be approved") — but its next poll tick is up to 2s away. Rather than
// preempt it with a bare "expired", ask the poll endpoint once, then fall
// back to guidance that names the common cause (sign-in stalled in the
// opened tab) instead of a dead-end.
const handleLocalExpiry = async () => {
if (!isMounted.current) return;
let backendMessage: string | null = null;
if (start && start.flow === "device_code") {
try {
const resp = await api.pollOAuthSession(provider.id, start.session_id);
if (resp.error_message) backendMessage = resp.error_message;
else if (resp.status === "pending") {
// Still pending server-side: the local countdown fired early
// (clock skew or a stalled tab). Keep the session alive for the
// poller instead of killing it with a wrong "expired".
if (isMounted.current) setPhase("polling");
return;
}
} catch {
// Poll endpoint unreachable — fall through to generic guidance.
}
}
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(backendMessage || t.oauth.sessionExpiredNoError);
};
// Tick the countdown down to zero — never further. What happens AT zero
// is owned by the lapse effect below, so the updater stays pure.
useEffect(() => {
if (secondsLeft === null) return;
if (secondsLeft <= 0) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => (s !== null && s > 0 ? s - 1 : 0));
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase]);
useEffect(() => {
if (secondsLeft !== 0) return;
if (phase === "approved" || phase === "error") return;
void handleLocalExpiry();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secondsLeft, phase]);
// 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")}`;
};
const handleCopyDeviceCode = async (code: string) => {
if (copyResetTimer.current !== null) {
window.clearTimeout(copyResetTimer.current);
copyResetTimer.current = null;
}
const copied = await copyTextToClipboard(code);
if (!isMounted.current) return;
setCopyStatus(copied ? "copied" : "failed");
copyResetTimer.current = window.setTimeout(() => {
if (isMounted.current) setCopyStatus("idle");
copyResetTimer.current = null;
}, 2000);
};
const deviceCode = start?.flow === "device_code" ? start.user_code : "";
const verificationUrl =
start?.flow === "device_code" ? start.verification_url : "";
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 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">
{deviceCode}
</code>
<Button
size="sm"
outlined
className="shrink-0 uppercase"
onClick={() => void handleCopyDeviceCode(deviceCode)}
prefix={
copyStatus === "copied" ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)
}
aria-label={t.oauth.copyCode}
>
{copyStatus === "copied" ? t.oauth.copied : t.oauth.copyCode}
</Button>
</div>
{copyStatus === "failed" && (
<p className="text-xs text-destructive">
{t.oauth.copyFailed}
</p>
)}
<a
href={verificationUrl}
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("");
setSecondsLeft(null);
setPhase("starting");
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(
resp.flow === "device_code"
? "polling"
: "awaiting_user",
);
if (resp.flow === "pkce") {
window.open(
resp.auth_url,
"_blank",
"noopener,noreferrer",
);
} else {
window.open(
resp.verification_url,
"_blank",
"noopener,noreferrer",
);
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
}}
>
{t.common.retry}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ShieldCheck,
ShieldOff,
ExternalLink,
RefreshCw,
Terminal,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
interface Props {
onError?: (msg: string) => void;
onSuccess?: (msg: string) => void;
}
function formatExpiresAt(
expiresAt: string | null | undefined,
expiresInTemplate: string,
): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
if (Number.isNaN(dt.getTime())) return null;
const now = Date.now();
const diff = dt.getTime() - now;
if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000);
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
const hours = Math.floor(mins / 60);
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
const days = Math.floor(hours / 24);
return expiresInTemplate.replace("{time}", `${days}d`);
} catch {
return null;
}
}
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const [disconnectTarget, setDisconnectTarget] =
useState<OAuthProvider | null>(null);
const { t } = useI18n();
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const refresh = useCallback(() => {
setLoading(true);
api
.getOAuthProviders()
.then((resp) => setProviders(resp.providers))
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
setBusyId(provider.id);
setDisconnectTarget(null);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
refresh();
} catch (e) {
onError?.(`${t.oauth.disconnect} failed: ${e}`);
} finally {
setBusyId(null);
}
};
const connectedCount =
providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.oauth.providerLogins}
</CardTitle>
</div>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={refresh}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>
<CardDescription>
{t.oauth.description
.replace("{connected}", String(connectedCount))
.replace("{total}", String(totalCount))}
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{t.oauth.noProviders}
</p>
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(
p.status.expires_at,
t.oauth.expiresIn,
);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
) : (
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge
tone="outline"
className="text-xs tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge tone="success" className="text-xs">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge tone="destructive" className="text-xs">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-xs">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<span className="truncate text-xs font-mono-ui text-text-secondary">
<span className="text-text-tertiary">token </span>
{p.status.token_preview}
{p.status.source_label && (
<span className="text-text-tertiary">
{" "}
· {p.status.source_label}
</span>
)}
</span>
)}
{!p.status.logged_in && (
<>
<span className="text-xs text-text-secondary">
{t.oauth.notConnected.split("{command}")[0].trimEnd()}
{t.oauth.notConnected.split("{command}")[1] ?? ""}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<code className="font-courier truncate text-xs opacity-60">
{p.cli_command}
</code>
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
</div>
</>
)}
{p.status.error && (
<span className="text-xs text-destructive">
{p.status.error}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
href={p.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button ghost size="icon">
<ExternalLink />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
className="uppercase"
onClick={() => setLoginFor(p)}
>
{t.oauth.login}
</Button>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
outlined
className="uppercase"
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : undefined}
>
{t.oauth.disconnect}
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-xs text-text-tertiary italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally}
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
{loginFor && (
<OAuthLoginModal
provider={loginFor}
onClose={() => {
setLoginFor(null);
refresh();
}}
onSuccess={(msg) => onSuccess?.(msg)}
onError={(msg) => onError?.(msg)}
/>
)}
<ConfirmDialog
open={disconnectTarget !== null}
onCancel={() => setDisconnectTarget(null)}
onConfirm={() => {
if (disconnectTarget) void handleDisconnect(disconnectTarget);
}}
title={`${t.oauth.disconnect} ${disconnectTarget?.name ?? ""}?`}
description={`This will remove the stored OAuth tokens for ${disconnectTarget?.name ?? "this provider"}. You will need to re-authenticate to use it again.`}
destructive
confirmLabel={t.oauth.disconnect}
/>
</Card>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { AlertTriangle, PowerOff, 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" | "outline"; label: string }
> = {
connected: { tone: "success", label: t.status.connected },
disconnected: { tone: "warning", label: t.status.disconnected },
disabled: { tone: "outline", label: t.status.disabled ?? "Disabled" },
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
: info.state === "disabled"
? PowerOff
: 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"
: info.state === "disabled"
? "text-muted-foreground"
: "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 ${
info.state === "disabled"
? "text-muted-foreground"
: "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][];
}
+27
View File
@@ -0,0 +1,27 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
/**
* App-wide amber banner shown while the global switcher targets a profile
* OTHER than the dashboard's own — every management write (config, keys,
* skills, MCPs, model) and new Chat sessions land in that profile.
*/
export function ProfileScopeBanner() {
const { profile, currentProfile } = useProfileScope();
const { t } = useI18n();
if (!profile || profile === currentProfile) return null;
return (
<div className="flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.app.managingProfileBanner ??
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
).replace("{name}", profile)}
</span>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useMemo } from "react";
import { Users } from "lucide-react";
import {
Select,
SelectOption,
} from "@nous-research/ui/ui/components/select";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* The machine dashboard's single write-target selector.
*
* Rendered in the sidebar above the nav. Every management page (Config,
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: ProfileSwitcherProps) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
const currentDashboardLabel = useMemo(
() =>
(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
),
[currentProfile, t.app.currentProfileOption],
);
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
const managingLabel = t.app.managingProfile ?? "Managing profile";
return (
<div
className={cn(
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={managingLabel}
>
<Users
className={cn(
"h-3.5 w-3.5 shrink-0",
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<Select
className={cn(
"min-w-0 flex-1",
collapsed && "lg:hidden",
"[&_button]:h-7 [&_button]:border-border [&_button]:bg-background [&_button]:px-2 [&_button]:text-xs",
"[&_button]:font-sans [&_button]:normal-case [&_button]:tracking-normal",
"[&_[role=listbox]>div]:font-sans [&_[role=listbox]>div]:text-xs",
"[&_[role=listbox]>div]:normal-case [&_[role=listbox]>div]:tracking-normal",
isOther &&
"[&_button]:border-amber-500/50 [&_button]:text-amber-300",
)}
id="hermes-profile-switcher"
onValueChange={setProfile}
value={profile}
>
<SelectOption value="">{currentDashboardLabel}</SelectOption>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<SelectOption key={name} value={name}>
{name}
</SelectOption>
))}
</Select>
{collapsed && <span className="sr-only">{managed}</span>}
</div>
);
}
interface ProfileSwitcherProps {
collapsed?: boolean;
}
+125
View File
@@ -0,0 +1,125 @@
/**
* ReasoningPicker — sets the main model's reasoning effort from the dashboard
* Chat sidebar, mirroring the desktop app's composer effort radio.
*
* The dashboard previously only showed a read-only "Reasoning" capability
* badge (see ModelInfoCard) with no way to actually choose the effort level —
* unlike the desktop app, which exposes a radio in its model menu. This closes
* that parity gap.
*
* Storage: the effort persists to config.yaml at `agent.reasoning_effort`
* (the same key the TUI's `/reasoning <level>` command and the desktop radio
* write). We read the whole config and write it back — the established
* single-key pattern on the dashboard (see ConfigPage) — so the value lands in
* the config the agent boots a fresh chat from. As with the model picker, the
* running chat session adopts the change on the next `/new` or page reload;
* we surface that hint rather than forcing a reload here.
*
* Profile scoping: the sidebar passes the chat profile explicitly, so this
* reads/writes the same config the chat PTY was launched from.
*/
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Brain } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import {
EFFORT_OPTIONS,
normalizeEffort,
VALID_EFFORTS,
} from "@/lib/reasoning-effort";
interface ReasoningPickerProps {
/** Current model string from config — re-reads the saved effort when it
* changes (a different model may have been selected). */
currentModel: string;
/** Profile whose config should be read/written. */
profile?: string;
/** Bumped after the model picker saves, to re-read config in lockstep. */
refreshKey?: number;
/** Called after a successful change so the sidebar can show an "apply on
* /new or reload" notice, matching the model-switch UX. */
onChanged?: (effort: string) => void;
}
export function ReasoningPicker({
currentModel,
profile,
refreshKey = 0,
onChanged,
}: ReasoningPickerProps) {
const [effort, setEffort] = useState("medium");
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
const fetchKey = `${profile ?? ""}:${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
void api
.getConfig(profile)
.then((cfg) => {
const agent = (cfg?.agent as Record<string, unknown> | undefined) ?? {};
setEffort(normalizeEffort(agent.reasoning_effort));
setLoaded(true);
})
.catch(() => {
// Best-effort: keep the last known value rather than blanking it.
setLoaded(true);
});
}, [currentModel, profile, refreshKey]);
const onSelect = useCallback(
(next: string) => {
if (!VALID_EFFORTS.has(next) || next === effort) return;
const prev = effort;
setEffort(next); // optimistic
setSaving(true);
// Read-modify-write the whole config — the dashboard's single-key save
// pattern — so we never clobber sibling keys. `saveConfig` PUTs the full
// object the agent boots from.
void api
.getConfig(profile)
.then((cfg) => {
const base = (cfg ?? {}) as Record<string, unknown>;
const agent =
base.agent && typeof base.agent === "object"
? { ...(base.agent as Record<string, unknown>) }
: {};
agent.reasoning_effort = next;
return api.saveConfig({ ...base, agent }, profile);
})
.then(() => {
onChanged?.(next);
})
.catch(() => {
setEffort(prev); // revert on failure
})
.finally(() => setSaving(false));
},
[effort, onChanged, profile],
);
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 text-text-tertiary">
<Brain className="h-3.5 w-3.5" />
<span className="text-display tracking-wider">reasoning</span>
</div>
<Select
className="ml-auto min-w-0"
disabled={!loaded || saving}
onValueChange={onSelect}
value={effort}
>
{EFFORT_OPTIONS.map((opt) => (
<SelectOption key={opt.value} value={opt.value}>
{opt.label}
</SelectOption>
))}
</Select>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useCallback } from "react";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Button } from "@nous-research/ui/ui/components/button";
import { useI18n } from "@/i18n";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
type IntervalUnit,
type ScheduleBuilderState,
type ScheduleMode,
type Weekday,
WEEKDAY_INDEXES,
} from "@/lib/schedule";
/**
* Human-readable schedule picker for cron job create/edit flows.
*
* Replaces the raw "type a cron expression" input that lived inline in
* ``CronPage``. The picker still emits a single backend-compatible
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
* fills out shape-appropriate inputs (time picker, weekday toggles,
* datetime-local field) per mode.
*
* Architecture:
*
* - The component is fully controlled. Parent owns the
* ``ScheduleBuilderState`` and the derived schedule string (built
* via ``buildScheduleString`` in render).
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
* preserved across mode switches so flipping back to a previous mode
* doesn't erase the user's work.
* - The "Custom" mode is an escape hatch — surfacing it as a normal
* option (instead of hiding it behind an "advanced" toggle) keeps
* power-user workflows discoverable without making everyone scroll
* past it.
*/
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
const { t } = useI18n();
const cronStrings = t.cron;
const modeStrings = cronStrings.scheduleModes;
const update = useCallback(
(patch: Partial<ScheduleBuilderState>) => {
onChange({ ...value, ...patch });
},
[onChange, value],
);
const toggleWeekday = useCallback(
(day: Weekday) => {
const present = value.weekdays.includes(day);
update({
weekdays: present
? value.weekdays.filter((d) => d !== day)
: [...value.weekdays, day],
});
},
[update, value.weekdays],
);
return (
<div className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-schedule-mode">
{cronStrings.scheduleMode ?? "Schedule"}
</Label>
<Select
id="cron-schedule-mode"
value={value.mode}
onValueChange={(v) => update({ mode: v as ScheduleMode })}
>
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
<SelectOption value="once">{modeStrings.once}</SelectOption>
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
</Select>
</div>
{value.mode === "interval" && (
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-interval-value">
{modeStrings.intervalEvery}
</Label>
<Input
id="cron-interval-value"
type="number"
min={1}
max={9999}
value={String(value.intervalValue)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
});
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
<Select
id="cron-interval-unit"
value={value.intervalUnit}
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
>
<SelectOption value="minutes">
{modeStrings.unitMinutes}
</SelectOption>
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
</Select>
</div>
</div>
)}
{value.mode === "daily" && (
<TimeOfDayField
id="cron-daily-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
)}
{value.mode === "weekly" && (
<>
<div className="grid gap-2">
<Label>{modeStrings.weekdays}</Label>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={modeStrings.weekdays}
>
{WEEKDAY_INDEXES.map((d) => {
const isOn = value.weekdays.includes(d);
return (
<Button
key={d}
type="button"
size="sm"
outlined={!isOn}
aria-pressed={isOn}
onClick={() => toggleWeekday(d)}
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
>
{modeStrings.weekdaysShort[d]}
</Button>
);
})}
</div>
</div>
<TimeOfDayField
id="cron-weekly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</>
)}
{value.mode === "monthly" && (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
<Input
id="cron-month-day"
type="number"
min={1}
max={31}
value={String(value.dayOfMonth)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
dayOfMonth:
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
});
}}
/>
</div>
<TimeOfDayField
id="cron-monthly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</div>
)}
{value.mode === "once" && (
<div className="grid gap-2">
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
{/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
shape ``parse_schedule`` accepts on the backend. */}
<input
id="cron-once-at"
type="datetime-local"
className="flex h-9 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"
value={value.onceAt}
onChange={(e) => update({ onceAt: e.target.value })}
/>
</div>
)}
{value.mode === "custom" && (
<div className="grid gap-2">
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
<Input
id="cron-custom-expr"
placeholder={modeStrings.customPlaceholder}
value={value.custom}
onChange={(e) => update({ custom: e.target.value })}
className="font-mono-ui"
/>
<p className="text-xs text-muted-foreground">
{modeStrings.customHint}
</p>
</div>
)}
{/* Inline preview of what we'll send to the backend. Helps users
eyeball the result before hitting Create, and keeps the
schedule grammar discoverable for the custom mode. */}
<p className="text-xs text-muted-foreground">
<span className="opacity-70">{modeStrings.preview}: </span>
<span className="font-mono-ui text-foreground">
{buildScheduleString(value) || modeStrings.previewEmpty}
</span>
</p>
</div>
);
}
function TimeOfDayField({
id,
label,
onChange,
value,
}: TimeOfDayFieldProps) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
{/* Native time picker is the right tool for "HH:MM" — saves us
two separate hour/minute selects, respects user locale's
AM/PM preference, and round-trips with ``buildScheduleString``
without parsing. */}
<input
id={id}
type="time"
className="flex h-9 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"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export { DEFAULT_SCHEDULE_STATE };
interface ScheduleBuilderProps {
onChange: (state: ScheduleBuilderState) => void;
value: ScheduleBuilderState;
}
interface TimeOfDayFieldProps {
id: string;
label: string;
onChange: (value: string) => void;
value: string;
}
+41
View File
@@ -0,0 +1,41 @@
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-sans 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",
)}
>
{t.app.footer.org}
</a>
</div>
);
}
interface SidebarFooterProps {
status: StatusResponse | null;
}
+72
View File
@@ -0,0 +1,72 @@
import { Link } from "react-router";
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-sans 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;
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
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 { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
/* ------------------------------------------------------------------ */
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
/* */
/* Headless/VPS users have no editor besides this: the only other way */
/* to author a custom skill is SSH + a terminal editor. Create mode */
/* posts a brand-new skill (name + optional category + SKILL.md); */
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
/* Validation (frontmatter, name, size) happens server-side via the */
/* same path the agent's skill_manage tool uses, so errors come back */
/* as actionable messages rendered inline. */
/* ------------------------------------------------------------------ */
const CREATE_TEMPLATE = `---
name: my-skill
description: One-line description of when to use this skill.
---
# My Skill
Numbered steps, exact commands, and pitfalls go here.
`;
export interface SkillEditorDialogProps {
open: boolean;
/** Skill name to edit, or null for create mode. */
editName: string | null;
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
profile?: string;
onClose: () => void;
/** Called after a successful save so the page can refresh its list. */
onSaved: (name: string) => void;
}
export function SkillEditorDialog({
open,
editName,
profile,
onClose,
onSaved,
}: SkillEditorDialogProps) {
// The body is remounted via `key` every time the dialog opens or the
// target skill changes, so all form state initializes through useState
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
{open && (
<EditorBody
key={editName ?? "__create__"}
editName={editName}
profile={profile}
onClose={onClose}
onSaved={onSaved}
/>
)}
</DialogContent>
</Dialog>
);
}
function EditorBody({
editName,
profile,
onClose,
onSaved,
}: Omit<SkillEditorDialogProps, "open">) {
const isEdit = editName !== null;
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!editName) return;
let cancelled = false;
api
.getSkillContent(editName, profile || undefined)
.then((res) => !cancelled && setContent(res.content))
.catch((e) => !cancelled && setError(String(e)))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [editName, profile]);
const handleSave = async () => {
setError(null);
if (!isEdit && !name.trim()) {
setError("Skill name is required.");
return;
}
if (!content.trim()) {
setError("SKILL.md content is required.");
return;
}
setSaving(true);
try {
if (isEdit) {
await api.updateSkillContent(editName, content, profile || undefined);
onSaved(editName);
} else {
const trimmed = name.trim();
await api.createSkill(
{
name: trimmed,
content,
category: category.trim() || undefined,
},
profile || undefined,
);
onSaved(trimmed);
}
onClose();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>
{isEdit ? `Edit skill: ${editName}` : "New skill"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
{!isEdit && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-name">Name</Label>
<Input
id="skill-editor-name"
autoFocus
placeholder="my-skill"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-category">Category (optional)</Label>
<Input
id="skill-editor-category"
placeholder="devops"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
</div>
)}
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-content">SKILL.md</Label>
{loading ? (
<div className="flex items-center justify-center py-16">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
id="skill-editor-content"
spellCheck={false}
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
)}
</div>
{error && (
<p className="whitespace-pre-wrap text-xs text-destructive">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
<Button ghost size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving || loading}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
</Button>
</div>
</div>
</>
);
}
+171
View File
@@ -0,0 +1,171 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<ListItem
key={`${it.text}-${i}`}
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta}
</span>
)}
</ListItem>
);
})}
</div>
);
},
);
+358
View File
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Palette, Check, Type } 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, THEME_DEFAULT_FONT_ID, useTheme } from "@/themes";
import type { DashboardTheme, FontChoice, 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, fontId, fontChoices, setFont } = 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
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}
/>
<FontSection
fontChoices={fontChoices}
fontId={fontId}
setFont={setFont}
/>
</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",
"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
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
<FontSection
fontChoices={fontChoices}
fontId={fontId}
setFont={setFont}
/>
</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
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>
);
})}
</>
);
}
const FONT_CATEGORY_LABEL_KEY: Record<FontChoice["category"], "fontSans" | "fontSerif" | "fontMono"> = {
sans: "fontSans",
serif: "fontSerif",
mono: "fontMono",
};
/** Font-override section rendered below the theme list. Lets the user pick
* any catalog font independently of the active theme, or "Theme default"
* to clear the override. Each row previews itself in its own font. */
function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
const { t } = useI18n();
const order: FontChoice["category"][] = ["sans", "serif", "mono"];
return (
<>
<div className="mt-1 border-t border-current/20 px-3 pb-1 pt-2">
<span className="inline-flex items-center gap-1.5">
<Type className="h-3 w-3 text-text-tertiary" />
<Typography
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{t.theme?.fontTitle ?? "Font"}
</Typography>
</span>
</div>
{/* Theme-default (clears the override). */}
<ListItem
active={fontId === THEME_DEFAULT_FONT_ID}
aria-selected={fontId === THEME_DEFAULT_FONT_ID}
className="gap-3"
onClick={() => setFont(THEME_DEFAULT_FONT_ID)}
role="option"
>
<span aria-hidden className="h-4 w-9 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography className="truncate text-xs tracking-normal">
{t.theme?.fontDefault ?? "Theme default"}
</Typography>
<Typography className="truncate text-xs tracking-normal text-text-tertiary">
{t.theme?.fontDefaultHint ?? "Use the active theme's font"}
</Typography>
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
fontId === THEME_DEFAULT_FONT_ID ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
{order.map((cat) => {
const fonts = fontChoices.filter((f) => f.category === cat);
if (fonts.length === 0) return null;
const catLabel = t.theme?.[FONT_CATEGORY_LABEL_KEY[cat]] ?? cat;
return (
<div key={cat}>
<div className="px-3 pb-0.5 pt-1.5">
<Typography className="text-[0.65rem] uppercase tracking-[0.1em] text-text-tertiary">
{catLabel}
</Typography>
</div>
{fonts.map((f) => {
const isActive = f.id === fontId;
return (
<ListItem
active={isActive}
aria-selected={isActive}
className="gap-3"
key={f.id}
onClick={() => setFont(f.id)}
role="option"
>
<span aria-hidden className="h-4 w-9 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
{/* Preview the font in its own stack. */}
<span
className="truncate text-sm"
style={{ fontFamily: f.stack }}
>
{f.label}
</span>
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
);
})}
</div>
);
})}
</>
);
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,
theme.palette.warmGlow,
];
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: c1 }} />
<span className="flex-1" style={{ background: c2 }} />
<span className="flex-1" style={{ background: c3 }} />
</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 FontSectionProps {
fontChoices: FontChoice[];
fontId: string;
setFont: (id: string) => void;
}
interface ThemeSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}
+460
View File
@@ -0,0 +1,460 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ExternalLink, Loader2, Terminal, X } from "lucide-react";
import { api } from "@/lib/api";
import type {
ToolsetConfig,
ToolsetInfo,
ToolsetProvider,
} from "@/lib/api";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { cn, themedBody } from "@/lib/utils";
interface Props {
/** The toolset whose backends are being configured. */
toolset: ToolsetInfo;
/** Optional profile to scope config reads/writes to (Skills page profile
* selector). Omitted = the dashboard process's own profile. */
profile?: string;
onClose: () => void;
/** Called after a toggle/provider/key change so the parent grid refreshes. */
onChanged: () => void;
}
/**
* Full configuration surface for a single toolset's backends — the dashboard
* equivalent of selecting a toolset in the `hermes tools` curses UI: toggle
* the toolset on/off, pick a provider, enter API keys, and run a provider's
* post-setup install hook (npm/pip/binary) with a live log tail.
*/
export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) {
const { toast, showToast } = useToast();
const [config, setConfig] = useState<ToolsetConfig | null>(null);
const [loading, setLoading] = useState(true);
const [enabled, setEnabled] = useState(toolset.enabled);
const [toggling, setToggling] = useState(false);
const [selecting, setSelecting] = useState<string | null>(null);
const [activeProvider, setActiveProvider] = useState<string | null>(null);
// Per-env-var draft input values, keyed by env var name.
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingProvider, setSavingProvider] = useState<string | null>(null);
const [isSet, setIsSet] = useState<Record<string, boolean>>({});
// Post-setup install log tail state.
const [postSetupRunning, setPostSetupRunning] = useState(false);
const [postSetupLog, setPostSetupLog] = useState<string[]>([]);
const [postSetupKey, setPostSetupKey] = useState<string | null>(null);
// Bumped each time a post-setup is kicked off, to (re)trigger the poll
// effect below. Mirrors the SkillsPage HubBrowser action-poll pattern so
// the recursive timer lives inside the effect (lint-clean — no ref
// mutation, no self-referencing memo).
const [postSetupTrigger, setPostSetupTrigger] = useState(0);
const loadConfig = useCallback(() => {
// Promise-chain shape (not async/await with a leading synchronous
// setLoading) so callers in a useEffect don't trip
// react-hooks/set-state-in-effect — setState only fires inside the
// async .then/.catch/.finally callbacks.
return api
.getToolsetConfig(toolset.name, profile)
.then((cfg) => {
setConfig(cfg);
setActiveProvider(cfg.active_provider);
const seed: Record<string, boolean> = {};
for (const p of cfg.providers) {
for (const e of p.env_vars) seed[e.key] = e.is_set;
}
setIsSet(seed);
})
.catch(() => showToast("Failed to load toolset config", "error"))
.finally(() => setLoading(false));
}, [toolset.name, profile, showToast]);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
// Poll the post-setup action's log until it exits. Driven by
// postSetupTrigger; the recursive timer + cleanup live entirely inside the
// effect (matches the SkillsPage HubBrowser pattern — lint-clean).
useEffect(() => {
if (postSetupTrigger === 0) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const st = await api.getActionStatus("tools-post-setup", 300);
if (cancelled) return;
setPostSetupLog(st.lines);
if (st.running) {
timer = setTimeout(() => void poll(), 1200);
} else {
setPostSetupRunning(false);
const ok = st.exit_code === 0;
showToast(
ok ? "Post-setup complete" : "Post-setup finished with errors",
ok ? "success" : "error",
);
// Refresh — a backend may now report itself configured/available.
void loadConfig();
onChanged();
}
} catch {
if (!cancelled) {
setPostSetupRunning(false);
showToast("Lost track of the post-setup process", "error");
}
}
};
// Small delay so the spawned action has a log file to read.
timer = setTimeout(() => void poll(), 800);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [postSetupTrigger, showToast, loadConfig, onChanged]);
const handleToggle = async (next: boolean) => {
setToggling(true);
try {
await api.toggleToolset(toolset.name, next, profile);
setEnabled(next);
showToast(
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
"success",
);
onChanged();
} catch {
showToast("Failed to toggle toolset", "error");
} finally {
setToggling(false);
}
};
const handleSelectProvider = async (provider: ToolsetProvider) => {
setSelecting(provider.name);
try {
await api.selectToolsetProvider(toolset.name, provider.name, profile);
setActiveProvider(provider.name);
showToast(`Provider set to ${provider.name}`, "success");
onChanged();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to select provider",
"error",
);
} finally {
setSelecting(null);
}
};
const handleSaveKeys = async (provider: ToolsetProvider) => {
const env: Record<string, string> = {};
for (const e of provider.env_vars) {
const v = drafts[e.key];
if (v && v.trim()) env[e.key] = v.trim();
}
if (Object.keys(env).length === 0) {
showToast("Enter at least one value to save", "error");
return;
}
setSavingProvider(provider.name);
try {
const res = await api.saveToolsetEnv(toolset.name, env, profile);
setIsSet((prev) => ({ ...prev, ...res.is_set }));
// Clear saved drafts so the inputs reset to the "saved" placeholder.
setDrafts((prev) => {
const next = { ...prev };
for (const k of res.saved) delete next[k];
return next;
});
showToast(
res.saved.length
? `Saved ${res.saved.length} key${res.saved.length > 1 ? "s" : ""}`
: "Nothing to save",
"success",
);
onChanged();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save keys",
"error",
);
} finally {
setSavingProvider(null);
}
};
const handleRunPostSetup = async (provider: ToolsetProvider) => {
if (!provider.post_setup) return;
setPostSetupRunning(true);
setPostSetupLog([]);
setPostSetupKey(provider.post_setup);
try {
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
// Bump the trigger so the poll effect (re)starts tailing the log.
setPostSetupTrigger((n) => n + 1);
} catch (e) {
setPostSetupRunning(false);
showToast(
e instanceof Error ? e.message : "Failed to start post-setup",
"error",
);
}
};
const labelText = toolset.label?.trim() || toolset.name;
const platformText = toolset.platform_label?.trim() || toolset.platform;
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={cn(
themedBody,
"relative w-full max-w-2xl max-h-[85vh] border border-border bg-card shadow-2xl flex flex-col",
)}
>
<Button
ghost
size="xs"
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
onClick={onClose}
aria-label="Close"
>
<X />
</Button>
{/* Header — toolset identity + enable toggle */}
<header className="p-5 pb-3 border-b border-border">
<div className="flex items-center gap-3 pr-8">
<span className="font-mondwest text-display text-base tracking-wider">
{labelText}
</span>
<Badge tone={enabled ? "success" : "outline"} className="text-xs">
{enabled ? "Active" : "Inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{toolset.description}
</p>
<div className="mt-3 flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(v) => void handleToggle(v)}
disabled={toggling}
aria-label={`Enable toolset for ${platformText}`}
/>
<span className="text-xs text-muted-foreground">
{enabled
? `Enabled for ${platformText}`
: `Disabled for ${platformText}`}
</span>
</div>
</header>
{/* Body — provider matrix */}
<div className="flex-1 min-h-0 overflow-y-auto p-5 pt-4 space-y-4">
{loading ? (
<div className="flex items-center justify-center py-10">
<Spinner />
</div>
) : !config?.has_category ? (
<p className="text-sm text-muted-foreground py-6 text-center">
This toolset has no configurable backends — toggle it on or off
above. It works with no provider selection or API keys.
</p>
) : config.providers.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No providers are available for this toolset in this install.
</p>
) : (
config.providers.map((provider) => {
const isActive = provider.name === activeProvider;
return (
<div
key={provider.name}
className={cn(
"border border-border p-3",
isActive && "border-emerald-500/60 bg-emerald-500/5",
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm">
{provider.name}
</span>
{provider.badge && (
<Badge tone="secondary" className="text-xs">
{provider.badge}
</Badge>
)}
{provider.requires_nous_auth && (
<Badge tone="outline" className="text-xs">
Nous Portal
</Badge>
)}
</div>
{isActive ? (
<Badge tone="success" className="text-xs shrink-0">
<Check className="h-3 w-3 mr-0.5" /> Selected
</Badge>
) : (
<Button
size="sm"
outlined
onClick={() => void handleSelectProvider(provider)}
disabled={selecting !== null}
>
{selecting === provider.name ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Select"
)}
</Button>
)}
</div>
{provider.tag && (
<p className="text-xs text-muted-foreground mt-1">
{provider.tag}
</p>
)}
{/* API key inputs */}
{provider.env_vars.length > 0 && (
<div className="mt-3 space-y-2.5">
{provider.env_vars.map((ev) => (
<div key={ev.key} className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label
htmlFor={`env-${ev.key}`}
className="text-xs font-mono"
>
{ev.key}
</Label>
{isSet[ev.key] && (
<Badge tone="success" className="text-xs">
Saved
</Badge>
)}
</div>
<Input
id={`env-${ev.key}`}
type="password"
className="h-8 rounded-none text-xs font-mono"
placeholder={
isSet[ev.key]
? "•••••••• (saved — leave blank to keep)"
: ev.prompt || ev.key
}
value={drafts[ev.key] ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[ev.key]: e.target.value,
}))
}
/>
{ev.url && (
<a
href={ev.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" /> Get a key
</a>
)}
</div>
))}
<Button
size="sm"
onClick={() => void handleSaveKeys(provider)}
disabled={savingProvider !== null}
>
{savingProvider === provider.name ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Save keys"
)}
</Button>
</div>
)}
{/* Post-setup install hook */}
{provider.post_setup && (
<div className="mt-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground mb-1.5">
This backend needs a one-time install
{" "}
<span className="font-mono">
({provider.post_setup})
</span>
. Runs on this host — may take a few minutes.
</p>
<Button
size="sm"
outlined
className={cn(
postSetupRunning &&
postSetupKey === provider.post_setup &&
"[&_svg]:animate-spin",
)}
onClick={() => void handleRunPostSetup(provider)}
disabled={postSetupRunning}
prefix={
postSetupRunning &&
postSetupKey === provider.post_setup ? (
<Loader2 />
) : (
<Terminal />
)
}
>
{postSetupRunning &&
postSetupKey === provider.post_setup
? "Installing…"
: "Run setup"}
</Button>
</div>
)}
</div>
);
})
)}
{/* Post-setup live log */}
{(postSetupRunning || postSetupLog.length > 0) && (
<div className="border border-border">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">
post-setup: {postSetupKey}
</span>
{postSetupRunning && (
<Loader2 className="h-3 w-3 animate-spin ml-auto text-muted-foreground" />
)}
</div>
<pre className="max-h-48 overflow-y-auto p-3 text-xs font-mono whitespace-pre-wrap text-text-secondary">
{postSetupLog.length ? postSetupLog.join("\n") : "Starting…"}
</pre>
</div>
)}
</div>
</div>
<Toast toast={toast} />
</div>,
document.body,
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router";
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",
// 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",
)}
>
{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>
);
}
+137
View File
@@ -0,0 +1,137 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useLocation, useSearchParams } from "react-router";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. React STATE is the source of truth; the
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
* land scoped and refresh survives. The selection is mirrored into the api
* module so `fetchJSON` transparently appends it to the profile-scoped
* endpoint families. "" = the dashboard's own profile.
*
* Why state-first instead of URL-first: sidebar nav links are bare paths
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
* dashboard's own profile on every nav click — the switcher would LOOK
* global while normal navigation dropped the write target. With state as
* truth, the effect below re-asserts `?profile=` onto the new location
* after each navigation, so the scope survives nav and stays deep-linkable.
*
* This exists because "Set as active" on the Profiles page historically only
* flipped the sticky active_profile file (future CLI/gateway runs). The
* switcher is the dashboard's write-target selector for Chat and management
* pages. We now sync the switcher when the sticky active profile differs from
* the dashboard process on load, and ProfilesPage updates the switcher when
* you click "Set as active".
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
// Initial value comes from the URL (deep link / refresh / unified-launch
// preselect); afterwards state leads and the URL follows.
const [profile, setProfileState] = useState(
() => searchParams.get("profile") ?? "",
);
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
// A profile param arriving via in-app navigation (e.g. the Profiles
// page's "Manage skills & tools" linking to /skills?profile=X) must win
// over current state — it's an explicit scope request.
const urlProfile = searchParams.get("profile");
useEffect(() => {
if (urlProfile !== null && urlProfile !== profile) {
setManagementProfile(urlProfile);
setProfileState(urlProfile);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlProfile]);
// Re-assert ?profile= after navigations that dropped it (bare nav links).
// Runs on every pathname/profile change; no-ops when already in sync.
useEffect(() => {
const inUrl = searchParams.get("profile") ?? "";
if ((profile || "") === inUrl) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (profile) next.set("profile", profile);
else next.delete("profile");
return next;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, profile]);
useEffect(() => {
let cancelled = false;
const urlProfile = searchParams.get("profile");
Promise.all([api.getProfiles(), api.getActiveProfile()])
.then(([profilesRes, info]) => {
if (cancelled) return;
setProfiles(profilesRes.profiles.map((p) => p.name));
const current = info.current || "default";
const active = info.active || "default";
setCurrentProfile(current);
// Deep links (?profile=) win. Otherwise align the switcher with the
// sticky active profile so Chat and management pages match what the
// Profiles page shows as "active" (machine dashboard runs as
// `current`, usually default).
if (urlProfile === null && active !== current) {
setManagementProfile(active);
setProfileState(active);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setProfileState(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}
+136
View File
@@ -0,0 +1,136 @@
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();
setActiveAction(action);
} else {
const resp = await api.updateHermes();
// Some installs cannot apply updates from inside the dashboard. The
// endpoint returns a structured {ok:false, message, update_command}
// envelope instead of spawning the action; surface that guidance
// rather than polling a synthetic failed action.
if (!resp.ok) {
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
setToast({
type: "success",
message:
(resp.message ??
"Updates don't apply from this dashboard.") +
cmd,
});
return;
}
setActiveAction(action);
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setToast({
type: "error",
message: `${t.status.actionFailed}: ${detail}`,
});
} finally {
setPendingAction(null);
}
},
[t.status.actionFailed],
);
const dismissLog = useCallback(() => {
setActiveAction(null);
setActionStatus(null);
}, []);
const isRunning = activeAction !== null && actionStatus?.running !== false;
const isBusy = pendingAction !== null || isRunning;
return (
<SystemActionsContext.Provider
value={{
actionStatus,
activeAction,
dismissLog,
isBusy,
isRunning,
pendingAction,
runAction,
}}
>
{children}
<Toast toast={toast} />
</SystemActionsContext.Provider>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
+12
View File
@@ -0,0 +1,12 @@
import { createContext } from "react";
import type { ReactNode } from "react";
export interface PageHeaderContextValue {
setAfterTitle: (node: ReactNode) => void;
setEnd: (node: ReactNode) => void;
setTitle: (title: string | null) => void;
}
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
null,
);
+19
View File
@@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});
@@ -0,0 +1,18 @@
import { createContext } from "react";
import type { ActionStatusResponse } from "@/lib/api";
export const SystemActionsContext = createContext<SystemActionsState | null>(
null,
);
export type SystemAction = "restart" | "update";
export interface SystemActionsState {
actionStatus: ActionStatusResponse | null;
activeAction: SystemAction | null;
dismissLog: () => void;
isBusy: boolean;
isRunning: boolean;
pendingAction: SystemAction | null;
runAction: (action: SystemAction) => Promise<void>;
}
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
if (!ctx) {
throw new Error("usePageHeader must be used within a PageHeaderProvider");
}
return ctx;
}
+6
View File
@@ -0,0 +1,6 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}
+15
View File
@@ -0,0 +1,15 @@
import { useContext } from "react";
import {
SystemActionsContext,
type SystemActionsState,
} from "./system-actions-context";
export function useSystemActions(): SystemActionsState {
const ctx = useContext(SystemActionsContext);
if (!ctx) {
throw new Error(
"useSystemActions must be used within a SystemActionsProvider",
);
}
return ctx;
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef } from "react";
/**
* Hook that adds standard modal behaviors when `open` is true:
* - Escape key calls `onClose`
* - Body scroll is locked
* - Focus is restored to the previously focused element on close
*
* Returns a ref to attach to the modal container (for optional future focus trapping).
*/
export function useModalBehavior({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prevActive = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
prevActive?.focus?.();
};
}, [open, onClose]);
return containerRef;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
const POLL_MS = 10_000;
/**
* Light-weight status poll for the app shell (sidebar). The Status page uses
* its own faster interval; we keep this slower to avoid duplicate load.
*/
export function useSidebarStatus() {
const [status, setStatus] = useState<StatusResponse | null>(null);
useEffect(() => {
const load = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
};
load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, []);
return status;
}
+779
View File
@@ -0,0 +1,779 @@
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",
filterChats: "Geselse",
filterAutomation: "Outomatisering",
filterAll: "Alles",
sourceFilter: "Sessiebron",
anySource: "Enige bron",
searchPlaceholder: "Soek boodskap-inhoud...",
noSessions: "Nog geen sessies nie",
noSessionsInFilter: "Geen sessies in hierdie filter 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",
deleteEmpty: "Skrap leë",
deleteEmptyConfirmTitle: "Skrap leë sessies?",
deleteEmptyConfirmMessage:
"Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.",
emptySessionsDeleted: "{count} leë sessies geskrap",
failedToDeleteEmpty: "Kon nie leë sessies skrap nie",
selectSession: "Kies sessie",
selectAllOnPage: "Kies alles op hierdie bladsy",
clearSelection: "Maak keuse skoon",
selectedCount: "{count} gekies",
deleteSelected: "Skrap {count}",
deleteSelectedConfirmTitle: "Skrap {count} sessies?",
deleteSelectedConfirmMessage:
"Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
selectedSessionsDeleted: "{count} sessies geskrap",
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
newChat: "Nuwe 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 * * *",
scheduleMode: "Skedule",
scheduleModes: {
interval: "Herhalende interval",
daily: "Daagliks",
weekly: "Weekliks",
monthly: "Maandeliks",
once: "Een keer",
custom: "Pasgemaak (cron-uitdrukking)",
intervalEvery: "Elke",
intervalUnit: "Eenheid",
unitMinutes: "minute",
unitHours: "ure",
unitDays: "dae",
timeOfDay: "Tyd van die dag",
weekdays: "Dae van die week",
weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"],
dayOfMonth: "Dag van die maand",
onceAt: "Hardloop op",
customLabel: "Cron-uitdrukking",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).",
preview: "Word gestuur as",
previewEmpty: "(onvolledig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Elke {n} min",
everyHours: "Elke {n} u",
everyDays: "Elke {n} d",
dailyAt: "Daagliks om {time}",
weeklyAt: "Weekliks op {days} om {time}",
monthlyAt: "Maandeliks op die {day} om {time}",
onceAt: "Een keer op {time}",
},
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", cloneFrom: "Kloon konfigurasie vanaf profiel",
cloneFromNone: "Geen (leeg)",
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",
customTitle: "Pasgemaakte sleutels",
customHint: "Arbitrêre omgewingsveranderlikes wat in jou .env gestoor is en wat Hermes nie herken nie. Gebruik dit om omgewingsveranderlikes vir vaardighede, MCP-bedieners of jou eie gereedskap in te spuit.",
customConfigured: "{count} pasgemaakte sleutel(s) gestel",
addCustomKey: "Voeg 'n pasgemaakte sleutel by",
customKeyName: "Veranderlike naam",
customKeyNamePlaceholder: "bv. MY_SERVICE_API_KEY",
add: "Voeg by",
invalidKeyName: "Gebruik slegs letters, syfers en onderstrepe (moet met 'n letter of onderstreep begin).",
},
oauth: {
title: "Verskaffer-aanmeldings (OAuth)",
providerLogins: "Verskaffer-aanmeldings (OAuth)",
description:
"{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.",
connected: "Gekoppel",
expired: "Verval",
notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of 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 ✓",
copyCode: "Kopieer kode",
copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.",
cli: "Kopieer",
copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
connect: "Koppel",
sessionExpires: "Sessie verval oor {time}",
sessionExpiredNoError:
"Aanmelding het verval sonder om die verskaffer te bereik. Dit beteken gewoonlik dat die aanmeldbladsy in die oop oortjie vasgeval het ('n bedienerkant-probleem) — voltooi die aanmelding daar en klik dan Probeer weer. As dit aanhou misluk, gebruik eerder 'n API-sleutel of die CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+713
View File
@@ -0,0 +1,713 @@
import { defineLocale } from "./define-locale";
export const ar = defineLocale({
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: "الجلسات",
filterChats: "الدردشات",
filterAutomation: "الأتمتة",
filterAll: "الكل",
sourceFilter: "مصدر الجلسة",
anySource: "أي مصدر",
noSessionsInFilter: "لا توجد جلسات في هذا الفلتر",
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: "البريد الإلكتروني",
},
},
profiles: {
newProfile: "ملف شخصي جديد",
name: "الاسم",
namePlaceholder: "مثال: coder, writer, إلخ.",
nameRequired: "الاسم مطلوب",
nameRule:
"حروف صغيرة، أرقام، _ و - فقط؛ يجب البدء بحرف أو رقم؛ حتى 64 حرفًا.",
invalidName: "اسم ملف شخصي غير صالح",
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 أو owner/repo",
inactive: "غير نشط",
installBtn: "تثبيت من Git",
installHeading: "تثبيت من GitHub / رابط Git",
installHint: "استخدم اختصار owner/repo أو رابط استنساخ كامل يبدأ بـ https:// أو git@.",
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",
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: "إظهار المتقدمة",
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: "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",
subtitle:
"شارات Hermes قابلة للجمع مكتسبة من سجل الجلسات الفعلي. الإنجازات غير المكتملة المعروفة تُعرض كـ Discovered؛ تبقى الإنجازات السرية مخفية حتى يظهر السلوك المطابق لأول مرة.",
scan_subtitle:
"فحص سجل جلسات Hermes. يمكن أن يستغرق الفحص الأول 5–10 ثوانٍ على السجلات الكبيرة.",
},
actions: {
rescan: "إعادة الفحص",
},
stats: {
unlocked: "مفتوحة",
unlocked_hint: "شارات مكتسبة",
discovered: "مكتشفة",
discovered_hint: "معروفة، لم تُكتسب بعد",
secrets: "أسرار",
secrets_hint: "مخفية حتى أول إشارة",
highest_tier: "أعلى مستوى",
highest_tier_hint: "نحاس → فضة → ذهب ← ماس → أوليمبي",
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 يحفظ الملف للاستخدام anywhere.",
clipboard_unsupported:
"نسخ صورة الحافظة غير مدعوم في هذا المتصفح — استخدم التنزيل بدلاً من ذلك.",
tweet_text: "فتحت للتو {tier_part}\"{name}\" في Hermes Agent ☤"
},
},
kanban: {
loading: "جاري تحميل لوحة Kanban…",
loadFailed: "فشل تحميل لوحة Kanban: ",
loadFailedHint:
"يُنشئ الواجهة الخلفية kanban.db تلقائيًا عند أول قراءة. إذا استمرت المشكلة، تفقَّد سجلات لوحة التحكم.",
board: "لوحة",
newBoard: "+ لوحة جديدة",
newBoardTitle: "لوحة جديدة",
newBoardDescription:
"تتيح اللوحات فصل تدفقات العمل غير المرتبطة — واحدة لكل مشروع أو مستودع أو مجال.",
slug: "المعرِّف",
slugHint: "— أحرف صغيرة، واصلات، مثال atm10-server",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: "غير مكلَّف",
needsAssignee: "يحتاج مكلِّف",
needsAssigneeHint:
"المتطلبات مُلباة، لكن المُجلِّب يتخطى هذه المهمة حتى تعيّن ملفًا شخصيًا.",
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: "مهمة واحدة تحتاج انتباه",
diagnostic: "تشخيصي",
open: "فتح",
close: "إغلاق (Esc)",
reassignTo: "إعادة تعيين إلى:",
copied: "تم النسخ",
copyCommand: "نسخ الأمر إلى الحافظة",
reclaim: "استعادة",
reassign: "إعادة تعيين",
renderingError: "اصطدم تبويب Kanban بخطأ عرض",
reloadView: "إعادة تحميل العرض",
wsAuthFailed:
"فشلت مصادقة WebSocket — أعد تحميل الصفحة لتحديث رمز الجلسة.",
markDone: "تحديد {n} مهمة كمكتملة؟",
markArchived: "أرشفة {n} مهمة؟",
warning: "تحذير",
phantomIds: "معرفات وهمية:",
active: "نشط",
ended: "منتهٍ",
noProfile: "(لا يوجد ملف شخصي)",
showAllAttempts: "إظهار كل المحاولات",
sendingUpdates: "إرسال التحديثات إلى",
sendNotifications: "إرسال إشعارات مكتمل / محظور / متروك لـ",
archiveBoardConfirm:
"أرشفة لوحة '{name}'؟ ستُنقَل إلى boards/_archived/ لتتمكن من استرجاعها لاحقًا. المهام على هذه اللوحة لن تظهر بعد الآن في أي مكان في الواجهة.",
archiveBoardTitle: "أرشفة هذه اللوحة",
boardSwitcherHint: "تتيح اللوحات فصل تدفقات عمل غير مرتبطة",
taskCreatedWarning: "تم إنشاء المهمة، لكن: ",
moveFailed: "فشل النقل: ",
bulkFailed: "مجموعة: ",
completionBlockedHallucination: "⚠ الحظر — معرّفات بطاقات وهمية",
suspectedHallucinatedReferences: "⚠ النص يشير إلى معرّفات بطاقات وهمية",
pickProfileFirst: "اختر ملفًا شخصيًا أولاً.",
unblockedMessage: "تم إلغاء حظر {id}. المهمة جاهزة للدورة التالية.",
unblockFailed: "فشل إلغاء الحظر: ",
reclaimedMessage: "تم استعادة {id}. المهمة عادت إلى جاهزة.",
reclaimFailed: "فشل الاستعادة: ",
reassignedMessage: "تم إعادة تعيين {id} إلى {profile}.",
reassignFailed: "فشل إعادة التعيين: ",
selectForBulk: "تحديد لإجراءات جماعية",
clickToEdit: "انقر للتعديل",
clickToEditAssignee: "انقر لتعديل المكلَّف",
emptyAssignee: "(فارغ = إزالة التكليف)",
columnLabels: {
triage: "فرز",
todo: "مهام",
ready: "جاهز",
running: "قيد التنفيذ",
blocked: "محظور",
done: "منجز",
archived: "مؤرشف",
},
columnHelp: {
triage: "أفكار أولية — سيصِغ المُحدِّد المواصفات",
todo: "في انتظار الاعتماديات أو غير مكلَّف",
ready: "الاعتماديات مُلباة؛ عيّن ملفًا شخصيًا للتشغيل",
running: "مطالب بعامل — قيد التنفيذ",
blocked: "طلب العامل مدخلات من الإنسان",
done: "مكتمل",
archived: "مؤرشف",
},
confirmDone:
"تحديد هذه المهمة كمكتملة؟ سيتم تحرير مطالبة العامل ويصبح الأبناء المعتمدون جاهزين.",
confirmArchive:
"أرشفة هذه المهمة؟ ستختفي من عرض اللوحة الافتراضي.",
confirmBlocked:
"تحديد هذه المهمة كمحظورة؟ سيتم تحرير مطالبة العامل.",
completionSummary:
"ملخص الإنجاز لـ {label}. يُخزَّن كنتيجة للمهمة.",
completionSummaryRequired:
"ملخص الإنجاز مطلوب قبل تحديد المهمة كمكتملة.",
triagePlaceholder: "فكرةrough — سيقوم الذكاء الاصطناعي بصياغة المواصفات…",
taskTitlePlaceholder: "عنوان المهمة الجديد…",
specifier: "محدِّد المواصفات",
assigneePlaceholder: "المكلَّف",
priority: "الأولوية",
skillsPlaceholder:
"مهارات (اختياري، مفصولة بفواصل): translation, github-code-review",
noParent: "— لا يوجد أب —",
workspacePathDir: "مسار مساحة العمل (مطلوب، مثال ~/projects/my-app)",
workspacePathOptional:
"مسار مساحة العمل (اختياري، مستنتج من المكلَّف إذا كان فارغًا)",
logTruncated: "(عرض آخر 100 كيلوبايت — السجل الكامل في ",
logAt: ")",
},
});
+136
View File
@@ -0,0 +1,136 @@
import { createContext, useContext, useState, useCallback, useEffect, 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";
import { ar } from "./ar";
const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh,
"zh-hant": zhHant,
ja,
de,
es,
fr,
tr,
uk,
af,
ko,
it,
ga,
pt,
ru,
hu,
ar,
};
// Locales whose script flows right-to-left. Consumed by the provider to set the
// document direction so Tailwind's logical utilities (ms-/me-, ps-/pe-) flip.
const RTL_LOCALES = new Set<Locale>(["ar"]);
// 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" },
ar: { name: "العربية" },
};
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
}
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale;
document.documentElement.dir = RTL_LOCALES.has(locale) ? "rtl" : "ltr";
}, [locale]);
const value: I18nContextValue = {
locale,
setLocale,
t: TRANSLATIONS[locale],
};
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
return useContext(I18nContext);
}
+778
View File
@@ -0,0 +1,778 @@
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",
filterChats: "Chats",
filterAutomation: "Automatisierung",
filterAll: "Alle",
sourceFilter: "Sitzungsquelle",
anySource: "Beliebige Quelle",
searchPlaceholder: "Nachrichteninhalt suchen...",
noSessions: "Noch keine Sitzungen",
noSessionsInFilter: "Keine Sitzungen in diesem Filter",
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",
deleteEmpty: "Leere löschen",
deleteEmptyConfirmTitle: "Leere Sitzungen löschen?",
deleteEmptyConfirmMessage:
"Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.",
emptySessionsDeleted: "{count} leere Sitzungen gelöscht",
failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden",
selectSession: "Sitzung auswählen",
selectAllOnPage: "Alle auf dieser Seite auswählen",
clearSelection: "Auswahl aufheben",
selectedCount: "{count} ausgewählt",
deleteSelected: "{count} löschen",
deleteSelectedConfirmTitle: "{count} Sitzungen löschen?",
deleteSelectedConfirmMessage:
"Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
selectedSessionsDeleted: "{count} Sitzungen gelöscht",
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
newChat: "Neuer Chat",
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 * * *",
scheduleMode: "Zeitplan",
scheduleModes: {
interval: "Wiederkehrendes Intervall",
daily: "Täglich",
weekly: "Wöchentlich",
monthly: "Monatlich",
once: "Einmalig",
custom: "Benutzerdefiniert (Cron-Ausdruck)",
intervalEvery: "Alle",
intervalUnit: "Einheit",
unitMinutes: "Minuten",
unitHours: "Stunden",
unitDays: "Tage",
timeOfDay: "Uhrzeit",
weekdays: "Wochentage",
weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
dayOfMonth: "Tag des Monats",
onceAt: "Ausführen am",
customLabel: "Cron-Ausdruck",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).",
preview: "Wird gesendet als",
previewEmpty: "(unvollständig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Alle {n} Min.",
everyHours: "Alle {n} Std.",
everyDays: "Alle {n} Tage",
dailyAt: "Täglich um {time}",
weeklyAt: "Wöchentlich am {days} um {time}",
monthlyAt: "Monatlich am {day} um {time}",
onceAt: "Einmal am {time}",
},
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", cloneFrom: "Konfiguration klonen von",
cloneFromNone: "Keine (leer)",
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",
customTitle: "Benutzerdefinierte Schlüssel",
customHint: "Beliebige Umgebungsvariablen in deiner .env, die Hermes nicht erkennt. Verwende sie, um Umgebungsvariablen für Skills, MCP-Server oder eigene Tools einzuschleusen.",
customConfigured: "{count} benutzerdefinierte Schlüssel gesetzt",
addCustomKey: "Benutzerdefinierten Schlüssel hinzufügen",
customKeyName: "Variablenname",
customKeyNamePlaceholder: "z. B. MY_SERVICE_API_KEY",
add: "Hinzufügen",
invalidKeyName: "Nur Buchstaben, Zahlen und Unterstriche verwenden (muss mit einem Buchstaben oder Unterstrich beginnen).",
},
oauth: {
title: "Anbieter-Logins (OAuth)",
providerLogins: "Anbieter-Logins (OAuth)",
description:
"{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.",
connected: "Verbunden",
expired: "Abgelaufen",
notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder 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 ✓",
copyCode: "Code kopieren",
copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.",
cli: "Kopieren",
copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
connect: "Verbinden",
sessionExpires: "Sitzung läuft in {time} ab",
sessionExpiredNoError:
"Die Anmeldung ist abgelaufen, ohne den Anbieter zu erreichen. Das bedeutet meist, dass die Anmeldeseite im geöffneten Tab hängen geblieben ist (serverseitiges Problem) — schließen Sie die Anmeldung dort ab und klicken Sie dann auf Erneut versuchen. Wenn es weiterhin fehlschlägt, verwenden Sie stattdessen einen API-Schlüssel oder die CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+46
View File
@@ -0,0 +1,46 @@
import { en } from "./en";
import type { Translations } from "./types";
// Partial-locale helper: a translation file supplies only the strings it has
// translated and every missing key falls back to English, while unknown keys
// still fail the type-check. Mirrors the desktop app's `defineLocale` so a new
// locale (e.g. Arabic) can land without hand-porting every future English key.
type TranslationOverride<T> = T extends (...args: never[]) => string
? T
: T extends readonly unknown[]
? T
: T extends string
? string
: T extends object
? { [K in keyof T]?: TranslationOverride<T[K]> }
: T;
export type TranslationOverrides = TranslationOverride<Translations>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeTranslations<T>(base: T, overrides: TranslationOverride<T> | undefined): T {
if (!isRecord(base) || !isRecord(overrides)) {
return (overrides ?? base) as T;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
continue;
}
const baseValue = result[key];
result[key] = isRecord(baseValue) && isRecord(value) ? mergeTranslations(baseValue, value) : value;
}
return result as T;
}
export function defineLocale(overrides: TranslationOverrides): Translations {
return mergeTranslations<Translations>(en, overrides);
}
+880
View File
@@ -0,0 +1,880 @@
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",
gateway: "Gateway",
gatewayHint:
"Messaging platforms, the API server and webhooks are configured on the Channels page. These are gateway-wide settings (proxy/relay mode and the global allowlist).",
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",
managingProfile: "Managing profile",
currentProfileOption: "this dashboard ({name})",
managingProfileBanner:
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
memoryOomRestartBanner:
"Your agent restarted unexpectedly, most likely because it ran out of memory. Long sessions and many concurrent tasks increase memory use.",
memoryCriticalBanner:
"Your agent is almost out of memory and may restart. Consider closing idle sessions or upgrading its memory.",
memoryElevatedBanner: "Your agent is running low on memory.",
diskCriticalBanner:
"Your agent's disk is almost full. New messages, memories, and settings may fail to save.",
diskElevatedBanner:
"Your agent's disk is filling up. Consider clearing old sessions or expanding its storage.",
dismiss: "Dismiss",
},
status: {
actionFailed: "Action failed",
actionFinished: "Finished",
actions: "Actions",
agent: "Agent",
activeSessions: "Active Sessions",
connected: "Connected",
connectedPlatforms: "Connected Platforms",
disabled: "Disabled",
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",
restartGatewayConfirmMessage:
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward.",
restartGatewayConfirmTitle: "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",
updateHermesConfirmMessage:
"This runs hermes update and restarts the gateway when it finishes. Active sessions keep their prompt cache until then.",
updateHermesConfirmNow: "Update now",
updateHermesConfirmTitle: "Update Hermes?",
updatingHermes: "Updating Hermes…",
waitingForOutput: "Waiting for output…",
},
sessions: {
title: "Sessions",
history: "History",
overview: "Overview",
filterChats: "Chats",
filterAutomation: "Automation",
filterAll: "All",
sourceFilter: "Session source",
anySource: "Any source",
searchPlaceholder: "Search message content...",
noSessions: "No sessions yet",
noSessionsInFilter: "No sessions in this filter",
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",
deleteEmpty: "Delete empty",
deleteEmptyConfirmTitle: "Delete empty sessions?",
deleteEmptyConfirmMessage:
"This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.",
emptySessionsDeleted: "{count} empty sessions deleted",
failedToDeleteEmpty: "Failed to delete empty sessions",
selectSession: "Select session",
selectAllOnPage: "Select all on this page",
clearSelection: "Clear selection",
selectedCount: "{count} selected",
deleteSelected: "Delete {count}",
deleteSelectedConfirmTitle: "Delete {count} sessions?",
deleteSelectedConfirmMessage:
"This permanently removes {count} selected sessions and all their messages. This cannot be undone.",
selectedSessionsDeleted: "{count} sessions deleted",
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
newChat: "New 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 * * *",
scheduleMode: "Schedule",
scheduleModes: {
interval: "Every interval",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
once: "Once",
custom: "Custom (cron expression)",
intervalEvery: "Every",
intervalUnit: "Unit",
unitMinutes: "minutes",
unitHours: "hours",
unitDays: "days",
timeOfDay: "Time of day",
weekdays: "Days of week",
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
dayOfMonth: "Day of month",
onceAt: "Run at",
customLabel: "Cron expression",
customPlaceholder: "0 9 * * *",
customHint:
"Five-field cron expression (minute, hour, day, month, weekday).",
preview: "Sends as",
previewEmpty: "(incomplete)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Every {n} min",
everyHours: "Every {n} h",
everyDays: "Every {n} d",
dailyAt: "Daily at {time}",
weeklyAt: "Weekly on {days} at {time}",
monthlyAt: "Monthly on the {day} at {time}",
onceAt: "Once at {time}",
},
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",
needsHomeChannel: "set a home channel first",
noneConfigured:
"No messaging platforms configured. Set one up under Channels to deliver reports.",
},
},
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",
cloneFrom: "Clone config from",
cloneFromNone: "None (blank)",
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",
activeProfile: "Active profile",
activeBadge: "active",
setActive: "Set as active",
activeSet: "Active profile set",
gatewayRunning: "Gateway running",
gatewayStopped: "Gateway stopped",
gatewayRunningWarning:
"This profile's gateway is running — it will be stopped.",
aliasBadge: "alias",
description: "Description",
descriptionPlaceholder:
"What is this profile good at? Used to route kanban tasks by role.",
noDescription: "No description",
editDescription: "Edit description",
descriptionSaved: "Description saved",
reviewBadge: "review",
autoGenerate: "Auto-generate",
generating: "Generating…",
describeFailed: "Could not generate description",
distribution: "Distribution",
advancedOptions: "Advanced options",
cloneAll: "Clone everything (memories, sessions, skills, state)",
noSkillsOption: "Don't seed bundled skills",
descriptionOptional: "Description (optional)",
modelOptional: "Model (optional)",
modelInherit: "Inherit from clone / default",
modelLoading: "Loading models…",
modelNone: "No authenticated providers — set a key first",
editModel: "Change model",
modelSaved: "Model updated",
modelSelect: "Select a model",
actions: "Actions",
},
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. For a plugin in a subdirectory, append the path: owner/repo/path/to/plugin (or <url>#path/to/plugin).",
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",
profileSelector: "Profile",
currentProfile: "current ({name})",
managingProfile:
"Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.",
},
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",
customTitle: "Custom Keys",
customHint: "Arbitrary environment variables stored in your .env that Hermes doesn't recognise. Use these to inject env vars for skills, MCP servers, or your own tooling.",
customConfigured: "{count} custom key{s} set",
addCustomKey: "Add a custom key",
customKeyName: "Variable name",
customKeyNamePlaceholder: "e.g. MY_SERVICE_API_KEY",
add: "Add",
invalidKeyName: "Use letters, numbers and underscores only (must start with a letter or underscore).",
},
oauth: {
title: "Provider Logins (OAuth)",
providerLogins: "Provider Logins (OAuth)",
description:
"{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.",
connected: "Connected",
expired: "Expired",
notConnected: "Not connected. Use Login when available, or run {command} in a terminal.",
runInTerminal: "in a terminal.",
noProviders: "No OAuth-capable providers detected.",
login: "Login",
disconnect: "Disconnect",
managedExternally: "Managed externally",
copied: "Copied ✓",
copyCode: "Copy code",
copyFailed: "Could not copy automatically. Select the code and copy it manually.",
cli: "Copy",
copyCliCommand: "Copy CLI command (for external / fallback)",
connect: "Connect",
sessionExpires: "Session expires in {time}",
sessionExpiredNoError:
"Sign-in expired without reaching the provider. This usually means the sign-in page stalled in the opened tab (server-side issue) — finish signing in there, then click Retry. If it keeps failing, use an API key or the CLI fallback instead.",
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",
fontTitle: "Font",
fontDefault: "Theme default",
fontDefaultHint: "Use the active theme's font",
fontSans: "Sans",
fontSerif: "Serif",
fontMono: "Mono",
},
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.",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
newTaskTitle: "New task — {column}",
taskTitleLabel: "Title",
assigneeLabel: "Assignee",
assigneeLabelHint: "(blank = dispatcher picks)",
skillsLabel: "Skills",
skillsLabelHint: "(optional, comma-separated)",
parentLabel: "Parent task",
parentLabelHint: "(child stays blocked until the parent is done)",
create: "Create",
boardSettings: "Settings",
boardSettingsTitle:
"Board settings — name, description, and the default project directory new tasks inherit",
boardSettingsTitleFor: "Board settings — {name}",
projectDirectoryOverrideHint:
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.",
saving: "Saving…",
commentHint:
"Comments reach the worker on its next run or kanban_show() — no need to block the task first.",
commentHintTitle:
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input.",
trash: {
confirmTitle: "Delete task?",
confirmManyTitle: "Delete {n} tasks?",
},
},
};
+779
View File
@@ -0,0 +1,779 @@
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",
filterChats: "Chats",
filterAutomation: "Automatización",
filterAll: "Todas",
sourceFilter: "Origen de la sesión",
anySource: "Cualquier origen",
searchPlaceholder: "Buscar contenido de mensajes...",
noSessions: "Aún no hay sesiones",
noSessionsInFilter: "No hay sesiones en este filtro",
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",
deleteEmpty: "Eliminar vacías",
deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?",
deleteEmptyConfirmMessage:
"Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.",
emptySessionsDeleted: "{count} sesiones vacías eliminadas",
failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías",
selectSession: "Seleccionar sesión",
selectAllOnPage: "Seleccionar todas en esta página",
clearSelection: "Limpiar selección",
selectedCount: "{count} seleccionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?",
deleteSelectedConfirmMessage:
"Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.",
selectedSessionsDeleted: "{count} sesiones eliminadas",
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
newChat: "Nuevo 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 * * *",
scheduleMode: "Programación",
scheduleModes: {
interval: "Cada intervalo",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensualmente",
once: "Una vez",
custom: "Personalizado (expresión cron)",
intervalEvery: "Cada",
intervalUnit: "Unidad",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "días",
timeOfDay: "Hora del día",
weekdays: "Días de la semana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
dayOfMonth: "Día del mes",
onceAt: "Ejecutar el",
customLabel: "Expresión cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).",
preview: "Se envía como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Cada {n} min",
everyHours: "Cada {n} h",
everyDays: "Cada {n} d",
dailyAt: "Diariamente a las {time}",
weeklyAt: "Semanalmente los {days} a las {time}",
monthlyAt: "Mensualmente el {day} a las {time}",
onceAt: "Una vez el {time}",
},
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",
cloneFrom: "Clonar desde el perfil",
cloneFromNone: "Ninguno (vacío)",
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",
customTitle: "Claves personalizadas",
customHint: "Variables de entorno arbitrarias almacenadas en tu .env que Hermes no reconoce. Úsalas para inyectar variables de entorno para skills, servidores MCP o tus propias herramientas.",
customConfigured: "{count} clave(s) personalizada(s) configurada(s)",
addCustomKey: "Añadir una clave personalizada",
customKeyName: "Nombre de la variable",
customKeyNamePlaceholder: "p. ej. MY_SERVICE_API_KEY",
add: "Añadir",
invalidKeyName: "Usa solo letras, números y guiones bajos (debe empezar por una letra o un guion bajo).",
},
oauth: {
title: "Inicios de sesión de proveedores (OAuth)",
providerLogins: "Inicios de sesión de proveedores (OAuth)",
description:
"{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.",
connected: "Conectado",
expired: "Caducado",
notConnected: "No conectado. Usa Iniciar sesión si está disponible, o 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 ✓",
copyCode: "Copiar código",
copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
connect: "Conectar",
sessionExpires: "La sesión caduca en {time}",
sessionExpiredNoError:
"El inicio de sesión expiró sin llegar al proveedor. Esto suele significar que la página de inicio de sesión se quedó bloqueada en la pestaña abierta (problema del lado del servidor): termina de iniciar sesión allí y luego haz clic en Reintentar. Si sigue fallando, usa una clave API o la CLI en su lugar.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+779
View File
@@ -0,0 +1,779 @@
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",
filterChats: "Discussions",
filterAutomation: "Automatisation",
filterAll: "Toutes",
sourceFilter: "Source de la session",
anySource: "Toute source",
searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour l'instant",
noSessionsInFilter: "Aucune session dans ce filtre",
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",
deleteEmpty: "Supprimer les vides",
deleteEmptyConfirmTitle: "Supprimer les sessions vides ?",
deleteEmptyConfirmMessage:
"Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.",
emptySessionsDeleted: "{count} sessions vides supprimées",
failedToDeleteEmpty: "Échec de la suppression des sessions vides",
selectSession: "Sélectionner la session",
selectAllOnPage: "Tout sélectionner sur cette page",
clearSelection: "Effacer la sélection",
selectedCount: "{count} sélectionnée(s)",
deleteSelected: "Supprimer {count}",
deleteSelectedConfirmTitle: "Supprimer {count} sessions ?",
deleteSelectedConfirmMessage:
"Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.",
selectedSessionsDeleted: "{count} sessions supprimées",
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
newChat: "Nouveau 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 * * *",
scheduleMode: "Planification",
scheduleModes: {
interval: "Intervalle récurrent",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
once: "Une fois",
custom: "Personnalisé (expression cron)",
intervalEvery: "Toutes les",
intervalUnit: "Unité",
unitMinutes: "minutes",
unitHours: "heures",
unitDays: "jours",
timeOfDay: "Heure de la journée",
weekdays: "Jours de la semaine",
weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"],
dayOfMonth: "Jour du mois",
onceAt: "Exécuter le",
customLabel: "Expression cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).",
preview: "Envoyé sous la forme",
previewEmpty: "(incomplet)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Toutes les {n} min",
everyHours: "Toutes les {n} h",
everyDays: "Tous les {n} j",
dailyAt: "Tous les jours à {time}",
weeklyAt: "Chaque {days} à {time}",
monthlyAt: "Le {day} de chaque mois à {time}",
onceAt: "Une fois le {time}",
},
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",
cloneFrom: "Cloner depuis le profil",
cloneFromNone: "Aucun (vide)",
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",
customTitle: "Clés personnalisées",
customHint: "Variables d'environnement arbitraires stockées dans votre .env que Hermes ne reconnaît pas. Utilisez-les pour injecter des variables d'environnement pour des compétences, des serveurs MCP ou vos propres outils.",
customConfigured: "{count} clé(s) personnalisée(s) définie(s)",
addCustomKey: "Ajouter une clé personnalisée",
customKeyName: "Nom de la variable",
customKeyNamePlaceholder: "p. ex. MY_SERVICE_API_KEY",
add: "Ajouter",
invalidKeyName: "Utilisez uniquement des lettres, des chiffres et des traits de soulignement (doit commencer par une lettre ou un trait de soulignement).",
},
oauth: {
title: "Connexions fournisseurs (OAuth)",
providerLogins: "Connexions fournisseurs (OAuth)",
description:
"{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.",
connected: "Connecté",
expired: "Expiré",
notConnected: "Non connecté. Utilisez Connexion si disponible, ou 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é ✓",
copyCode: "Copier le code",
copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.",
cli: "Copier",
copyCliCommand: "Copier la commande CLI (pour externe / repli)",
connect: "Connecter",
sessionExpires: "La session expire dans {time}",
sessionExpiredNoError:
"La connexion a expiré sans atteindre le fournisseur. Cela signifie généralement que la page de connexion est restée bloquée dans l'onglet ouvert (problème côté serveur) — terminez la connexion là-bas, puis cliquez sur Réessayer. Si le problème persiste, utilisez une clé API ou la CLI à la place.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+787
View File
@@ -0,0 +1,787 @@
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ú",
filterChats: "Comhráite",
filterAutomation: "Uathoibriú",
filterAll: "Uile",
sourceFilter: "Foinse an tseisiúin",
anySource: "Foinse ar bith",
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
noSessions: "Gan seisiúin go fóill",
noSessionsInFilter: "Níl aon seisiún sa scagaire seo",
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",
deleteEmpty: "Scrios folamh",
deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?",
deleteEmptyConfirmMessage:
"Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.",
emptySessionsDeleted: "{count} seisiúin fholmha scriosta",
failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha",
selectSession: "Roghnaigh seisiún",
selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo",
clearSelection: "Glan an rogha",
selectedCount: "{count} roghnaithe",
deleteSelected: "Scrios {count}",
deleteSelectedConfirmTitle: "Scrios {count} seisiún?",
deleteSelectedConfirmMessage:
"Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.",
selectedSessionsDeleted: "Scriosadh {count} seisiún",
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
newChat: "Comhrá nua",
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 * * *",
scheduleMode: "Sceideal",
scheduleModes: {
interval: "Eatramh athfhillteach",
daily: "Go laethúil",
weekly: "Go seachtainiúil",
monthly: "Go míosúil",
once: "Uair amháin",
custom: "Saincheaptha (slonn cron)",
intervalEvery: "Gach",
intervalUnit: "Aonad",
unitMinutes: "nóiméad",
unitHours: "uair",
unitDays: "lá",
timeOfDay: "Am an lae",
weekdays: "Laethanta na seachtaine",
weekdaysShort: [
"Domh",
"Luan",
"Máirt",
"Céad",
"Déar",
"Aoine",
"Sath",
],
dayOfMonth: "Lá den mhí",
onceAt: "Rith ag",
customLabel: "Slonn cron",
customPlaceholder: "0 9 * * *",
customHint:
"Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).",
preview: "Seoltar mar",
previewEmpty: "(neamhiomlán)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Gach {n} nóim",
everyHours: "Gach {n} u",
everyDays: "Gach {n} lá",
dailyAt: "Go laethúil ag {time}",
weeklyAt: "Gach {days} ag {time}",
monthlyAt: "An {day} de gach mí ag {time}",
onceAt: "Uair amháin ag {time}",
},
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í", cloneFrom: "Clónáil cumraíocht ón bpróifíl",
cloneFromNone: "Dada (folamh)",
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",
customTitle: "Eochracha Saincheaptha",
customHint: "Athróga timpeallachta treallach atá stóráilte i do .env nach n-aithníonn Hermes. Úsáid iad chun athróga timpeallachta a instealladh do scileanna, freastalaithe MCP, nó d'uirlisí féin.",
customConfigured: "{count} eochair shaincheaptha socraithe",
addCustomKey: "Cuir eochair shaincheaptha leis",
customKeyName: "Ainm na hathróige",
customKeyNamePlaceholder: "m.sh. MY_SERVICE_API_KEY",
add: "Cuir leis",
invalidKeyName: "Úsáid litreacha, uimhreacha agus fostríoca amháin (caithfidh sé tosú le litir nó fostríoc).",
},
oauth: {
title: "Logálacha isteach soláthraí (OAuth)",
providerLogins: "Logálacha isteach soláthraí (OAuth)",
description:
"{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.",
connected: "Ceangailte",
expired: "As feidhm",
notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó 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 ✓",
copyCode: "Cóipeáil an cód",
copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.",
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}",
sessionExpiredNoError:
"Chuaigh an síniú isteach in éag gan an soláthraí a bhaint amach. De ghnáth ciallaíonn sé seo gur stop leathanach an tsínithe isteach sa chluaisín oscailte (fadhb ar thaobh an fhreastalaí) — críochnaigh an síniú isteach ansin, ansin cliceáil Atriail. Má theipeann air i gcónaí, úsáid eochair API nó an CLI ina ionad.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+779
View File
@@ -0,0 +1,779 @@
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",
filterChats: "Csevegések",
filterAutomation: "Automatizálás",
filterAll: "Összes",
sourceFilter: "Munkamenet forrása",
anySource: "Bármely forrás",
searchPlaceholder: "Keresés üzenettartalomban...",
noSessions: "Még nincsenek munkamenetek",
noSessionsInFilter: "Nincs munkamenet ebben a szűrőben",
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",
deleteEmpty: "Üresek törlése",
deleteEmptyConfirmTitle: "Üres munkamenetek törlése?",
deleteEmptyConfirmMessage:
"Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.",
emptySessionsDeleted: "{count} üres munkamenet törölve",
failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket",
selectSession: "Munkamenet kijelölése",
selectAllOnPage: "Az oldalon mindegyik kijelölése",
clearSelection: "Kijelölés törlése",
selectedCount: "{count} kijelölve",
deleteSelected: "{count} törlése",
deleteSelectedConfirmTitle: "{count} munkamenet törlése?",
deleteSelectedConfirmMessage:
"Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.",
selectedSessionsDeleted: "{count} munkamenet törölve",
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
newChat: "Új csevegés",
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 * * *",
scheduleMode: "Ütemezés",
scheduleModes: {
interval: "Ismétlődő intervallum",
daily: "Naponta",
weekly: "Hetente",
monthly: "Havonta",
once: "Egyszer",
custom: "Egyéni (cron kifejezés)",
intervalEvery: "Minden",
intervalUnit: "Egység",
unitMinutes: "perc",
unitHours: "óra",
unitDays: "nap",
timeOfDay: "Napszak",
weekdays: "Hét napjai",
weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
dayOfMonth: "Hónap napja",
onceAt: "Futtatás ekkor",
customLabel: "Cron kifejezés",
customPlaceholder: "0 9 * * *",
customHint:
"Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).",
preview: "Elküldve mint",
previewEmpty: "(hiányos)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n} percenként",
everyHours: "{n} óránként",
everyDays: "{n} naponta",
dailyAt: "Naponta {time}-kor",
weeklyAt: "Hetente {days} {time}-kor",
monthlyAt: "Havonta {day} {time}-kor",
onceAt: "Egyszer {time}-kor",
},
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", cloneFrom: "Konfiguráció klónozása ebből a profilból",
cloneFromNone: "Nincs (üres)",
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",
customTitle: "Egyéni kulcsok",
customHint: "A .env fájlban tárolt tetszőleges környezeti változók, amelyeket a Hermes nem ismer fel. Használd ezeket környezeti változók beillesztésére képességekhez, MCP-kiszolgálókhoz vagy saját eszközeidhez.",
customConfigured: "{count} egyéni kulcs beállítva",
addCustomKey: "Egyéni kulcs hozzáadása",
customKeyName: "Változó neve",
customKeyNamePlaceholder: "pl. MY_SERVICE_API_KEY",
add: "Hozzáadás",
invalidKeyName: "Csak betűket, számokat és aláhúzásokat használj (betűvel vagy aláhúzással kell kezdődnie).",
},
oauth: {
title: "Szolgáltatói bejelentkezések (OAuth)",
providerLogins: "Szolgáltatói bejelentkezések (OAuth)",
description:
"{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.",
connected: "Csatlakoztatva",
expired: "Lejárt",
notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy 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 ✓",
copyCode: "Kód másolása",
copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.",
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",
sessionExpiredNoError:
"A bejelentkezés lejárt anélkül, hogy elérte volna a szolgáltatót. Ez általában azt jelenti, hogy a bejelentkezési oldal elakadt a megnyitott lapon (szerveroldali probléma) — fejezze be ott a bejelentkezést, majd kattintson az Újra gombra. Ha továbbra sem sikerül, használjon inkább API-kulcsot vagy a CLI-t.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
displayName: "Megjelenítendő név",
displayNameHint: "(opcionális)",
description: "Leírás",
descriptionHint: "(opcionális)",
icon: "Ikon",
iconHint: "(egyetlen karakter vagy emodzsi)",
switchAfterCreate: "Váltás erre a táblára létrehozás után",
cancel: "Mégse",
creating: "Létrehozás…",
createBoard: "Tábla létrehozása",
search: "Keresés",
filterCards: "Kártyák szűrése…",
tenant: "Tenant",
allTenants: "Összes tenant",
assignee: "Felelős",
allProfiles: "Összes profil",
showArchived: "Archiváltak megjelenítése",
lanesByProfile: "Sávok profil szerint",
nudgeDispatcher: "Dispatcher noszogatása",
refresh: "Frissítés",
selected: "kiválasztva",
complete: "Befejezés",
archive: "Archiválás",
apply: "Alkalmaz",
clear: "Törlés",
createTask: "Feladat létrehozása ebben az oszlopban",
noTasks: "— nincsenek feladatok —",
unassigned: "nincs felelős",
untitled: "(névtelen)",
loadingDetail: "Betöltés…",
addComment: "Hozzászólás hozzáadása… (Enter a beküldéshez)",
comment: "Hozzászólás",
status: "Állapot",
workspace: "Munkaterület",
skills: "Készségek",
createdBy: "Létrehozta",
result: "Eredmény",
comments: "Hozzászólások",
events: "Események",
runHistory: "Futási előzmények",
workerLog: "Worker napló",
loadingLog: "Napló betöltése…",
noWorkerLog:
"— még nincs worker napló (a feladat nem indult el, vagy a napló rotálódott) —",
noDescription: "— nincs leírás —",
noComments: "— nincsenek hozzászólások —",
edit: "szerkesztés",
save: "Mentés",
dependencies: "Függőségek",
parents: "Szülők:",
children: "Gyermekek:",
none: "nincs",
addParent: "— szülő hozzáadása —",
addChild: "— gyermek hozzáadása —",
removeDependency: "Függőség eltávolítása",
block: "Blokkolás",
unblock: "Feloldás",
notifyHomeChannels: "Otthoni csatornák értesítése",
diagnostics: "Diagnosztika",
hide: "Elrejtés",
show: "Megjelenítés",
attention: "Figyelem",
tasksNeedAttention: "feladat figyelmet igényel",
taskNeedsAttention: "1 feladat figyelmet igényel",
diagnostic: "diagnosztika",
open: "Megnyitás",
close: "Bezárás (Esc)",
reassignTo: "Új felelős:",
copied: "Másolva",
copyCommand: "Parancs másolása a vágólapra",
reclaim: "Visszavétel",
reassign: "Újrakiosztás",
renderingError: "A Kanban fülön renderelési hiba lépett fel",
reloadView: "Nézet újratöltése",
wsAuthFailed:
"WebSocket-hitelesítés sikertelen — töltsd újra az oldalt a munkamenet-token frissítéséhez.",
markDone: "Megjelölöd {n} feladatot késznek?",
markArchived: "Archiválsz {n} feladatot?",
warning: "Figyelmeztetés",
phantomIds: "Fantom id-k:",
active: "aktív",
ended: "befejeződött",
noProfile: "(nincs profil)",
showAllAttempts: "Összes próbálkozás megjelenítése",
sendingUpdates: "Frissítések küldése ide:",
sendNotifications: "completed / blocked / gave_up értesítések küldése ide:",
archiveBoardConfirm:
"Archiválod a(z) '{name}' táblát? Áthelyezzük a boards/_archived/ mappába, hogy később visszaállíthasd. A táblán lévő feladatok többé nem jelennek meg sehol az UI-ban.",
archiveBoardTitle: "Tábla archiválása",
boardSwitcherHint: "A táblákkal külön tudod választani az egymással nem összefüggő munkafolyamokat",
taskCreatedWarning: "Feladat létrehozva, de: ",
moveFailed: "Áthelyezés sikertelen: ",
bulkFailed: "Tömeges: ",
completionBlockedHallucination: "⚠ Befejezés blokkolva — fantom kártya id-k",
suspectedHallucinatedReferences: "⚠ A szöveg fantom kártya id-kre hivatkozott",
pickProfileFirst: "Először válassz profilt.",
unblockedMessage: "{id} feloldva. A feladat készen áll a következő tickre.",
unblockFailed: "Feloldás sikertelen: ",
reclaimedMessage: "{id} visszavéve. A feladat újra ready állapotban van.",
reclaimFailed: "Visszavétel sikertelen: ",
reassignedMessage: "{id} újrakiosztva neki: {profile}.",
reassignFailed: "Újrakiosztás sikertelen: ",
selectForBulk: "Kijelölés tömeges műveletekhez",
clickToEdit: "Kattints a szerkesztéshez",
clickToEditAssignee: "Kattints a felelős szerkesztéséhez",
emptyAssignee: "(üres = felelős eltávolítása)",
columnLabels: {
triage: "Triázs",
todo: "Tennivaló",
scheduled: "Ütemezett",
ready: "Indulásra kész",
running: "Folyamatban",
blocked: "Blokkolva",
done: "Kész",
archived: "Archivált",
},
columnHelp: {
triage: "Nyers ötletek — egy specifier kidolgozza a specifikációt",
todo: "Függőségekre vár vagy nincs felelőse",
scheduled: "Ismert időzítésre vagy ütemezett utánkövetésre vár",
ready: "A függőségek teljesültek; rendelj hozzá profilt az indításhoz",
running: "Worker felvette — folyamatban",
blocked: "A worker emberi beavatkozást kért",
done: "Befejezve",
archived: "Archiválva",
},
confirmDone:
"Megjelölöd ezt a feladatot késznek? A worker foglalása felszabadul, és a függő gyermekek ready állapotba kerülnek.",
confirmArchive:
"Archiválod ezt a feladatot? Eltűnik az alapértelmezett tábla nézetből.",
confirmBlocked:
"Megjelölöd ezt a feladatot blokkoltként? A worker foglalása felszabadul.",
completionSummary:
"Befejezési összefoglaló a következőhöz: {label}. Ez a feladat eredményeként kerül tárolásra.",
completionSummaryRequired:
"A feladat késznek jelölése előtt kötelező megadni a befejezési összefoglalót.",
triagePlaceholder: "Nyers ötlet — az AI specifikálja…",
taskTitlePlaceholder: "Új feladat címe…",
specifier: "specifier",
assigneePlaceholder: "felelős",
priority: "Prioritás",
skillsPlaceholder:
"készségek (opcionális, vesszővel elválasztva): translation, github-code-review",
noParent: "— nincs szülő —",
workspacePathDir: "munkaterület útvonala (kötelező, pl. ~/projects/my-app)",
workspacePathOptional:
"munkaterület útvonala (opcionális, üresen a felelősből származtatva)",
logTruncated: "(az utolsó 100 KB látható — teljes napló: ",
logAt: ")",
},
};
+2
View File
@@ -0,0 +1,2 @@
export { I18nProvider, useI18n, LOCALE_META } from "./context";
export type { Locale, Translations } from "./types";
+778
View File
@@ -0,0 +1,778 @@
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",
filterChats: "Chat",
filterAutomation: "Automazione",
filterAll: "Tutte",
sourceFilter: "Origine della sessione",
anySource: "Qualsiasi origine",
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
noSessions: "Nessuna sessione",
noSessionsInFilter: "Nessuna sessione in questo filtro",
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",
deleteEmpty: "Elimina vuote",
deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?",
deleteEmptyConfirmMessage:
"Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.",
emptySessionsDeleted: "{count} sessioni vuote eliminate",
failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote",
selectSession: "Seleziona sessione",
selectAllOnPage: "Seleziona tutte in questa pagina",
clearSelection: "Annulla selezione",
selectedCount: "{count} selezionate",
deleteSelected: "Elimina {count}",
deleteSelectedConfirmTitle: "Eliminare {count} sessioni?",
deleteSelectedConfirmMessage:
"Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.",
selectedSessionsDeleted: "{count} sessioni eliminate",
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
newChat: "Nuova 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 * * *",
scheduleMode: "Pianificazione",
scheduleModes: {
interval: "Intervallo ricorrente",
daily: "Giornaliero",
weekly: "Settimanale",
monthly: "Mensile",
once: "Una volta",
custom: "Personalizzato (espressione cron)",
intervalEvery: "Ogni",
intervalUnit: "Unità",
unitMinutes: "minuti",
unitHours: "ore",
unitDays: "giorni",
timeOfDay: "Ora del giorno",
weekdays: "Giorni della settimana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"],
dayOfMonth: "Giorno del mese",
onceAt: "Esegui il",
customLabel: "Espressione cron",
customPlaceholder: "0 9 * * *",
customHint:
"Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).",
preview: "Inviato come",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Ogni {n} min",
everyHours: "Ogni {n} h",
everyDays: "Ogni {n} g",
dailyAt: "Tutti i giorni alle {time}",
weeklyAt: "Ogni {days} alle {time}",
monthlyAt: "Il {day} di ogni mese alle {time}",
onceAt: "Una volta il {time}",
},
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", cloneFrom: "Clona configurazione dal profilo",
cloneFromNone: "Nessuno (vuoto)",
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",
customTitle: "Chiavi personalizzate",
customHint: "Variabili d'ambiente arbitrarie salvate nel tuo .env che Hermes non riconosce. Usale per iniettare variabili d'ambiente per skill, server MCP o i tuoi strumenti.",
customConfigured: "{count} chiave/i personalizzata/e impostata/e",
addCustomKey: "Aggiungi una chiave personalizzata",
customKeyName: "Nome della variabile",
customKeyNamePlaceholder: "es. MY_SERVICE_API_KEY",
add: "Aggiungi",
invalidKeyName: "Usa solo lettere, numeri e trattini bassi (deve iniziare con una lettera o un trattino basso).",
},
oauth: {
title: "Accessi provider (OAuth)",
providerLogins: "Accessi provider (OAuth)",
description:
"{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.",
connected: "Connesso",
expired: "Scaduto",
notConnected: "Non connesso. Usa Accedi se disponibile, oppure 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 ✓",
copyCode: "Copia codice",
copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.",
cli: "Copia",
copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
connect: "Connetti",
sessionExpires: "La sessione scade tra {time}",
sessionExpiredNoError:
"L'accesso è scaduto senza raggiungere il provider. Di solito significa che la pagina di accesso si è bloccata nella scheda aperta (problema lato server): completa l'accesso lì, poi fai clic su Riprova. Se continua a fallire, usa una chiave API o la CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+778
View File
@@ -0,0 +1,778 @@
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: "概要",
filterChats: "チャット",
filterAutomation: "自動化",
filterAll: "すべて",
sourceFilter: "セッションのソース",
anySource: "すべてのソース",
searchPlaceholder: "メッセージ内容を検索...",
noSessions: "まだセッションがありません",
noSessionsInFilter: "このフィルターにセッションはありません",
noMatch: "検索条件に一致するセッションはありません",
startConversation: "会話を開始するとここに表示されます",
noMessages: "メッセージがありません",
untitledSession: "無題のセッション",
deleteSession: "セッションを削除",
confirmDeleteTitle: "セッションを削除しますか?",
confirmDeleteMessage:
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
deleteEmpty: "空を削除",
deleteEmptyConfirmTitle: "空のセッションを削除しますか?",
deleteEmptyConfirmMessage:
"メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。",
emptySessionsDeleted: "{count} 件の空のセッションを削除しました",
failedToDeleteEmpty: "空のセッションの削除に失敗しました",
selectSession: "セッションを選択",
selectAllOnPage: "このページの全てを選択",
clearSelection: "選択を解除",
selectedCount: "{count}件選択中",
deleteSelected: "{count}件削除",
deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?",
deleteSelectedConfirmMessage:
"選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
selectedSessionsDeleted: "{count}件のセッションを削除しました",
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
newChat: "新しいチャット",
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 * * *",
scheduleMode: "スケジュール",
scheduleModes: {
interval: "繰り返し間隔",
daily: "毎日",
weekly: "毎週",
monthly: "毎月",
once: "1回のみ",
custom: "カスタム(cron式)",
intervalEvery: "実行間隔",
intervalUnit: "単位",
unitMinutes: "分",
unitHours: "時間",
unitDays: "日",
timeOfDay: "時刻",
weekdays: "曜日",
weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"],
dayOfMonth: "日付",
onceAt: "実行日時",
customLabel: "cron式",
customPlaceholder: "0 9 * * *",
customHint: "5フィールドのcron式(分、時、日、月、曜日)。",
preview: "送信形式",
previewEmpty: "(未入力)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}分ごと",
everyHours: "{n}時間ごと",
everyDays: "{n}日ごと",
dailyAt: "毎日 {time}",
weeklyAt: "毎週 {days} {time}",
monthlyAt: "毎月{day} {time}",
onceAt: "{time} に1回",
},
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: "無効なプロファイル名", cloneFrom: "プロファイルから複製",
cloneFromNone: "なし(空)",
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: "値を非表示",
customTitle: "カスタムキー",
customHint: "Hermes が認識しない、.env に保存された任意の環境変数。スキル、MCP サーバー、または独自のツール用に環境変数を注入するために使用します。",
customConfigured: "カスタムキーを {count} 個設定済み",
addCustomKey: "カスタムキーを追加",
customKeyName: "変数名",
customKeyNamePlaceholder: "例: MY_SERVICE_API_KEY",
add: "追加",
invalidKeyName: "英字・数字・アンダースコアのみ使用できます(英字またはアンダースコアで始める必要があります)。",
},
oauth: {
title: "プロバイダーログイン (OAuth)",
providerLogins: "プロバイダーログイン (OAuth)",
description:
"{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。",
connected: "接続済み",
expired: "期限切れ",
notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。",
runInTerminal: "ターミナルで実行してください。",
noProviders: "OAuth 対応プロバイダーは検出されませんでした。",
login: "ログイン",
disconnect: "切断",
managedExternally: "外部で管理",
copied: "コピーしました ✓",
copyCode: "コードをコピー",
copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。",
cli: "コピー",
copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
connect: "接続",
sessionExpires: "セッションは {time} 後に期限切れになります",
sessionExpiredNoError:
"プロバイダーに到達しないままサインインの有効期限が切れました。多くの場合、開いたタブでサインインページが止まっている(サーバー側の問題)ことが原因です。そのタブでサインインを完了してから「再試行」をクリックしてください。それでも失敗する場合は、API キーまたは CLI をご利用ください。",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+778
View File
@@ -0,0 +1,778 @@
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: "개요",
filterChats: "채팅",
filterAutomation: "자동화",
filterAll: "전체",
sourceFilter: "세션 소스",
anySource: "모든 소스",
searchPlaceholder: "메시지 내용 검색...",
noSessions: "아직 세션이 없습니다",
noSessionsInFilter: "이 필터에 세션이 없습니다",
noMatch: "검색과 일치하는 세션이 없습니다",
startConversation: "대화를 시작하면 여기에 표시됩니다",
noMessages: "메시지가 없습니다",
untitledSession: "제목 없는 세션",
deleteSession: "세션 삭제",
confirmDeleteTitle: "세션을 삭제하시겠습니까?",
confirmDeleteMessage:
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
deleteEmpty: "빈 세션 삭제",
deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?",
deleteEmptyConfirmMessage:
"메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.",
emptySessionsDeleted: "빈 세션 {count}개 삭제됨",
failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다",
selectSession: "세션 선택",
selectAllOnPage: "이 페이지 전체 선택",
clearSelection: "선택 해제",
selectedCount: "{count}개 선택됨",
deleteSelected: "{count}개 삭제",
deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?",
deleteSelectedConfirmMessage:
"선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.",
selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다",
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
newChat: "새 채팅",
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 * * *",
scheduleMode: "일정",
scheduleModes: {
interval: "반복 간격",
daily: "매일",
weekly: "매주",
monthly: "매월",
once: "한 번",
custom: "사용자 지정 (cron 표현식)",
intervalEvery: "실행 간격",
intervalUnit: "단위",
unitMinutes: "분",
unitHours: "시간",
unitDays: "일",
timeOfDay: "시각",
weekdays: "요일",
weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"],
dayOfMonth: "날짜",
onceAt: "실행 시각",
customLabel: "cron 표현식",
customPlaceholder: "0 9 * * *",
customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).",
preview: "전송 형식",
previewEmpty: "(미완성)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}분마다",
everyHours: "{n}시간마다",
everyDays: "{n}일마다",
dailyAt: "매일 {time}",
weeklyAt: "매주 {days} {time}",
monthlyAt: "매월 {day} {time}",
onceAt: "{time}에 한 번",
},
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: "잘못된 프로필 이름입니다", cloneFrom: "프로필에서 복제",
cloneFromNone: "없음 (빈 상태)",
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: "값 숨기기",
customTitle: "사용자 지정 키",
customHint: "Hermes가 인식하지 못하는, .env에 저장된 임의의 환경 변수입니다. 스킬, MCP 서버 또는 자체 도구를 위한 환경 변수를 주입하는 데 사용하세요.",
customConfigured: "사용자 지정 키 {count}개 설정됨",
addCustomKey: "사용자 지정 키 추가",
customKeyName: "변수 이름",
customKeyNamePlaceholder: "예: MY_SERVICE_API_KEY",
add: "추가",
invalidKeyName: "문자, 숫자, 밑줄만 사용하세요(문자 또는 밑줄로 시작해야 합니다).",
},
oauth: {
title: "제공자 로그인 (OAuth)",
providerLogins: "제공자 로그인 (OAuth)",
description:
"{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.",
connected: "연결됨",
expired: "만료됨",
notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.",
runInTerminal: "터미널에서.",
noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.",
login: "로그인",
disconnect: "연결 해제",
managedExternally: "외부에서 관리됨",
copied: "복사됨 ✓",
copyCode: "코드 복사",
copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.",
cli: "복사",
copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
connect: "연결",
sessionExpires: "세션이 {time} 후 만료됩니다",
sessionExpiredNoError:
"제공자에 도달하지 못한 채 로그인 시간이 만료되었습니다. 보통 열린 탭에서 로그인 페이지가 멈춘 경우입니다(서버 측 문제) — 해당 탭에서 로그인을 완료한 후 다시 시도를 클릭하세요. 계속 실패하면 API 키 또는 CLI를 사용하세요.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+780
View File
@@ -0,0 +1,780 @@
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",
filterChats: "Conversas",
filterAutomation: "Automação",
filterAll: "Todas",
sourceFilter: "Origem da sessão",
anySource: "Qualquer origem",
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
noSessions: "Ainda não há sessões",
noSessionsInFilter: "Nenhuma sessão neste filtro",
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",
deleteEmpty: "Eliminar vazias",
deleteEmptyConfirmTitle: "Eliminar sessões vazias?",
deleteEmptyConfirmMessage:
"Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.",
emptySessionsDeleted: "{count} sessões vazias eliminadas",
failedToDeleteEmpty: "Falha ao eliminar sessões vazias",
selectSession: "Selecionar sessão",
selectAllOnPage: "Selecionar todas nesta página",
clearSelection: "Limpar seleção",
selectedCount: "{count} selecionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "Eliminar {count} sessões?",
deleteSelectedConfirmMessage:
"Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.",
selectedSessionsDeleted: "{count} sessões eliminadas",
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
newChat: "Novo 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 * * *",
scheduleMode: "Agendamento",
scheduleModes: {
interval: "Intervalo recorrente",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensalmente",
once: "Uma vez",
custom: "Personalizado (expressão cron)",
intervalEvery: "A cada",
intervalUnit: "Unidade",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "dias",
timeOfDay: "Hora do dia",
weekdays: "Dias da semana",
weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"],
dayOfMonth: "Dia do mês",
onceAt: "Executar em",
customLabel: "Expressão cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).",
preview: "Enviado como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "A cada {n} min",
everyHours: "A cada {n} h",
everyDays: "A cada {n} d",
dailyAt: "Diariamente às {time}",
weeklyAt: "Semanalmente {days} às {time}",
monthlyAt: "Mensalmente no dia {day} às {time}",
onceAt: "Uma vez em {time}",
},
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",
cloneFrom: "Clonar a partir do perfil",
cloneFromNone: "Nenhum (vazio)",
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",
customTitle: "Chaves personalizadas",
customHint: "Variáveis de ambiente arbitrárias armazenadas no seu .env que o Hermes não reconhece. Use-as para injetar variáveis de ambiente para skills, servidores MCP ou suas próprias ferramentas.",
customConfigured: "{count} chave(s) personalizada(s) definida(s)",
addCustomKey: "Adicionar uma chave personalizada",
customKeyName: "Nome da variável",
customKeyNamePlaceholder: "ex. MY_SERVICE_API_KEY",
add: "Adicionar",
invalidKeyName: "Use apenas letras, números e sublinhados (deve começar com uma letra ou sublinhado).",
},
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. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.",
connected: "Ligado",
expired: "Expirado",
notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou 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 ✓",
copyCode: "Copiar código",
copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.",
cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / fallback)",
connect: "Ligar",
sessionExpires: "A sessão expira em {time}",
sessionExpiredNoError:
"O login expirou sem alcançar o provedor. Isso geralmente significa que a página de login travou na aba aberta (problema do lado do servidor) — conclua o login lá e clique em Tentar novamente. Se continuar falhando, use uma chave de API ou a CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+779
View File
@@ -0,0 +1,779 @@
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: "Обзор",
filterChats: "Чаты",
filterAutomation: "Автоматизация",
filterAll: "Все",
sourceFilter: "Источник сессии",
anySource: "Любой источник",
searchPlaceholder: "Поиск по содержимому сообщений...",
noSessions: "Сессий пока нет",
noSessionsInFilter: "В этом фильтре нет сессий",
noMatch: "Нет сессий, соответствующих запросу",
startConversation: "Начните разговор, чтобы увидеть его здесь",
noMessages: "Нет сообщений",
untitledSession: "Сессия без названия",
deleteSession: "Удалить сессию",
confirmDeleteTitle: "Удалить сессию?",
confirmDeleteMessage:
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
deleteEmpty: "Удалить пустые",
deleteEmptyConfirmTitle: "Удалить пустые сессии?",
deleteEmptyConfirmMessage:
"Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.",
emptySessionsDeleted: "Удалено пустых сессий: {count}",
failedToDeleteEmpty: "Не удалось удалить пустые сессии",
selectSession: "Выбрать сессию",
selectAllOnPage: "Выбрать все на этой странице",
clearSelection: "Снять выделение",
selectedCount: "Выбрано: {count}",
deleteSelected: "Удалить {count}",
deleteSelectedConfirmTitle: "Удалить {count} сессий?",
deleteSelectedConfirmMessage:
"Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.",
selectedSessionsDeleted: "Удалено сессий: {count}",
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
newChat: "Новый чат",
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 * * *",
scheduleMode: "Расписание",
scheduleModes: {
interval: "Повторяющийся интервал",
daily: "Ежедневно",
weekly: "Еженедельно",
monthly: "Ежемесячно",
once: "Один раз",
custom: "Произвольное (cron-выражение)",
intervalEvery: "Каждые",
intervalUnit: "Единицы",
unitMinutes: "минут",
unitHours: "часов",
unitDays: "дней",
timeOfDay: "Время суток",
weekdays: "Дни недели",
weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День месяца",
onceAt: "Выполнить в",
customLabel: "Cron-выражение",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-выражение из пяти полей (минута, час, день, месяц, день недели).",
preview: "Отправляется как",
previewEmpty: "(не заполнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Каждые {n} мин",
everyHours: "Каждые {n} ч",
everyDays: "Каждые {n} дн",
dailyAt: "Ежедневно в {time}",
weeklyAt: "Еженедельно в {days} в {time}",
monthlyAt: "Ежемесячно {day} числа в {time}",
onceAt: "Один раз {time}",
},
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: "Недопустимое имя профиля", cloneFrom: "Клонировать конфигурацию из профиля",
cloneFromNone: "Нет (пусто)",
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: "Скрыть значение",
customTitle: "Пользовательские ключи",
customHint: "Произвольные переменные окружения, сохранённые в вашем .env, которые Hermes не распознаёт. Используйте их для внедрения переменных окружения для навыков, серверов MCP или собственных инструментов.",
customConfigured: "Задано пользовательских ключей: {count}",
addCustomKey: "Добавить пользовательский ключ",
customKeyName: "Имя переменной",
customKeyNamePlaceholder: "напр. MY_SERVICE_API_KEY",
add: "Добавить",
invalidKeyName: "Используйте только буквы, цифры и подчёркивания (должно начинаться с буквы или подчёркивания).",
},
oauth: {
title: "Входы провайдеров (OAuth)",
providerLogins: "Входы провайдеров (OAuth)",
description:
"Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.",
connected: "Подключено",
expired: "Срок истёк",
notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.",
runInTerminal: "в терминале.",
noProviders: "OAuth-совместимые провайдеры не обнаружены.",
login: "Войти",
disconnect: "Отключить",
managedExternally: "Управляется извне",
copied: "Скопировано ✓",
copyCode: "Скопировать код",
copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.",
cli: "Копировать",
copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
connect: "Подключить",
sessionExpires: "Сессия истечёт через {time}",
sessionExpiredNoError:
"Время входа истекло, не достигнув провайдера. Обычно это означает, что страница входа зависла в открытой вкладке (проблема на стороне сервера) — завершите вход там, затем нажмите «Повторить». Если ошибка повторяется, используйте API-ключ или CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+779
View File
@@ -0,0 +1,779 @@
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ış",
filterChats: "Sohbetler",
filterAutomation: "Otomasyon",
filterAll: "Tümü",
sourceFilter: "Oturum kaynağı",
anySource: "Herhangi bir kaynak",
searchPlaceholder: "Mesaj içeriğinde ara...",
noSessions: "Henüz oturum yok",
noSessionsInFilter: "Bu filtrede 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",
deleteEmpty: "Boşları sil",
deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?",
deleteEmptyConfirmMessage:
"Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.",
emptySessionsDeleted: "{count} boş oturum silindi",
failedToDeleteEmpty: "Boş oturumlar silinemedi",
selectSession: "Oturumu seç",
selectAllOnPage: "Bu sayfadakilerin tümünü seç",
clearSelection: "Seçimi temizle",
selectedCount: "{count} seçildi",
deleteSelected: "{count} sil",
deleteSelectedConfirmTitle: "{count} oturum silinsin mi?",
deleteSelectedConfirmMessage:
"Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
selectedSessionsDeleted: "{count} oturum silindi",
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
newChat: "Yeni sohbet",
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 * * *",
scheduleMode: "Zamanlama",
scheduleModes: {
interval: "Tekrarlanan aralık",
daily: "Günlük",
weekly: "Haftalık",
monthly: "Aylık",
once: "Bir kez",
custom: "Özel (cron ifadesi)",
intervalEvery: "Her",
intervalUnit: "Birim",
unitMinutes: "dakika",
unitHours: "saat",
unitDays: "gün",
timeOfDay: "Günün saati",
weekdays: "Haftanın günleri",
weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
dayOfMonth: "Ayın günü",
onceAt: "Çalıştırma zamanı",
customLabel: "Cron ifadesi",
customPlaceholder: "0 9 * * *",
customHint:
"Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).",
preview: "Gönderilecek olan",
previewEmpty: "(eksik)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Her {n} dk",
everyHours: "Her {n} sa",
everyDays: "Her {n} gün",
dailyAt: "Her gün {time}",
weeklyAt: "Her hafta {days} {time}",
monthlyAt: "Her ayın {day} günü {time}",
onceAt: "{time} bir kez",
},
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ı", cloneFrom: "Profilden yapılandırmayı klonla",
cloneFromNone: "Hiçbiri (boş)",
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",
customTitle: "Özel Anahtarlar",
customHint: ".env dosyanızda saklanan ve Hermes'in tanımadığı rastgele ortam değişkenleri. Bunları beceriler, MCP sunucuları veya kendi araçlarınız için ortam değişkenleri eklemek için kullanın.",
customConfigured: "{count} özel anahtar ayarlandı",
addCustomKey: "Özel anahtar ekle",
customKeyName: "Değişken adı",
customKeyNamePlaceholder: "örn. MY_SERVICE_API_KEY",
add: "Ekle",
invalidKeyName: "Yalnızca harf, rakam ve alt çizgi kullanın (bir harf veya alt çizgi ile başlamalıdır).",
},
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ı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.",
connected: "Bağlandı",
expired: "Süresi doldu",
notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya 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ı ✓",
copyCode: "Kodu kopyala",
copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.",
cli: "Kopyala",
copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
connect: "Bağlan",
sessionExpires: "Oturumun süresi {time} sonra dolacak",
sessionExpiredNoError:
"Oturum açma, sağlayıcıya ulaşmadan süresi doldu. Bu genellikle oturum açma sayfasının açılan sekmede takıldığı anlamına gelir (sunucu tarafı sorun) — oradan oturum açmayı tamamlayın, ardından Yeniden Dene'ye tıklayın. Sorun devam ederse bunun yerine bir API anahtarı veya CLI kullanın.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+875
View File
@@ -0,0 +1,875 @@
export type Locale =
| "en"
| "zh"
| "zh-hant"
| "ja"
| "de"
| "es"
| "fr"
| "tr"
| "uk"
| "af"
| "ko"
| "it"
| "ga"
| "pt"
| "ru"
| "hu"
| "ar";
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;
// Optional: non-English locales fall back to the English literal in the
// component until translated, matching the enriched-profiles keys.
gateway?: string;
gatewayHint?: 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;
/** Optional — fall back to English literals until translated. */
managingProfile?: string;
currentProfileOption?: string;
managingProfileBanner?: string;
/** NS-656 memory-pressure banner — optional, English fallback. */
memoryOomRestartBanner?: string;
memoryCriticalBanner?: string;
memoryElevatedBanner?: string;
/** NS-656 disk-usage banner — optional, English fallback. */
diskCriticalBanner?: string;
diskElevatedBanner?: string;
dismiss?: string;
};
// ── Status page ──
status: {
actionFailed: string;
actionFinished: string;
actions: string;
agent: string;
connected: string;
connectedPlatforms: string;
disabled?: 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;
restartGatewayConfirmMessage?: string;
restartGatewayConfirmTitle?: string;
restartingGateway: string;
running: string;
runningRemote: string;
startFailed: string;
starting: string;
startedInBackground: string;
stopped: string;
updateHermes: string;
updateHermesConfirmMessage?: string;
updateHermesConfirmNow?: string;
updateHermesConfirmTitle?: string;
updatingHermes: string;
waitingForOutput: string;
};
// ── Sessions page ──
sessions: {
title: string;
history: string;
overview: string;
filterChats: string;
filterAutomation: string;
filterAll: string;
sourceFilter: string;
anySource: string;
searchPlaceholder: string;
noSessions: string;
noSessionsInFilter: string;
noMatch: string;
startConversation: string;
noMessages: string;
untitledSession: string;
deleteSession: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
deleteEmpty: string;
deleteEmptyConfirmTitle: string;
deleteEmptyConfirmMessage: string;
emptySessionsDeleted: string;
failedToDeleteEmpty: string;
selectSession: string;
selectAllOnPage: string;
clearSelection: string;
selectedCount: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmMessage: string;
selectedSessionsDeleted: string;
failedToDeleteSelected: string;
resumeInChat: string;
newChat: 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;
scheduleMode: string;
scheduleModes: {
interval: string;
daily: string;
weekly: string;
monthly: string;
once: string;
custom: string;
intervalEvery: string;
intervalUnit: string;
unitMinutes: string;
unitHours: string;
unitDays: string;
timeOfDay: string;
weekdays: string;
weekdaysShort: [string, string, string, string, string, string, string];
dayOfMonth: string;
onceAt: string;
customLabel: string;
customPlaceholder: string;
customHint: string;
preview: string;
previewEmpty: string;
};
scheduleDescribe: {
none: string;
everyMinutes: string;
everyHours: string;
everyDays: string;
dailyAt: string;
weeklyAt: string;
monthlyAt: string;
onceAt: 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;
needsHomeChannel?: string;
noneConfigured?: 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;
cloneFrom: string;
cloneFromNone: 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;
// Optional keys added for the enriched profiles experience. Non-English
// locales fall back to the English literal in the component until
// translated, so these are optional to avoid churning every locale file.
activeProfile?: string;
activeBadge?: string;
setActive?: string;
activeSet?: string;
gatewayRunning?: string;
gatewayStopped?: string;
gatewayRunningWarning?: string;
aliasBadge?: string;
description?: string;
descriptionPlaceholder?: string;
noDescription?: string;
editDescription?: string;
descriptionSaved?: string;
reviewBadge?: string;
autoGenerate?: string;
generating?: string;
describeFailed?: string;
distribution?: string;
advancedOptions?: string;
cloneAll?: string;
noSkillsOption?: string;
descriptionOptional?: string;
modelOptional?: string;
modelInherit?: string;
modelLoading?: string;
modelNone?: string;
editModel?: string;
modelSaved?: string;
modelSelect?: string;
actions?: string;
manageSkills?: string;
activeSetHint?: 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;
/** Optional — fall back to English literals until translated. */
profileSelector?: string;
currentProfile?: string;
managingProfile?: 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;
customTitle: string;
customHint: string;
customConfigured: string;
addCustomKey: string;
customKeyName: string;
customKeyNamePlaceholder: string;
add: string;
invalidKeyName: 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;
copyCode: string;
copyFailed: string;
cli: string;
copyCliCommand: string;
connect: string;
sessionExpires: string;
sessionExpiredNoError: 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;
/** Font-override section (optional — locales fall back to English). */
fontTitle?: string;
fontDefault?: string;
fontDefaultHint?: string;
fontSans?: string;
fontSerif?: string;
fontMono?: 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;
confirmDoneMany: string;
confirmArchiveMany: string;
confirmBlockedMany: 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;
// Optional keys added with the modal create-task dialog, board-settings
// dialog, and comment workflow hint. Non-English locales fall back to
// the English literal in the plugin bundle until translated, so these
// are optional to avoid churning every locale file.
newTaskTitle?: string;
taskTitleLabel?: string;
assigneeLabel?: string;
assigneeLabelHint?: string;
skillsLabel?: string;
skillsLabelHint?: string;
parentLabel?: string;
parentLabelHint?: string;
create?: string;
boardSettings?: string;
boardSettingsTitle?: string;
boardSettingsTitleFor?: string;
projectDirectoryOverrideHint?: string;
saving?: string;
commentHint?: string;
commentHintTitle?: string;
// Optional in-app confirm-dialog strings for the trash/delete flow;
// non-English locales fall back to the English literals in the bundle.
trash?: {
confirmTitle?: string;
confirmManyTitle?: string;
};
};
}
+780
View File
@@ -0,0 +1,780 @@
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: "Огляд",
filterChats: "Чати",
filterAutomation: "Автоматизація",
filterAll: "Усі",
sourceFilter: "Джерело сесії",
anySource: "Будь-яке джерело",
searchPlaceholder: "Пошук у вмісті повідомлень...",
noSessions: "Поки немає сесій",
noSessionsInFilter: "У цьому фільтрі немає сесій",
noMatch: "Жодна сесія не відповідає вашому пошуку",
startConversation: "Почніть розмову, щоб побачити її тут",
noMessages: "Немає повідомлень",
untitledSession: "Сесія без назви",
deleteSession: "Видалити сесію",
confirmDeleteTitle: "Видалити сесію?",
confirmDeleteMessage:
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
deleteEmpty: "Видалити порожні",
deleteEmptyConfirmTitle: "Видалити порожні сесії?",
deleteEmptyConfirmMessage:
"Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.",
emptySessionsDeleted: "Видалено порожніх сесій: {count}",
failedToDeleteEmpty: "Не вдалося видалити порожні сесії",
selectSession: "Вибрати сесію",
selectAllOnPage: "Вибрати всі на цій сторінці",
clearSelection: "Скинути вибір",
selectedCount: "Вибрано: {count}",
deleteSelected: "Видалити {count}",
deleteSelectedConfirmTitle: "Видалити {count} сесій?",
deleteSelectedConfirmMessage:
"Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.",
selectedSessionsDeleted: "Видалено сесій: {count}",
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
newChat: "Новий чат",
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 * * *",
scheduleMode: "Розклад",
scheduleModes: {
interval: "Повторюваний інтервал",
daily: "Щодня",
weekly: "Щотижня",
monthly: "Щомісяця",
once: "Один раз",
custom: "Користувацьке (cron-вираз)",
intervalEvery: "Кожні",
intervalUnit: "Одиниці",
unitMinutes: "хвилин",
unitHours: "годин",
unitDays: "днів",
timeOfDay: "Час доби",
weekdays: "Дні тижня",
weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День місяця",
onceAt: "Виконати о",
customLabel: "Cron-вираз",
customPlaceholder: "0 9 * * *",
customHint:
"П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).",
preview: "Надсилається як",
previewEmpty: "(не заповнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Кожні {n} хв",
everyHours: "Кожні {n} год",
everyDays: "Кожні {n} дн",
dailyAt: "Щодня о {time}",
weeklyAt: "Щотижня у {days} о {time}",
monthlyAt: "Щомісяця {day} числа о {time}",
onceAt: "Один раз {time}",
},
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: "Недопустима назва профілю",
cloneFrom: "Клонувати з профілю",
cloneFromNone: "Жоден (порожній)",
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: "Сховати значення",
customTitle: "Власні ключі",
customHint: "Довільні змінні середовища, збережені у вашому .env, які Hermes не розпізнає. Використовуйте їх для впровадження змінних середовища для навичок, серверів MCP або власних інструментів.",
customConfigured: "Задано власних ключів: {count}",
addCustomKey: "Додати власний ключ",
customKeyName: "Назва змінної",
customKeyNamePlaceholder: "напр. MY_SERVICE_API_KEY",
add: "Додати",
invalidKeyName: "Використовуйте лише літери, цифри та підкреслення (має починатися з літери або підкреслення).",
},
oauth: {
title: "Входи постачальників (OAuth)",
providerLogins: "Входи постачальників (OAuth)",
description:
"Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.",
connected: "Підключено",
expired: "Прострочено",
notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.",
runInTerminal: "у терміналі.",
noProviders: "Не виявлено постачальників із підтримкою OAuth.",
login: "Увійти",
disconnect: "Відключити",
managedExternally: "Керується ззовні",
copied: "Скопійовано ✓",
copyCode: "Скопіювати код",
copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.",
cli: "Копіювати",
copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
connect: "Підключити",
sessionExpires: "Сесія завершиться через {time}",
sessionExpiredNoError:
"Час входу минув, не досягнувши провайдера. Зазвичай це означає, що сторінка входу зависла у відкритій вкладці (проблема на боці сервера) — завершіть вхід там, потім натисніть «Повторити». Якщо помилка повторюється, скористайтеся API-ключем або CLI.",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+778
View File
@@ -0,0 +1,778 @@
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: "總覽",
filterChats: "聊天",
filterAutomation: "自動化",
filterAll: "全部",
sourceFilter: "工作階段來源",
anySource: "任何來源",
searchPlaceholder: "搜尋訊息內容...",
noSessions: "尚無工作階段",
noSessionsInFilter: "此篩選條件下沒有工作階段",
noMatch: "沒有符合的工作階段",
startConversation: "開始對話後將顯示於此",
noMessages: "尚無訊息",
untitledSession: "未命名工作階段",
deleteSession: "刪除工作階段",
confirmDeleteTitle: "刪除工作階段?",
confirmDeleteMessage:
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
deleteEmpty: "刪除空工作階段",
deleteEmptyConfirmTitle: "刪除空工作階段?",
deleteEmptyConfirmMessage:
"這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。",
emptySessionsDeleted: "已刪除 {count} 個空工作階段",
failedToDeleteEmpty: "刪除空工作階段失敗",
selectSession: "選擇工作階段",
selectAllOnPage: "全選本頁",
clearSelection: "清除選擇",
selectedCount: "已選擇 {count} 個",
deleteSelected: "刪除 {count} 個",
deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?",
deleteSelectedConfirmMessage:
"此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。",
selectedSessionsDeleted: "已刪除 {count} 個工作階段",
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
newChat: "新對話",
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 * * *",
scheduleMode: "排程",
scheduleModes: {
interval: "重複間隔",
daily: "每日",
weekly: "每週",
monthly: "每月",
once: "僅一次",
custom: "自訂(cron 運算式)",
intervalEvery: "每",
intervalUnit: "單位",
unitMinutes: "分鐘",
unitHours: "小時",
unitDays: "天",
timeOfDay: "時間",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "執行時間",
customLabel: "cron 運算式",
customPlaceholder: "0 9 * * *",
customHint: "五欄位 cron 運算式(分、時、日、月、星期)。",
preview: "傳送為",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分鐘",
everyHours: "每 {n} 小時",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每週 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 執行一次",
},
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: "設定檔名稱無效", cloneFrom: "從設定檔複製",
cloneFromNone: "無(空白)",
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: "隱藏值",
customTitle: "自訂密鑰",
customHint: "儲存在 .env 中、Hermes 無法識別的任意環境變數。可用於為技能、MCP 伺服器或你自己的工具注入環境變數。",
customConfigured: "已設定 {count} 個自訂密鑰",
addCustomKey: "新增自訂密鑰",
customKeyName: "變數名稱",
customKeyNamePlaceholder: "例如 MY_SERVICE_API_KEY",
add: "新增",
invalidKeyName: "僅能使用字母、數字和底線(必須以字母或底線開頭)。",
},
oauth: {
title: "提供者登入(OAuth)",
providerLogins: "提供者登入(OAuth)",
description:
"已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」;CLI 指令仍可用於外部或備用設定。",
connected: "已連線",
expired: "已過期",
notConnected: "未連線。可用時請使用「登入」,或在終端機執行 {command}。",
runInTerminal: "於終端機。",
noProviders: "未偵測到支援 OAuth 的提供者。",
login: "登入",
disconnect: "中斷連線",
managedExternally: "由外部管理",
copied: "已複製 ✓",
copyCode: "複製代碼",
copyFailed: "無法自動複製。請選取代碼並手動複製。",
cli: "複製",
copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
connect: "連線",
sessionExpires: "工作階段將於 {time} 後過期",
sessionExpiredNoError:
"登入已過期,且未能連線到提供方。這通常表示登入頁面在開啟的分頁中卡住了(伺服器端問題)——請在該分頁完成登入後點擊重試。若仍失敗,請改用 API 金鑰或 CLI。",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+774
View File
@@ -0,0 +1,774 @@
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: "概览",
filterChats: "聊天",
filterAutomation: "自动化",
filterAll: "全部",
sourceFilter: "会话来源",
anySource: "任意来源",
searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话",
noSessionsInFilter: "此筛选条件下暂无会话",
noMatch: "没有匹配的会话",
startConversation: "开始对话后将显示在此处",
noMessages: "暂无消息",
untitledSession: "无标题会话",
deleteSession: "删除会话",
confirmDeleteTitle: "删除会话?",
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
deleteEmpty: "删除空会话",
deleteEmptyConfirmTitle: "删除空会话?",
deleteEmptyConfirmMessage:
"这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。",
emptySessionsDeleted: "已删除 {count} 个空会话",
failedToDeleteEmpty: "删除空会话失败",
selectSession: "选择会话",
selectAllOnPage: "全选本页",
clearSelection: "清除选择",
selectedCount: "已选择 {count} 个",
deleteSelected: "删除 {count} 个",
deleteSelectedConfirmTitle: "删除 {count} 个会话?",
deleteSelectedConfirmMessage:
"此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。",
selectedSessionsDeleted: "已删除 {count} 个会话",
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
newChat: "新对话",
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 * * *",
scheduleMode: "调度",
scheduleModes: {
interval: "重复间隔",
daily: "每天",
weekly: "每周",
monthly: "每月",
once: "仅一次",
custom: "自定义(cron 表达式)",
intervalEvery: "每",
intervalUnit: "单位",
unitMinutes: "分钟",
unitHours: "小时",
unitDays: "天",
timeOfDay: "时间",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "执行时间",
customLabel: "cron 表达式",
customPlaceholder: "0 9 * * *",
customHint: "五字段 cron 表达式(分、时、日、月、星期)。",
preview: "发送为",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分钟",
everyHours: "每 {n} 小时",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每周 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 执行一次",
},
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配置名称非法", cloneFrom: "从配置文件克隆",
cloneFromNone: "无(空白)",
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: "隐藏值",
customTitle: "自定义密钥",
customHint: "存储在 .env 中、Hermes 无法识别的任意环境变量。可用于为技能、MCP 服务器或你自己的工具注入环境变量。",
customConfigured: "已设置 {count} 个自定义密钥",
addCustomKey: "添加自定义密钥",
customKeyName: "变量名",
customKeyNamePlaceholder: "例如 MY_SERVICE_API_KEY",
add: "添加",
invalidKeyName: "只能使用字母、数字和下划线(必须以字母或下划线开头)。",
},
oauth: {
title: "提供商登录(OAuth)",
providerLogins: "提供商登录(OAuth)",
description:
"已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」;CLI 命令仍可用于外部或备用设置。",
connected: "已连接",
expired: "已过期",
notConnected: "未连接。可用时请使用「登录」,或在终端中运行 {command}。",
runInTerminal: "在终端中。",
noProviders: "未检测到支持 OAuth 的提供商。",
login: "登录",
disconnect: "断开连接",
managedExternally: "外部管理",
copied: "已复制 ✓",
copyCode: "复制代码",
copyFailed: "无法自动复制。请选中代码并手动复制。",
cli: "复制",
copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
connect: "连接",
sessionExpires: "会话将在 {time} 后过期",
sessionExpiredNoError:
"登录已过期,且未能连接到提供方。这通常意味着登录页面在打开的标签页中卡住了(服务端问题)——请在该标签页完成登录,然后点击重试。如果仍然失败,请改用 API 密钥或 CLI。",
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",
confirmDoneMany:
"Mark {n} tasks as done? The workers' claims are released and dependent children become ready.",
confirmArchiveMany:
"Archive {n} tasks? They disappear from the default board view.",
confirmBlockedMany:
"Mark {n} tasks as blocked? The workers' claims are released.",
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: ")",
},
};
+255
View File
@@ -0,0 +1,255 @@
@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) palette applied
statically as the default dashboard theme. */
/* ------------------------------------------------------------------ */
: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;
/* 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;
/* Data-series accents — consumed by Analytics + Models pages for the
input-vs-output token visualisations (chart bars, table values,
legend swatches). Defaults are tuned for the Hermes-teal LENS_0
look: cream input + emerald-400 output read as warm/cool against
the dark canvas. Themes override via ThemeProvider, which emits
these as `--series-input-token` / `--series-output-token`. */
--series-input-token: #ffe6cb;
--series-output-token: #34d399;
}
/* 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;
}
/* 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;
}
/* RTL support — Arabic and any future right-to-left locale. The i18n provider
sets `dir` on <html>; Tailwind v4's logical spacing utilities (ms-/me-,
ps-/pe-) and logical properties then flip automatically. Scoped so the
default LTR layout is untouched. */
html[dir="rtl"] {
direction: rtl;
}
+202
View File
@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, fetchJSON, setManagementProfile } from "./api";
const reloadMocks = vi.hoisted(() => ({
attemptDashboardTokenReloadOnce: vi.fn(() => false),
clearDashboardTokenReloadAttempt: vi.fn(),
}));
vi.mock("./dashboard-auth-reload", () => ({
attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt,
}));
const SESSION_HEADER = "X-Hermes-Session-Token";
beforeEach(() => {
reloadMocks.attemptDashboardTokenReloadOnce.mockReset();
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false);
reloadMocks.clearDashboardTokenReloadAttempt.mockReset();
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
});
afterEach(() => {
setManagementProfile("");
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function jsonFetchMock(body: unknown = { ok: true }) {
return vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
status: 200,
}),
);
}
describe("fetchJSON", () => {
it("tries the one-shot reload path for loopback 401s", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
clone: () => ({
json: async () => ({}),
}),
ok: false,
status: 401,
statusText: "Unauthorized",
text: async () => "Unauthorized",
})),
);
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true);
const pending = fetchJSON("/api/status");
await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe(
"pending",
);
expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1);
expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled();
});
it("clears the reload latch after a successful response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
json: async () => ({ ok: true }),
ok: true,
status: 200,
})),
);
await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true });
expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1);
});
});
describe("api.getModelOptions", () => {
it("requests a live model refresh when asked", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ providers: [] });
vi.stubGlobal("fetch", fetchMock);
await api.getModelOptions({ refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});
it("keeps explicit profile scoping when refreshing", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ providers: [] });
vi.stubGlobal("fetch", fetchMock);
await api.getModelOptions({ profile: "default", refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?profile=default&refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});
});
describe("api OAuth helpers", () => {
it("starts OAuth login in gated mode without requiring an injected session token", async () => {
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
await api.startOAuthLogin("openai-codex");
expect(fetchMock).toHaveBeenCalledWith(
"/api/providers/oauth/openai-codex/start",
expect.objectContaining({
body: "{}",
credentials: "include",
method: "POST",
}),
);
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.has(SESSION_HEADER)).toBe(false);
});
it("still sends the injected session token for OAuth login in loopback mode", async () => {
vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" });
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
await api.startOAuthLogin("openai-codex");
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
expect(headers.get(SESSION_HEADER)).toBe("loopback-token");
});
it("runs provider auth mutations in gated mode via cookie auth", async () => {
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
const fetchMock = jsonFetchMock({ ok: true });
vi.stubGlobal("fetch", fetchMock);
await api.disconnectOAuthProvider("anthropic");
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
await api.cancelOAuthSession("oauth-session");
await api.revealEnvVar("OPENAI_API_KEY");
for (const call of fetchMock.mock.calls) {
const init = call[1] as RequestInit;
expect(init.credentials).toBe("include");
expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
}
});
it("keeps every OAuth operation on the selected management profile", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await api.getOAuthProviders();
await api.disconnectOAuthProvider("anthropic");
await api.startOAuthLogin("openai-codex");
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
await api.pollOAuthSession("anthropic", "oauth-session");
await api.cancelOAuthSession("oauth-session");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/providers/oauth?profile=worker",
"/api/providers/oauth/anthropic?profile=worker",
"/api/providers/oauth/openai-codex/start?profile=worker",
"/api/providers/oauth/anthropic/submit?profile=worker",
"/api/providers/oauth/anthropic/poll/oauth-session?profile=worker",
"/api/providers/oauth/sessions/oauth-session?profile=worker",
]);
});
});
+2670
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { latchChatActivation } from "./chat-activation";
describe("latchChatActivation", () => {
it("stays inactive while the chat tab has never been active", () => {
// A dashboard sitting on /sessions, /system, … must not flip the latch,
// so the persistently-mounted ChatPage never opens /api/pty (which would
// trigger the TUI/agent bootstrap on every page).
expect(latchChatActivation(false, false)).toBe(false);
});
it("activates when the chat tab becomes active", () => {
expect(latchChatActivation(false, true)).toBe(true);
});
it("stays activated after the chat tab is left (sticky / persistence)", () => {
// Once the user has opened /chat, the PTY must survive navigating away so
// a running agent turn is not torn down on every tab switch.
expect(latchChatActivation(true, false)).toBe(true);
});
it("stays activated while the chat tab remains active", () => {
expect(latchChatActivation(true, true)).toBe(true);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Chat PTY activation latch.
*
* The dashboard keeps `ChatPage` mounted persistently (just hidden with CSS)
* on every route so the embedded chat PTY survives tab switches. The downside
* is that the PTY-connect effect would otherwise open `/api/pty` — which spawns
* the whole TUI + agent bootstrap (on a fresh checkout this prints
* `Installing TUI dependencies…` and runs `npm install`) — the moment the
* dashboard loads *any* page, even one the user never navigates the chat into.
*
* The fix is to only open the PTY once the chat tab has actually been active,
* while keeping activation **sticky** so the PTY still persists across later
* tab switches. This helper computes that latch: once `true`, it stays `true`.
*/
export function latchChatActivation(previous: boolean, isActive: boolean): boolean {
return previous || isActive;
}
@@ -0,0 +1,35 @@
/**
* Tests for the sidecar ``session.create`` params built by ChatSidebar.
*
* The sidecar must opt its session into close_on_disconnect so the gateway
* reaps the slash_worker on WS disconnect (the #21370/#21467 leak), and it
* must pass the dashboard's selected profile so model/credential info
* matches the PTY child under profile-scoped chat.
*
*/
import { describe, expect, it } from "vitest";
import { sidecarSessionCreateParams } from "@/components/ChatSidebar";
describe("sidecarSessionCreateParams", () => {
it("opts into close_on_disconnect", () => {
const params = sidecarSessionCreateParams();
expect(params.close_on_disconnect).toBe(true);
});
it("sets source to 'tool'", () => {
const params = sidecarSessionCreateParams();
expect(params.source).toBe("tool");
});
it("forwards the profile when present", () => {
const params = sidecarSessionCreateParams("work");
expect(params.profile).toBe("work");
});
it("omits profile when undefined", () => {
const params = sidecarSessionCreateParams();
expect(params).not.toHaveProperty("profile");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title";
describe("normalizeSessionTitle", () => {
it("trims non-empty session titles", () => {
expect(normalizeSessionTitle(" Rename the dashboard ")).toBe(
"Rename the dashboard",
);
});
it("treats blank and non-string values as no title", () => {
expect(normalizeSessionTitle(" ")).toBeNull();
expect(normalizeSessionTitle(null)).toBeNull();
expect(normalizeSessionTitle(42)).toBeNull();
});
});
describe("titleFromSessionInfoPayload", () => {
it("returns undefined when the payload has no title field", () => {
expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined();
expect(titleFromSessionInfoPayload(null)).toBeUndefined();
});
it("returns null when the title field is present but empty", () => {
expect(titleFromSessionInfoPayload({ title: "" })).toBeNull();
expect(titleFromSessionInfoPayload({ title: " " })).toBeNull();
});
it("returns the normalized title when present", () => {
expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe(
"Live session title",
);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function normalizeSessionTitle(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const title = raw.trim();
return title ? title : null;
}
export function titleFromSessionInfoPayload(
payload: unknown,
): string | null | undefined {
if (!payload || typeof payload !== "object" || !("title" in payload)) {
return undefined;
}
return normalizeSessionTitle((payload as { title?: unknown }).title);
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
firstImageFromClipboard,
imageFilesFromTransfer,
transferMayContainImage,
} from "./chatImagePaste";
// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed
// items/files, so we hand-roll the shape the helpers read.
function makeItem(kind: string, type: string, file: File | null) {
return { kind, type, getAsFile: () => file } as unknown as DataTransferItem;
}
function makeData(opts: {
items?: DataTransferItem[];
files?: File[];
}): DataTransfer {
const items = opts.items ?? [];
const files = opts.files ?? [];
const itemList: Record<string | number, unknown> = { length: items.length };
items.forEach((it, i) => {
itemList[i] = it;
});
const fileList: Record<string | number, unknown> = { length: files.length };
files.forEach((f, i) => {
fileList[i] = f;
});
return {
items: itemList,
files: fileList,
} as unknown as DataTransfer;
}
const png = new File([new Uint8Array([1, 2, 3])], "x.png", {
type: "image/png",
});
const gif = new File([new Uint8Array([4, 5])], "y.gif", {
type: "image/gif",
});
describe("firstImageFromClipboard", () => {
it("returns null for null clipboard data", () => {
expect(firstImageFromClipboard(null)).toBeNull();
});
it("finds an image via items[].getAsFile()", () => {
const data = makeData({ items: [makeItem("file", "image/png", png)] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("ignores non-file and non-image items", () => {
const data = makeData({
items: [
makeItem("string", "text/plain", null),
makeItem("file", "application/pdf", new File([], "a.pdf")),
],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
it("falls back to files[] when items are absent (Safari/Firefox)", () => {
const data = makeData({ files: [png] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("returns null when nothing image-like is present", () => {
const data = makeData({
files: [new File([], "notes.txt", { type: "text/plain" })],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
});
describe("imageFilesFromTransfer", () => {
it("dedupes the same file when present in both items and files", () => {
const data = makeData({
items: [makeItem("file", "image/png", png)],
files: [png, gif],
});
expect(imageFilesFromTransfer(data)).toEqual([png, gif]);
});
});
describe("transferMayContainImage", () => {
it("is true for image items even when type is empty (some browsers)", () => {
const data = makeData({
items: [makeItem("file", "", png)],
});
expect(transferMayContainImage(data)).toBe(true);
});
it("is false for text-only transfers", () => {
const data = makeData({
items: [makeItem("string", "text/plain", null)],
});
expect(transferMayContainImage(data)).toBe(false);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { authedFetch } from "@/lib/api";
// Clipboard image MIME → file extension. Mirrors the set the TUI's /image
// attach path and the gateway's image sniffer accept.
const IMAGE_MIME_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
};
// Anthropic caps a single vision image at ~25 MB; reject earlier client-side
// with a clear message rather than round-tripping a doomed upload.
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
export interface ChatImageUploadResult {
/** Absolute path under HERMES_HOME/images the gateway wrote. */
path: string;
/** Byte size of the uploaded image. */
bytes: number;
/** Basename written on the server. */
name: string;
mime_type: string;
}
function imageFileKey(file: File): string {
return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`;
}
function addImageFile(files: File[], seen: Set<string>, file: File | null) {
if (!file || !file.type.startsWith("image/")) return;
const key = imageFileKey(file);
if (seen.has(key)) return;
seen.add(key);
files.push(file);
}
/** Pull every image file out of a DataTransfer (clipboard or drop). */
export function imageFilesFromTransfer(
data: DataTransfer | null,
): File[] {
if (!data) return [];
const files: File[] = [];
const seen = new Set<string>();
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
addImageFile(files, seen, item.getAsFile());
}
}
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
addImageFile(files, seen, data.files[i]);
}
}
return files;
}
/** Pull the first image blob out of a DataTransfer, or null if none present. */
export function firstImageFromClipboard(
data: DataTransfer | null,
): File | null {
return imageFilesFromTransfer(data)[0] ?? null;
}
/** True when a drag payload may contain an image (for dragover preventDefault). */
export function transferMayContainImage(data: DataTransfer | null): boolean {
if (!data) return false;
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (
item.kind === "file" &&
(!item.type || item.type.startsWith("image/"))
) {
return true;
}
}
return false;
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
if (data.files[i].type.startsWith("image/")) return true;
}
}
return false;
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error("image read failed"));
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
resolve(result);
} else {
reject(new Error("image read failed"));
}
};
reader.readAsDataURL(file);
});
}
/**
* Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the
* dedicated chat upload endpoint and return the absolute gateway path.
*
* The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the
* gateway. The container has no access to the browser's clipboard, so the
* server-side ``clipboard.paste`` path can never see a pasted image.
* Upload the bytes the browser already holds, then hand the path to the
* TUI's ``/image`` command.
*/
export async function uploadChatImage(
blob: Blob,
profile = "",
): Promise<ChatImageUploadResult> {
if (blob.size === 0) throw new Error("clipboard image is empty");
if (blob.size > MAX_IMAGE_BYTES) {
const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
throw new Error(`image too large (max ${mb} MB)`);
}
const mime = blob.type || "image/png";
const ext = IMAGE_MIME_EXT[mime] || "png";
const filename =
blob instanceof File && blob.name
? blob.name
: `clipboard.${ext}`;
const file =
blob instanceof File
? blob
: new File([blob], filename, { type: mime });
const dataUrl = await fileToDataUrl(file);
const qs = profile ? `?profile=${encodeURIComponent(profile)}` : "";
const res = await authedFetch(`/api/chat/image-upload${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data_url: dataUrl,
filename,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || `HTTP ${res.status}`);
}
const uploaded = (await res.json()) as ChatImageUploadResult;
if (!uploaded?.path) {
throw new Error("image upload did not return a path");
}
return uploaded;
}
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
// Regression guard: every dashboard copy action must go through
// copyTextToClipboard (web/src/lib/clipboard.ts), which falls back to a
// selection-based copy when the async Clipboard API is unavailable —
// e.g. self-hosted dashboards served over plain HTTP on a LAN, where
// window.isSecureContext is false and navigator.clipboard is undefined.
//
// Clipboard READS (paste paths) have no equivalent legacy fallback and are
// exempt; they must feature-detect and fail soft instead.
//
// Ported from paperclipai/paperclip#10875.
const SRC_ROOT = join(__dirname, "..");
// The helper itself is the single allowed writer.
const ALLOWLIST = new Set(["lib/clipboard.ts"]);
function collectSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
collectSourceFiles(full, out);
} else if (/\.(ts|tsx)$/.test(entry) && !/\.(test|spec)\.(ts|tsx)$/.test(entry)) {
out.push(full);
}
}
return out;
}
describe("clipboard write regression guard", () => {
it("web/src has no direct navigator.clipboard write calls outside lib/clipboard.ts", () => {
const offenders: string[] = [];
for (const file of collectSourceFiles(SRC_ROOT)) {
const rel = relative(SRC_ROOT, file).replace(/\\/g, "/");
if (ALLOWLIST.has(rel)) continue;
const source = readFileSync(file, "utf8");
// Match writeText / write on navigator.clipboard (incl. optional
// chaining); reads (read / readText) are intentionally not matched.
const pattern = /navigator\.clipboard\??\s*\.\s*write(Text)?\s*[(.]/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const line = source.slice(0, match.index).split("\n").length;
offenders.push(`${rel}:${line}`);
}
}
expect(
offenders,
`Direct navigator.clipboard writes found — use copyTextToClipboard from "@/lib/clipboard" instead:\n${offenders.join("\n")}`,
).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyTextToClipboard } from "./clipboard";
const originalNavigator = globalThis.navigator;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
function setGlobal<K extends keyof typeof globalThis>(
key: K,
value: (typeof globalThis)[K] | undefined,
) {
Object.defineProperty(globalThis, key, {
configurable: true,
value,
});
}
afterEach(() => {
setGlobal("navigator", originalNavigator);
setGlobal("document", originalDocument);
setGlobal("window", originalWindow);
vi.restoreAllMocks();
});
describe("copyTextToClipboard", () => {
it("uses navigator.clipboard when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal("document", undefined);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
});
it("falls back to selection copy when Clipboard API fails", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
const appendChild = vi.fn();
const removeChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
const textarea = {
focus: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
setSelectionRange: vi.fn(),
style: {},
value: "",
} as unknown as HTMLTextAreaElement;
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal("document", {
body: { appendChild, removeChild },
createElement: vi.fn(() => textarea),
execCommand,
getSelection: vi.fn(() => null),
} as unknown as Document);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
expect(textarea.value).toBe("CODEX-1234");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(removeChild).toHaveBeenCalledWith(textarea);
});
it("uses selection copy directly in insecure browser contexts", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const appendChild = vi.fn();
const removeChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
const textarea = {
focus: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
setSelectionRange: vi.fn(),
style: {},
value: "",
} as unknown as HTMLTextAreaElement;
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal(
"window",
{ isSecureContext: false } as unknown as Window & typeof globalThis,
);
setGlobal("document", {
body: { appendChild, removeChild },
createElement: vi.fn(() => textarea),
execCommand,
getSelection: vi.fn(() => null),
} as unknown as Document);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).not.toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
});
it("returns false when no copy mechanism is available", async () => {
setGlobal("navigator", {} as Navigator);
setGlobal("document", undefined);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
export async function copyTextToClipboard(text: string): Promise<boolean> {
const clipboard =
typeof navigator === "undefined" ? undefined : navigator.clipboard;
const secureContext =
typeof window === "undefined" ? true : window.isSecureContext;
if (secureContext && clipboard?.writeText) {
try {
await clipboard.writeText(text);
return true;
} catch {
// Fall through to the selection-based copy path below.
}
}
if (typeof document === "undefined") {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
const selection = document.getSelection();
const ranges: Range[] = [];
if (selection) {
for (let i = 0; i < selection.rangeCount; i += 1) {
ranges.push(selection.getRangeAt(i));
}
}
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
if (selection) {
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}
}
return copied;
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import {
buildCronJobPayload,
cronJobHasExecutionContent,
cronJobFormFromJob,
cronLastResult,
splitCronList,
type CronJobFormState,
} from "./cron-job";
import type { CronJob } from "./api";
function form(overrides: Partial<CronJobFormState> = {}): CronJobFormState {
return {
name: "",
prompt: "prompt",
schedule: "every 1h",
deliver: "local",
skills: [],
provider: "",
model: "",
base_url: "",
script: "",
no_agent: false,
context_from: "",
continuity: false,
enabled_toolsets: [],
workdir: "",
...overrides,
};
}
describe("splitCronList", () => {
it("normalizes comma and newline separated cron list fields", () => {
expect(splitCronList(" web, terminal\nfile ,, ")).toEqual([
"web",
"terminal",
"file",
]);
});
});
describe("buildCronJobPayload", () => {
it("normalizes list fields and base URLs", () => {
const payload = buildCronJobPayload(
form({
base_url: "https://example.invalid/v1/",
enabled_toolsets: ["web", ""],
context_from: "upstream-a\nupstream-b",
}),
);
expect(payload).toMatchObject({
base_url: "https://example.invalid/v1",
context_from: ["upstream-a", "upstream-b"],
enabled_toolsets: ["web"],
});
});
it("stores continuity as the reserved self entry", () => {
const payload = buildCronJobPayload(
form({ continuity: true, context_from: "upstream-a" }),
);
expect(payload.context_from).toEqual(["upstream-a", "self"]);
});
it("continuity off strips any hand-typed self entry", () => {
const payload = buildCronJobPayload(
form({ continuity: false, context_from: "SELF\nupstream-a" }),
);
expect(payload.context_from).toEqual(["upstream-a"]);
});
it("keeps clear operations explicit for update payloads", () => {
const payload = buildCronJobPayload(form({ schedule: "every 2h" }));
expect(payload).toMatchObject({
schedule: "every 2h",
provider: null,
model: null,
base_url: null,
script: null,
no_agent: false,
context_from: null,
enabled_toolsets: null,
workdir: null,
});
});
});
describe("cronJobHasExecutionContent", () => {
it("treats a script as execution content for agent-backed cron jobs", () => {
const payload = buildCronJobPayload(
form({ prompt: "", skills: [], script: "collect-status.py" }),
);
expect(cronJobHasExecutionContent(payload)).toBe(true);
});
it("rejects payloads with no prompt, skills, or script", () => {
const payload = buildCronJobPayload(form({ prompt: "", skills: [], script: "" }));
expect(cronJobHasExecutionContent(payload)).toBe(false);
});
});
describe("cronJobFormFromJob", () => {
it("preserves schedule fallback and editable list fields", () => {
const job: CronJob = {
id: "abc",
enabled: true,
schedule_display: "every 1h",
context_from: ["upstream-a", "upstream-b"],
enabled_toolsets: ["web"],
};
expect(cronJobFormFromJob(job)).toMatchObject({
schedule: "every 1h",
context_from: "upstream-a\nupstream-b",
continuity: false,
enabled_toolsets: ["web"],
});
});
it("splits the stored self entry into the continuity toggle", () => {
const job: CronJob = {
id: "abc",
enabled: true,
schedule_display: "every 1h",
context_from: ["self", "upstream-a"],
};
expect(cronJobFormFromJob(job)).toMatchObject({
context_from: "upstream-a",
continuity: true,
});
});
it("prefers one-shot run_at over the human display string", () => {
const job: CronJob = {
id: "once-job",
enabled: true,
schedule: {
kind: "once",
run_at: "2026-02-03T14:00:00+08:00",
},
schedule_display: "once at 2026-02-03 14:00",
};
expect(cronJobFormFromJob(job)).toMatchObject({
schedule: "2026-02-03T14:00:00+08:00",
});
});
});
describe("cronLastResult", () => {
it("renders nothing for a job that never ran", () => {
expect(cronLastResult({ last_status: null })).toBeNull();
expect(cronLastResult({ last_status: "" })).toBeNull();
});
it("is green for ok with no detail", () => {
expect(cronLastResult({ last_status: "ok", last_error: null })).toEqual({
status: "ok",
tone: "success",
detail: null,
});
});
it("is amber for delivery_failed and explains it from last_delivery_error", () => {
// The agent run succeeded (last_error is null for these runs); the reason
// lives in last_delivery_error. Must never render as green or as "unknown".
expect(
cronLastResult({
last_status: "delivery_failed",
last_error: null,
last_delivery_error: "telegram: 502 Bad Gateway",
}),
).toEqual({
status: "delivery_failed",
tone: "warning",
detail: "telegram: 502 Bad Gateway",
});
});
it("is red for error and any unrecognised literal", () => {
expect(cronLastResult({ last_status: "error", last_error: "boom" })).toEqual({
status: "error",
tone: "destructive",
detail: "boom",
});
expect(cronLastResult({ last_status: "something_new" })?.tone).toBe("destructive");
});
it("is amber for blocked_config (preflight refused to burn a run)", () => {
expect(
cronLastResult({ last_status: "blocked_config", last_error: "missing API key" }),
).toEqual({ status: "blocked_config", tone: "warning", detail: "missing API key" });
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { CronJob, CronJobMutation } from "./api";
export interface CronJobFormState {
name: string;
prompt: string;
schedule: string;
deliver: string;
skills: string[];
provider: string;
model: string;
base_url: string;
script: string;
no_agent: boolean;
context_from: string;
continuity: boolean;
enabled_toolsets: string[];
workdir: string;
}
/** Split a comma/newline list (or array) into trimmed, non-empty items. */
export function splitCronList(value: unknown): string[] {
const items = Array.isArray(value)
? value
: typeof value === "string"
? value.split(/[\n,]/)
: [];
return items.map((item) => String(item).trim()).filter(Boolean);
}
/** Trim to a non-empty string, or null. Optionally strip trailing slashes
* (base URLs). Mirrors the backend's `_cron_optional_text`. */
function optionalText(value: string, stripTrailingSlash = false): string | null {
const text = stripTrailingSlash ? value.trim().replace(/\/+$/, "") : value.trim();
return text || null;
}
/** Read a stored string field as a plain string ("" when absent). */
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** Build the create/update payload. Optional fields collapse to null so an
* update explicitly clears them rather than leaving stale values. */
export function buildCronJobPayload(form: CronJobFormState): CronJobMutation {
// The `continuity` toggle is stored as the reserved "self" entry in
// context_from (the job's own previous output). Users never type "self" —
// the checkbox is the surface; strip any hand-typed variant first.
const contextFrom = splitCronList(form.context_from).filter(
(item) => item.toLowerCase() !== "self",
);
if (form.continuity) contextFrom.push("self");
const enabledToolsets = form.enabled_toolsets.filter(Boolean);
return {
name: form.name.trim(),
prompt: form.prompt.trim(),
schedule: form.schedule.trim(),
deliver: form.deliver.trim() || "local",
skills: form.skills.filter(Boolean),
provider: optionalText(form.provider),
model: optionalText(form.model),
base_url: optionalText(form.base_url, true),
script: optionalText(form.script),
no_agent: Boolean(form.no_agent),
context_from: contextFrom.length > 0 ? contextFrom : null,
enabled_toolsets: enabledToolsets.length > 0 ? enabledToolsets : null,
workdir: optionalText(form.workdir),
};
}
export function cronJobHasExecutionContent(
job: Pick<CronJobMutation, "prompt" | "skills" | "script">,
): boolean {
const skills = Array.isArray(job.skills) ? job.skills.filter(Boolean) : [];
return Boolean(asString(job.prompt).trim() || asString(job.script).trim() || skills.length);
}
export function cronJobFormFromJob(job: CronJob): CronJobFormState {
const storedRefs = splitCronList(job.context_from);
// Raw store records carry the reserved "self" entry inside context_from;
// tool/RPC-formatted records strip it and set an explicit continuity flag.
const continuity =
Boolean((job as { continuity?: boolean }).continuity) ||
storedRefs.some((item) => item.toLowerCase() === "self");
const externalRefs = storedRefs.filter((item) => item.toLowerCase() !== "self");
return {
name: asString(job.name),
prompt: asString(job.prompt),
schedule:
asString(job.schedule?.expr) ||
asString(job.schedule?.run_at) ||
asString(job.schedule_display),
deliver: asString(job.deliver) || "local",
skills: Array.isArray(job.skills) ? job.skills.filter(Boolean) : [],
provider: asString(job.provider),
model: asString(job.model),
base_url: asString(job.base_url),
script: asString(job.script),
no_agent: Boolean(job.no_agent),
context_from: externalRefs.join("\n"),
continuity,
enabled_toolsets: splitCronList(job.enabled_toolsets),
workdir: asString(job.workdir),
};
}
/** How a job's `last_status` should render. The scheduler writes a small,
* closed set of literals; every literal maps to an explicit tone here so a
* new status can never fall through to a neutral "unknown"-looking badge.
* In particular `delivery_failed` (agent run succeeded, output never reached
* the target) is amber, not green and not the same red as a run error, and
* its detail lives in `last_delivery_error` (last_error is null for it). */
export type CronLastResultTone = "success" | "warning" | "destructive";
export interface CronLastResult {
status: string;
tone: CronLastResultTone;
/** Human detail to show next to the badge; null when nothing to add. */
detail: string | null;
}
const CRON_LAST_RESULT_TONE: Record<string, CronLastResultTone> = {
ok: "success",
delivery_failed: "warning",
blocked_config: "warning",
error: "destructive",
};
export function cronLastResult(
job: Pick<CronJob, "last_status" | "last_error" | "last_delivery_error">,
): CronLastResult | null {
const status = asString(job.last_status).trim();
if (!status) return null;
const tone = CRON_LAST_RESULT_TONE[status] ?? "destructive";
if (status === "ok") return { status, tone, detail: null };
const detail =
status === "delivery_failed"
? asString(job.last_delivery_error).trim() || asString(job.last_error).trim()
: asString(job.last_error).trim() || asString(job.last_delivery_error).trim();
return { status, tone, detail: detail || null };
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { createCronTriggerController } from "@hermes/shared";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
describe("createCronTriggerController", () => {
it("announces immediately and coalesces the same job while it is running", async () => {
const request = deferred<string>();
const order: string[] = [];
const action = vi.fn(() => {
order.push("action");
return request.promise;
});
const onStarted = vi.fn(() => order.push("started"));
const onRunningChange = vi.fn();
const controller = createCronTriggerController(onRunningChange);
const first = controller.run("profile-a:job-1", action, onStarted);
const duplicate = await controller.run("profile-a:job-1", action, onStarted);
expect(action).toHaveBeenCalledTimes(1);
expect(onStarted).toHaveBeenCalledTimes(1);
expect(order).toEqual(["started", "action"]);
expect(onRunningChange).toHaveBeenNthCalledWith(1, "profile-a:job-1", true);
expect(duplicate).toEqual({ started: false, value: null });
request.resolve("done");
await expect(first).resolves.toEqual({ started: true, value: "done" });
expect(onRunningChange).toHaveBeenLastCalledWith("profile-a:job-1", false);
});
it("releases the job after failure so a retry can start", async () => {
const request = deferred<void>();
const controller = createCronTriggerController();
const failed = controller.run("job-1", () => request.promise);
request.reject(new Error("failed"));
await expect(failed).rejects.toThrow("failed");
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
started: true,
value: "retried",
});
});
it("releases the job when the immediate feedback callback fails", async () => {
const controller = createCronTriggerController();
await expect(
controller.run("job-1", async () => "not-called", () => {
throw new Error("toast failed");
}),
).rejects.toThrow("toast failed");
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
started: true,
value: "retried",
});
});
it("allows the same job id in different profiles to run concurrently", async () => {
const defaultRequest = deferred<string>();
const workRequest = deferred<string>();
const defaultAction = vi.fn(() => defaultRequest.promise);
const workAction = vi.fn(() => workRequest.promise);
const controller = createCronTriggerController();
const defaultRun = controller.run("default:job-1", defaultAction);
const workRun = controller.run("work:job-1", workAction);
expect(defaultAction).toHaveBeenCalledTimes(1);
expect(workAction).toHaveBeenCalledTimes(1);
defaultRequest.resolve("default");
workRequest.resolve("work");
await expect(defaultRun).resolves.toEqual({ started: true, value: "default" });
await expect(workRun).resolves.toEqual({ started: true, value: "work" });
});
it("releases the job when the running-state callback fails", async () => {
const controller = createCronTriggerController((_key, running) => {
if (running) throw new Error("state callback failed");
});
await expect(controller.run("job-1", async () => "not-called")).rejects.toThrow(
"state callback failed",
);
expect(controller.isRunning("job-1")).toBe(false);
});
it("releases the job before the stopped-state callback runs", async () => {
const action = vi.fn(async () => "done");
const controller = createCronTriggerController((_key, running) => {
if (!running) throw new Error("stopped callback failed");
});
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
expect(controller.isRunning("job-1")).toBe(false);
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
expect(action).toHaveBeenCalledTimes(2);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import {
attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt,
maybeReloadForLoopbackWsAuthFailure,
} from "./dashboard-auth-reload";
function makeStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.get(key) ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}
describe("attemptDashboardTokenReloadOnce", () => {
it("reloads once and latches the attempt", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});
it("clears the latch when asked", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
clearDashboardTokenReloadAttempt(storage);
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(2);
});
});
describe("maybeReloadForLoopbackWsAuthFailure", () => {
it("reloads once for loopback 4401 closes", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(
maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload),
).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
});
it("does not reload in gated mode or for other close codes", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(
maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload),
).toBe(false);
expect(
maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload),
).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
+69
View File
@@ -0,0 +1,69 @@
type StorageLike = Pick<Storage, "getItem" | "removeItem" | "setItem">;
const TOKEN_RELOAD_STORAGE_KEY = "hermes.tokenReloadAttempted";
function dashboardAuthRequired(): boolean {
return typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__;
}
function reloadDashboardWindow(): void {
if (typeof window !== "undefined") {
window.location.reload();
}
}
function dashboardSessionStorage(): StorageLike | null {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage;
} catch {
return null;
}
}
export function clearDashboardTokenReloadAttempt(
storage: StorageLike | null = dashboardSessionStorage(),
): void {
try {
storage?.removeItem(TOKEN_RELOAD_STORAGE_KEY);
} catch {
/* privacy mode / blocked storage — ignore */
}
}
export function attemptDashboardTokenReloadOnce(
storage: StorageLike | null = dashboardSessionStorage(),
reload: () => void = reloadDashboardWindow,
): boolean {
let alreadyReloaded = false;
try {
alreadyReloaded =
storage?.getItem(TOKEN_RELOAD_STORAGE_KEY) === "1";
} catch {
/* privacy mode / blocked storage — fall through */
}
if (alreadyReloaded) {
return false;
}
try {
storage?.setItem(TOKEN_RELOAD_STORAGE_KEY, "1");
} catch {
/* privacy mode / blocked storage — best effort */
}
reload();
return true;
}
export function maybeReloadForLoopbackWsAuthFailure(
code: number,
authRequired = dashboardAuthRequired(),
storage: StorageLike | null = dashboardSessionStorage(),
reload: () => void = reloadDashboardWindow,
): boolean {
if (authRequired || code !== 4401) {
return false;
}
return attemptDashboardTokenReloadOnce(storage, reload);
}
+24
View File
@@ -0,0 +1,24 @@
declare global {
interface Window {
/**
* Injected by the server as `true`. The embedded TUI Chat surface
* (`/chat`, `/api/ws`, `/api/pty`) is always enabled, so this is
* effectively a constant; kept on `window` for any consumer that reads
* it directly and for parity with the server's bootstrap script.
*/
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
}
}
/**
* Whether the dashboard's embedded TUI Chat surface is available.
*
* The embedded chat (`/chat` tab, `/api/ws` + `/api/pty` WebSockets) is now
* an unconditional part of the dashboard — the desktop app and the in-browser
* Chat tab both depend on it — so this always returns `true`. The function is
* retained as a stable seam so call sites don't need to change if the surface
* ever becomes conditional again.
*/
export function isDashboardEmbeddedChatEnabled(): boolean {
return true;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
DASHBOARD_MODAL_BACKDROP,
DASHBOARD_MODAL_PANEL,
shouldCloseOuterModalOnEscape,
} from "./dashboard-modal-shell";
describe("dashboard modal shell", () => {
it("uses an opaque panel (bg-card), not the glass Card default", () => {
expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/);
expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/);
});
it("keeps the backdrop above page chrome (z-[100])", () => {
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/);
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/);
});
it("does not close the outer modal on Escape while a nested picker is open", () => {
expect(shouldCloseOuterModalOnEscape(true)).toBe(false);
expect(shouldCloseOuterModalOnEscape(false)).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared dashboard dialog shell classes.
*
* Page `<Card>` defaults to `bg-background-base/80` (glass). That looks fine
* on the page canvas, but as a modal panel it lets the Models page bleed
* through and kills readability — especially on Cyberpunk / mobile.
*
* Modal panels must use opaque `bg-card`; backdrops use the same z-index
* band as Auxiliary / Confirm dialogs so they sit above page chrome.
*
* Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot
* escape the dashboard column's `relative z-2` stacking context (see
* ModelPickerDialog / ToolsetConfigDrawer).
*/
export const DASHBOARD_MODAL_BACKDROP =
"fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4";
export const DASHBOARD_MODAL_PANEL =
"relative w-full border border-border bg-card shadow-2xl";
/**
* Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog)
* must ignore Escape while the picker is open; the picker owns that key.
*/
export function shouldCloseOuterModalOnEscape(
nestedPickerOpen: boolean,
): boolean {
return !nestedPickerOpen;
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { EnvVarInfo } from "./api";
import { removeDeletedEnvVarFromState } from "./env-state";
function envVar(overrides: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
is_set: true,
redacted_value: "secr...alue",
description: "",
url: null,
category: "provider",
is_password: true,
tools: [],
advanced: false,
...overrides,
};
}
describe("removeDeletedEnvVarFromState", () => {
it("removes a deleted custom key from dashboard state", () => {
const vars = {
WHATSAPP_DEBUG: envVar({ category: "custom", custom: true }),
OPENAI_API_KEY: envVar(),
};
const updated = removeDeletedEnvVarFromState(vars, "WHATSAPP_DEBUG");
expect(updated).not.toHaveProperty("WHATSAPP_DEBUG");
expect(updated?.OPENAI_API_KEY).toBe(vars.OPENAI_API_KEY);
});
it("keeps a catalog key available while marking it unset", () => {
const vars = { OPENAI_API_KEY: envVar() };
const updated = removeDeletedEnvVarFromState(vars, "OPENAI_API_KEY");
expect(updated?.OPENAI_API_KEY).toEqual({
...vars.OPENAI_API_KEY,
is_set: false,
redacted_value: null,
});
});
});

Some files were not shown because too many files have changed in this diff Show More