diff --git a/CLAUDE.md b/CLAUDE.md index 04c0424..6ecda56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The application core is the `skald-core` crate; the binaries are **shells** arou | ---- | ---- | | `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no Tauri, no HTTP server, and **no concrete plugin crate** — `PluginManager` only ever sees `Arc` from `core-api` | | `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, the Tauri `desktop/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new` | -| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking whether to encrypt, default yes). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | +| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | | `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | Two rules keep the boundary real, and both are enforced by the compiler: @@ -113,7 +113,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven UI conventions live in the free-form `roles.attrs` JSON (e.g. `ui_mode`, see the frontend section) — never new columns per attribute. ## Filesystem & containers (blueprint §6) @@ -254,14 +254,22 @@ To add a Python dependency: add it to `requirements.txt`. It will be installed o All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/chat-session.js`) is the shared base for WS-connected chat UIs. +**The chat is the home page.** `` is a single persistent element with two layout modes driven by the route (`llm-page-change`): `mode="full"` on the home route (it fills the workspace — the conversation IS the landing page, with a welcome hero + prompt suggestions as its empty state) and `mode="dock"` on every other route (the classic resizable side panel). Same element ⇒ WS, tabs, scroll and drafts survive navigation; you watch files/projects update live while the conversation keeps going. Collapse only applies to the dock. The old dashboard content (hero, LLM stats charts, pending inbox, quick guide) lives on as the separate `#dashboard` page; the debug toggle moved to the Settings page. + +**Theme** (`web/css/variables.css`): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (`--radius-sm/md/lg`), 16px-base chat type, WCAG-fixed contrasts, global `:focus-visible` ring and `prefers-reduced-motion` support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet. + +**i18n** (`web/lib/i18n.js` + `web/i18n/{en,it}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1). + +**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. + | File | Element | Notes | | ---- | ------- | ----- | -| `copilot.js` | `` | Desktop copilot (`_wsSource='web'`); composer input with model pill, auto-resize textarea | +| `copilot.js` | `` | The chat surface (`_wsSource='web'`): full/dock roving layout, welcome hero empty state, privacy chip, composer with model pill, slash-command autocomplete | | `shared/chat-page.js` | `` | Mobile chat (`_wsSource='mobile'`) | | `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page | -| `sidebar.js` | `` | Nav sidebar; polls `/api/inbox` every 10 s for badge | -| `topbar.js` | `` | Top nav bar | -| `home-page.js` | `` | Landing / dashboard | +| `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); polls `/api/inbox` every 10 s for badge | +| `topbar.js` | `` | Top nav bar; per-user avatar color hashed from the username | +| `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide | | `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | | `file-viewer-page.js` | `` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` | | `shared/file-viewer-mobile.js` | `` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button | diff --git a/Cargo.lock b/Cargo.lock index 6e8512c..97af534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5644,6 +5644,7 @@ dependencies = [ "anyhow", "rpassword", "skald-core", + "sqlx", "tokio", ] diff --git a/README.md b/README.md index 0e5b86b..6ac0adc 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > ⚠️ **Active development** — expect breaking changes. Things move fast. -
Skáldkonur — the digital skald +
Skald Circle — app icon -**Skald** (also **Skáldkonur**) is a local AI assistant that lives on your machine — named after the Norse tradition of women skalds, the poet-warriors who wove history, memory, and wisdom into verse. It chats with you, helps you get things done, and — because it can rewrite and restart itself — grows with you. +**Skald Circle** is a family AI assistant — a warm, collaborative space where families and small groups work together. With a supervised chat system for children and vulnerable people. It's not a chatbot you talk to. It's a partner that nudges you, remembers what matters, and runs tasks on your behalf: reading your email, checking your calendar, sending WhatsApp messages, writing code, researching the web, generating images, and more. diff --git a/SKALD.md b/SKALD.md new file mode 100644 index 0000000..04c2e22 --- /dev/null +++ b/SKALD.md @@ -0,0 +1,31 @@ +# Skald Circle — SKALD + +## Stato attuale + +Progetto nuova applicazione con agenti e chatbot per aiutare famiglie e piccoli gruppi a collaborare, con chat supervisionato per bambini/persone vulnerabili. + +### Icone agenti — completate ✅ + +Tutti gli 11 agenti hanno ora icone in stile **Vector Paintings** (painterly vector, caldo e family-friendly), generate via ComfyUI: + +| Agente | Animale | Stato | +|--------|---------|-------| +| Main Assistant | 🦊 Volpe | ✅ | +| Project Coordinator | 🦡 Tasso | ✅ | +| Researcher | 🐿️ Scoiattolo | ✅ | +| Generalist | 🦫 Castoro | ✅ | +| Code Explorer | 🕵️ Meerkat | ✅ | +| Software Architect | 🏗️ Airone | ✅ | +| Software Engineer | 🔧 Orso | ✅ | +| Spec Writer | 📝 Gufo | ✅ | +| Tech Lead | 👑 Cervo | ✅ | +| TIC | 👁️ Gatto | ✅ | +| Business Analyst | 💼 Gazza | ✅ | + +- Business Analyst aveva `meta.json` senza campo `icon` — aggiunto. +- `agents/README.md` riscritto con nuova guida stile Vector Paintings. +- Stile: `VectorPaintDaal` trigger, palette calde (terracotta, ambra, oro, corallo, teal), animali come personaggi. + +### Prossimi passi + +- Sviluppare l'app Skald Circle vera e propria diff --git a/agents/README.md b/agents/README.md index 5c1464a..51e5e73 100644 --- a/agents/README.md +++ b/agents/README.md @@ -4,48 +4,41 @@ Each agent in the `agents/` directory can have an icon/avatar declared in the `" ## Visual style -Icons were generated with **xAI Grok Imagine** in a **concept art / character design** style: +Icons are generated with **Vector Paintings** LoRA via ComfyUI in a warm, family-friendly style: -- **Style**: illustrated, not photorealistic, not flat vector, not anime -- **Technique**: bold brushstrokes, rich colours, depth, video game concept art quality (Overwatch / Arcane / Hades) -- **Format**: portrait (vertical rectangle) -- **Background**: medium-bright, not dark, no neon -- **Subject**: a character / living being representing the agent's role, with contextual elements (tools, holograms, symbols) -- **Palette**: varies per agent, generally warm with one dominant colour +- **Style**: painterly vector — bold shapes fused with expressive brushstrokes +- **Technique**: vivid colours, emotion, motion, warm lighting +- **Format**: square (1024×1024), rendered as a character portrait +- **Background**: warm, cozy, medium-bright (no dark/no neon) +- **Subject**: a warm animal character representing the agent's role, with contextual elements (tools, symbols, objects) +- **Palette**: terracotta, amber, warm gold, coral, soft teal — warm and inviting +- **Trigger word**: `VectorPaintDaal` must be included at the start of the prompt -## Base prompt template +## Prompt template ``` -Stylized character portrait of an AI agent called "{NAME}". -Concept art style with bold brushstrokes and rich colors. -{character description and surrounding visual elements} -{dominant colours} -Illustrated character design, not photorealistic, not flat vector, not anime. -Video game concept art quality. -Portrait format, vertical. -High detail, expressive. +VectorPaintDaal. A warm friendly {ANIMAL} character with a gentle smile, wearing {CLOTHING/ACCESSORIES}. It holds {OBJECT} and around it float {SYMBOLS}. Warm golden light, cozy atmosphere. {DOMINANT_COLOURS} palette. Expressive bold brushstrokes, painterly vector style. Family-friendly illustration, portrait of a kind {ROLE}. ``` ## Per-agent reference -| Agent | Subject | Palette | -|-------|---------|---------| -| **Architect** | Visionary with floating architectural blueprints and geometry | Blue & teal | -| **Engineer** | Technician/cyborg with holographic tools, gears, circuits | Amber & steel blue | -| **Explorer** | Curious analyst with magnifying glass, floating code and data trails | Deep blue & gold | -| **Researcher** | Scientist with smart glasses, floating documents, magnifier | Purple & teal | -| **Main Assistant** | Central charismatic leader with luminous geometric shapes | Purple & gold | -| **TIC** | Mysterious figure with multiple eyes, radar, data nodes | Dark purple & cyan | -| **Tinker** | Clever craftsperson with multitool, gears, repair tools | Orange & steel grey | -| **Worker** | Practical person with futuristic toolbelt and mechanical elements | Orange & steel grey | -| **Blueprint** | Scholarly figure with floating scrolls and glowing quills writing words in mid-air, luminous documents orbiting | Deep indigo & burnished gold | -| **Tech Lead** | Confident strategist at a holographic kanban board, task cards floating mid-air, sub-agents visible in the background | Warm amber & deep teal | -| **Project Coordinator** | Central orchestrator with glowing connected nodes, satellite sub-agents orbiting, holographic project maps and branching task flows | Teal & warm gold | - +| Agent | Animal | Role | Elements | Palette | +|-------|--------|------|----------|---------| +| **Main Assistant** 🦊 | Fox | General assistant | Glowing threads connecting a heart, star, house | Terracotta, amber, gold | +| **Project Coordinator** 🦡 | Badger | Family coordinator | Floating threads linking heart, star, house, smiling face; cozy kitchen table | Terracotta, amber, gold, coral | +| **Researcher** 🐿️ | Squirrel | Curious researcher | Glowing book, magnifying glass, compass, scrolls, stars | Terracotta, amber, soft teal, coral | +| **Generalist** 🦫 | Beaver | Handy executor | Glowing multitool, wrench, paintbrush, trowel, cooking pot | Terracotta, orange, amber, timber | +| **Code Explorer** 🕵️ | Meerkat | Curious analyst | Magnifying glass, data trails, sparkling code symbols | Terracotta, amber, deep blue, gold | +| **Software Architect** 🏗️ | Heron | Thoughtful planner | Floating blueprints, geometric shapes, building blocks | Terracotta, soft teal, amber, pale gold | +| **Software Engineer** 🔧 | Bear | Focused builder | Glowing wrench, gears, circuit board, hammer, sparks | Terracotta, orange, amber, steel grey | +| **Spec Writer** 📝 | Owl | Wise scribe | Glowing quill, scrolls, open books, words floating mid-air | Deep indigo, burnished gold, amber, cream | +| **Tech Lead** 👑 | Stag | Confident strategist | Holographic kanban board, task cards, sub-agent symbols | Warm amber, deep teal, gold, coral | +| **TIC** 👁️ | Cat | Watchful guardian | Sensor nodes, radar arcs, notification symbols (bell, letter, calendar) | Dark purple, amber, soft cyan, warm grey | +| **Business Analyst** 💼 | Magpie | Thoughtful evaluator | Glowing clipboard, floating documents, abacus, data points | Deep indigo, gold, soft teal, amber | ## Adding a new agent icon -1. Generate the image using the prompt template above +1. Generate the image using the Vector Paintings prompt template above (include `VectorPaintDaal` at the start) 2. Save it as `agents/{agent_id}/icon.png` -3. Add `"icon": "icon.png"` to the agent's `meta.json` +3. Add `"icon": "icon.png"` to the agent's `meta.json` (if not already present) 4. No code changes needed — the backend serves whatever file path is declared in the manifest diff --git a/agents/business-analyst/icon.png b/agents/business-analyst/icon.png new file mode 100644 index 0000000..37b4eb2 Binary files /dev/null and b/agents/business-analyst/icon.png differ diff --git a/agents/business-analyst/meta.json b/agents/business-analyst/meta.json index e892cfc..06f3dea 100644 --- a/agents/business-analyst/meta.json +++ b/agents/business-analyst/meta.json @@ -5,5 +5,6 @@ "instructions": "Pass the idea, the draft business plan, and any market/competitor evidence you have. Specify an output path/dir for the critique report. The more evidence you provide, the sharper the critique — missing evidence is flagged as open questions, not guessed.", "type": "task", "scope": "reasoning", - "strength": "high" + "strength": "high", + "icon": "icon.png" } diff --git a/agents/code-explorer/icon.png b/agents/code-explorer/icon.png index ac99b38..5136804 100644 Binary files a/agents/code-explorer/icon.png and b/agents/code-explorer/icon.png differ diff --git a/agents/generalist/icon.png b/agents/generalist/icon.png index 6efa439..dab9573 100644 Binary files a/agents/generalist/icon.png and b/agents/generalist/icon.png differ diff --git a/agents/main/icon.png b/agents/main/icon.png index 6ec59b9..788d8a7 100644 Binary files a/agents/main/icon.png and b/agents/main/icon.png differ diff --git a/agents/project-coordinator/icon.png b/agents/project-coordinator/icon.png index f85bccd..746ff2b 100644 Binary files a/agents/project-coordinator/icon.png and b/agents/project-coordinator/icon.png differ diff --git a/agents/researcher/icon.png b/agents/researcher/icon.png index cbcec2f..66a2aa2 100644 Binary files a/agents/researcher/icon.png and b/agents/researcher/icon.png differ diff --git a/agents/software-architect/icon.png b/agents/software-architect/icon.png index e6319ca..d0a0416 100644 Binary files a/agents/software-architect/icon.png and b/agents/software-architect/icon.png differ diff --git a/agents/software-engineer/icon.png b/agents/software-engineer/icon.png index 87f8882..4e24e9b 100644 Binary files a/agents/software-engineer/icon.png and b/agents/software-engineer/icon.png differ diff --git a/agents/spec-writer/icon.png b/agents/spec-writer/icon.png index 90fe926..f4b962c 100644 Binary files a/agents/spec-writer/icon.png and b/agents/spec-writer/icon.png differ diff --git a/agents/tech-lead/icon.png b/agents/tech-lead/icon.png index 28fedc7..3c8b847 100644 Binary files a/agents/tech-lead/icon.png and b/agents/tech-lead/icon.png differ diff --git a/agents/tic/icon.png b/agents/tic/icon.png index c78b9b3..eb698db 100644 Binary files a/agents/tic/icon.png and b/agents/tic/icon.png differ diff --git a/assets/icons/icon-1024.png b/assets/icons/icon-1024.png new file mode 100644 index 0000000..79b8ed7 Binary files /dev/null and b/assets/icons/icon-1024.png differ diff --git a/assets/images/app-icon.png b/assets/images/app-icon.png new file mode 100644 index 0000000..79b8ed7 Binary files /dev/null and b/assets/images/app-icon.png differ diff --git a/assets/images/skaldkonur.png b/assets/images/skaldkonur.png index 29c9d7d..79b8ed7 100644 Binary files a/assets/images/skaldkonur.png and b/assets/images/skaldkonur.png differ diff --git a/crates/core-api/src/user_fs.rs b/crates/core-api/src/user_fs.rs index 15f6150..ffdbf76 100644 --- a/crates/core-api/src/user_fs.rs +++ b/crates/core-api/src/user_fs.rs @@ -18,6 +18,7 @@ //! fs-tools *before* reaching here; `UserFs` only ever sees physical paths. use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, RwLock}; /// One shared folder mounted into a user's container. #[derive(Debug, Clone)] @@ -128,6 +129,40 @@ fn strip_home_prefix(path: &str) -> &str { } } +/// A hot-swappable handle to a [`UserFs`] snapshot, shared by every holder that +/// must observe a membership change without being rebuilt (blueprint §6 remount). +/// +/// Cloning shares the *same* cell. `store` replaces the snapshot for all clones at +/// once; each `load` returns the current `Arc`. A live chat session's +/// handler holds a clone, so a shared-folder change reaches it on its next tool +/// call — no handler eviction, and no cross-session race (the swap is a single +/// pointer store behind the lock, and each `ToolContext` takes a consistent +/// snapshot for the duration of its call). +#[derive(Clone)] +pub struct SharedFs(Arc>>); + +impl SharedFs { + pub fn new(fs: UserFs) -> Self { + Self(Arc::new(RwLock::new(Arc::new(fs)))) + } + + /// The current snapshot. Cheap — clones an `Arc`. + pub fn load(&self) -> Arc { + Arc::clone(&self.0.read().expect("SharedFs lock poisoned")) + } + + /// Replace the snapshot seen by every holder of this cell. + pub fn store(&self, fs: UserFs) { + *self.0.write().expect("SharedFs lock poisoned") = Arc::new(fs); + } +} + +impl std::fmt::Debug for SharedFs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SharedFs").field(&*self.load()).finish() + } +} + /// Pure lexical normalization (resolve `.`/`..`), no filesystem access. fn normalize(p: &Path) -> PathBuf { let mut out = PathBuf::new(); diff --git a/crates/skald-core/src/container/mod.rs b/crates/skald-core/src/container/mod.rs index a060442..d7bdc25 100644 --- a/crates/skald-core/src/container/mod.rs +++ b/crates/skald-core/src/container/mod.rs @@ -18,6 +18,7 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use anyhow::{bail, Context, Result}; use sqlx::SqlitePool; @@ -39,6 +40,9 @@ pub const HOMES_DIR: &str = "homes"; pub const SHARED_DIR: &str = "shared"; /// Home mount point inside the container. pub const CONTAINER_HOME: &str = "/root"; +/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL) +/// before force-killing — enough for a shell or MCP `docker exec` child to exit. +const STOP_GRACE: Duration = Duration::from_secs(10); /// The deterministic container name for a user — derivable without any manager, /// so `UserFs` can carry it and `execute_cmd` can exec into it directly. @@ -203,6 +207,38 @@ impl ContainerManager { let _ = docker(&["rm", "-f", &name]).await; Ok(()) } + + /// Gracefully shuts down a user's container: `docker stop` sends SIGTERM to the + /// in-container processes and waits up to `STOP_GRACE` before SIGKILL, so an + /// in-flight `execute_cmd` shell (and any per-user MCP `docker exec` child) gets + /// a window to exit cleanly instead of vanishing mid-write. Best-effort: a + /// missing or already-stopped container is fine. + pub async fn stop(&self, user_id: &str) -> Result<()> { + let name = container_name(user_id); + let secs = STOP_GRACE.as_secs().to_string(); + if let Err(e) = docker(&["stop", "-t", &secs, &name]).await { + tracing::debug!(container = %name, error = %e, "container stop (ignored)"); + } + Ok(()) + } + + /// Cleanly recreates a user's container so it picks up a changed mount topology + /// — e.g. a shared-folder membership change (§6), whose mounts are fixed at + /// `docker create` time and cannot be altered on a live container. Graceful + /// [`stop`](Self::stop) → remove → [`ensure`](Self::ensure) (which rebuilds the + /// mount set from the current memberships and recreates the host dirs). The + /// container holds no durable state — everything lives in the bind mounts — so a + /// recreate is safe by construction. A no-op-safe `rm` (the container is already + /// stopped) precedes `ensure`, which then finds it absent and creates it fresh. + /// + /// Caveat (caller's concern, not this method's): the per-user MCP runtime and a + /// logged-in user's `UserFs` snapshot are both bound to the old container/ + /// membership and are NOT refreshed here — see the shared-folders remount wiring. + pub async fn recreate(&self, user_id: &str) -> Result<()> { + self.stop(user_id).await?; + let _ = docker(&["rm", &container_name(user_id)]).await; + self.ensure(user_id).await + } } // ── docker CLI helpers ──────────────────────────────────────────────────────── diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 7e699a6..e35c1d2 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -400,6 +400,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { database_password BLOB, password_hash BLOB, active INTEGER NOT NULL DEFAULT 1, + locale TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), CHECK ( @@ -410,6 +411,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { ) .execute(pool) .await?; + // Per-user UI locale override is additive — reaches an existing DB in place. + ensure_column(pool, "users", "locale", "TEXT").await?; // Shared on-disk folders (blueprint §6/§0.1): a named directory // `{WD}/shared/{folder_name}` bind-mounted into the container of each member. @@ -422,11 +425,16 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { "CREATE TABLE IF NOT EXISTS shared_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, folder_name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) .await?; + // The folder's description is injected into the agent's system context so it + // knows what each shared folder holds and when to read/write it. Additive — + // reaches an existing DB in place (a no-op on the fresh CREATE above). + ensure_column(pool, "shared_folders", "description", "TEXT NOT NULL DEFAULT ''").await?; sqlx::query( "CREATE TABLE IF NOT EXISTS shared_folder_members ( diff --git a/crates/skald-core/src/db/role_capabilities.rs b/crates/skald-core/src/db/role_capabilities.rs index 4cbc9f2..7633591 100644 --- a/crates/skald-core/src/db/role_capabilities.rs +++ b/crates/skald-core/src/db/role_capabilities.rs @@ -25,6 +25,12 @@ pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script"; /// Curate the connector catalog (admin only). pub const MANAGE_CATALOG: &str = "mcp.manage_catalog"; +/// Manage shared on-disk folders — create/describe/delete and grant membership +/// (blueprint §6). Admin-only for now; not in [`DEFAULT_USER_CAPABILITIES`], so +/// `admin` holds it implicitly (via [`has`]) and opening it to another role later +/// is a single [`grant`], no code change. +pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage"; + /// The default capabilities of an ordinary (non-admin) user role. pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG]; diff --git a/crates/skald-core/src/db/shared_folders.rs b/crates/skald-core/src/db/shared_folders.rs index 0b1bdbe..71c41af 100644 --- a/crates/skald-core/src/db/shared_folders.rs +++ b/crates/skald-core/src/db/shared_folders.rs @@ -15,6 +15,9 @@ use sqlx::SqlitePool; pub struct SharedFolder { pub id: i64, pub folder_name: String, + /// What the folder holds — injected into the agent's system context so it + /// knows what to store here and when to read it. Admin-authored (§6). + pub description: String, pub created_at: String, } @@ -59,25 +62,50 @@ pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result Result> { - let rows = sqlx::query_as::<_, (i64, String, String)>( - "SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name", + let rows = sqlx::query_as::<_, (i64, String, String, String)>( + "SELECT id, folder_name, description, created_at FROM shared_folders ORDER BY folder_name", ) .fetch_all(pool) .await?; Ok(rows .into_iter() - .map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at }) + .map(|(id, folder_name, description, created_at)| SharedFolder { + id, + folder_name, + description, + created_at, + }) .collect()) } +pub async fn get(pool: &SqlitePool, folder_id: i64) -> Result> { + let row = sqlx::query_as::<_, (i64, String, String, String)>( + "SELECT id, folder_name, description, created_at FROM shared_folders WHERE id = ?", + ) + .bind(folder_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder { + id, + folder_name, + description, + created_at, + })) +} + pub async fn get_by_name(pool: &SqlitePool, folder_name: &str) -> Result> { - let row = sqlx::query_as::<_, (i64, String, String)>( - "SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?", + let row = sqlx::query_as::<_, (i64, String, String, String)>( + "SELECT id, folder_name, description, created_at FROM shared_folders WHERE folder_name = ?", ) .bind(folder_name) .fetch_optional(pool) .await?; - Ok(row.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at })) + Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder { + id, + folder_name, + description, + created_at, + })) } /// The members of a folder — the set of users whose containers mount it. @@ -98,15 +126,27 @@ pub async fn members(pool: &SqlitePool, folder_id: i64) -> Result Result { - let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)") +pub async fn create(pool: &SqlitePool, folder_name: &str, description: &str) -> Result { + let id = sqlx::query("INSERT INTO shared_folders (folder_name, description) VALUES (?, ?)") .bind(folder_name) + .bind(description) .execute(pool) .await? .last_insert_rowid(); Ok(id) } +/// Updates a folder's description — the agent-facing text. No-op if `folder_id` +/// no longer exists. +pub async fn set_description(pool: &SqlitePool, folder_id: i64, description: &str) -> Result<()> { + sqlx::query("UPDATE shared_folders SET description = ? WHERE id = ?") + .bind(description) + .bind(folder_id) + .execute(pool) + .await?; + Ok(()) +} + /// Adds (or updates the capability of) a member. Idempotent on the PK. pub async fn add_member( pool: &SqlitePool, diff --git a/crates/skald-core/src/db/users.rs b/crates/skald-core/src/db/users.rs index 544713e..2d95ea3 100644 --- a/crates/skald-core/src/db/users.rs +++ b/crates/skald-core/src/db/users.rs @@ -65,6 +65,8 @@ pub struct User { pub role_id: String, pub credentials: Credentials, pub active: bool, + /// UI locale override (NULL = follow the instance default). + pub locale: Option, pub created_at: String, pub updated_at: String, } @@ -78,6 +80,7 @@ pub struct UserSummary { pub role_id: String, pub encrypted: bool, pub active: bool, + pub locale: Option, pub created_at: String, pub updated_at: String, } @@ -95,6 +98,7 @@ impl User { role_id: self.role_id.clone(), encrypted: self.is_encrypted(), active: self.active, + locale: self.locale.clone(), created_at: self.created_at.clone(), updated_at: self.updated_at.clone(), } @@ -140,6 +144,7 @@ struct Row { database_password: Option>, password_hash: Option>, active: bool, + locale: Option, created_at: String, updated_at: String, } @@ -150,7 +155,7 @@ macro_rules! select { ($tail:literal) => { concat!( "SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ", - "database_password, password_hash, active, created_at, updated_at FROM users ", + "database_password, password_hash, active, locale, created_at, updated_at FROM users ", $tail ) }; @@ -185,6 +190,7 @@ impl TryFrom for User { role_id: r.role_id, credentials, active: r.active, + locale: r.locale, created_at: r.created_at, updated_at: r.updated_at, }) @@ -370,6 +376,22 @@ pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: O Ok(()) } +/// Sets (or clears, with `None`) the user's UI locale override. +pub async fn set_locale(pool: &SqlitePool, id: &str, locale: Option<&str>) -> Result<()> { + let n = sqlx::query( + "UPDATE users SET locale = ?2, updated_at = datetime('now') WHERE id = ?1", + ) + .bind(id) + .bind(locale) + .execute(pool) + .await? + .rows_affected(); + if n == 0 { + bail!("no such user: {id}"); + } + Ok(()) +} + /// Removes the directory row only. The caller still owns `database/{id}.db`: /// erasing a user means deleting that file too. pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> { @@ -556,6 +578,7 @@ mod tests { role_id: "admin".into(), credentials: encrypted(), active: true, + locale: None, created_at: "now".into(), updated_at: "now".into(), }; diff --git a/crates/skald-core/src/i18n.rs b/crates/skald-core/src/i18n.rs new file mode 100644 index 0000000..8920338 --- /dev/null +++ b/crates/skald-core/src/i18n.rs @@ -0,0 +1,55 @@ +//! UI localization knobs. +//! +//! The instance default locale lives in the registry `config` table under +//! [`DEFAULT_LOCALE_KEY`], editable by the admin from the Settings page. Each +//! user can override it on their own profile (`users.locale`); the frontend +//! resolves user → instance → built-in English at boot. + +use core_api::{ConfigProperty, ConfigSet, PropertyType}; + +pub const DEFAULT_LOCALE_KEY: &str = "ui_locale"; + +/// Locales the web UI ships dictionaries for. Anything else is rejected at +/// write time (profile override, first-run setup) rather than silently +/// falling back to English later. +pub const SUPPORTED_LOCALES: &[&str] = &["en", "it", "fr"]; + +pub fn is_supported(locale: &str) -> bool { + SUPPORTED_LOCALES.contains(&locale) +} + +/// Writes the instance default locale straight to the registry `config` table. +/// Used by first-run provisioning shells (e.g. `skald-setup`), where no +/// `GlobalConfigManager` — hence no system bus — exists. A running server +/// should go through `GlobalConfigManager::set` instead, which also emits the +/// change event. +pub async fn set_default_locale(pool: &sqlx::SqlitePool, locale: &str) -> anyhow::Result<()> { + anyhow::ensure!(is_supported(locale), "unsupported locale: {locale}"); + sqlx::query( + "INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + ) + .bind(DEFAULT_LOCALE_KEY) + .bind(locale) + .execute(pool) + .await?; + Ok(()) +} + +pub fn config_set() -> ConfigSet { + ConfigSet { + name: "Interface".into(), + description: "Look and feel of the web interface.".into(), + properties: vec![ + ConfigProperty { + key: DEFAULT_LOCALE_KEY.into(), + name: "Language".into(), + description: "Default interface language for the whole instance (e.g. en, it). Each user can override it on their profile.".into(), + property_type: PropertyType::String, + default_value: Some("en".into()), + }, + ], + } +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index 9704829..cff95e2 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod cron; pub mod db; pub mod events; pub mod image_generate; +pub mod i18n; pub mod inbox; pub mod latex; pub mod llm; diff --git a/crates/skald-core/src/mcp/mod.rs b/crates/skald-core/src/mcp/mod.rs index e69f9fc..7cd3d7f 100644 --- a/crates/skald-core/src/mcp/mod.rs +++ b/crates/skald-core/src/mcp/mod.rs @@ -268,6 +268,17 @@ impl McpManager { self.descriptions.write().unwrap().remove(name); } + /// Stops **every** running server (each dropped client → `kill_on_drop` kills + /// its child process) and forgets them. Used when a per-user container is + /// recreated (§6 remount): the old `docker exec -i` children are bound to the + /// now-gone container, so they must be torn down before reconnecting against + /// the fresh one via [`connect_all`](Self::connect_all). + pub fn stop_all(&self) { + self.servers.write().unwrap().clear(); + self.errors.write().unwrap().clear(); + self.descriptions.write().unwrap().clear(); + } + pub fn tools(&self) -> Vec { self.servers.read().unwrap().values() .flat_map(|s| s.tools().iter().cloned()) diff --git a/crates/skald-core/src/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs index e7432dc..8779f2f 100644 --- a/crates/skald-core/src/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -431,7 +431,9 @@ impl ChatSessionHandler { let ctx = ToolContext { session_id: self.session_id, pool: Arc::clone(&self.db), - fs: Arc::clone(&self.fs), + // Snapshot the fs cell for the duration of this tool call — a concurrent + // shared-folder remount swaps the cell, the next call picks it up (§6). + fs: self.fs.load(), }; self.tools.run(name, &ctx, args) } diff --git a/crates/skald-core/src/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs index 7881ba5..3a28a28 100644 --- a/crates/skald-core/src/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -20,7 +20,7 @@ use crate::config::DatetimeConfig; use crate::db::{chat_history, chat_sessions_stack}; use crate::events::ServerEvent; use core_api::message_meta::MessageMetadata; -use core_api::user_fs::UserFs; +use core_api::user_fs::SharedFs; use crate::llm::LlmManager; use crate::mcp::McpProvider; use crate::image_generate::ImageGeneratorManager; @@ -272,8 +272,11 @@ pub struct ChatSessionHandler { pub(super) user_id: String, /// The owner's filesystem view (home + shared folders + container), threaded /// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and - /// `execute_cmd` execs into the owner's container (blueprint §6). - pub(super) fs: Arc, + /// `execute_cmd` execs into the owner's container (blueprint §6). A **shared + /// swappable cell** (not a snapshot): a shared-folder membership change is + /// applied in place (§6 remount), so a live session picks it up on its next + /// tool call without being rebuilt — see [`SharedFs`]. + pub(super) fs: SharedFs, pub(super) llm_manager: Arc, pub(super) max_history_messages: usize, pub(super) max_tool_rounds: usize, @@ -343,7 +346,7 @@ impl ChatSessionHandler { db: Arc, shared_pool: Arc, user_id: String, - fs: Arc, + fs: SharedFs, llm_manager: Arc, max_history_messages: usize, max_tool_rounds: usize, diff --git a/crates/skald-core/src/session/manager.rs b/crates/skald-core/src/session/manager.rs index b96861c..44304be 100644 --- a/crates/skald-core/src/session/manager.rs +++ b/crates/skald-core/src/session/manager.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use core_api::user_fs::UserFs; +use core_api::user_fs::{SharedFs, UserFs}; use sqlx::SqlitePool; use tokio::sync::Mutex; @@ -29,8 +29,9 @@ pub struct ChatSessionManager { shared_pool: Arc, user_id: String, /// The owner's filesystem view, threaded to each handler and on into every - /// `ToolContext` (blueprint §6). - user_fs: Arc, + /// `ToolContext` (blueprint §6). A shared swappable cell so a shared-folder + /// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions. + user_fs: SharedFs, llm_manager: Arc, max_history_messages: usize, max_tool_rounds: usize, @@ -60,7 +61,7 @@ impl ChatSessionManager { db: Arc, shared_pool: Arc, user_id: String, - user_fs: Arc, + user_fs: SharedFs, llm_manager: Arc, max_history_messages: usize, max_tool_rounds: usize, @@ -171,7 +172,7 @@ impl ChatSessionManager { self.db.clone(), self.shared_pool.clone(), self.user_id.clone(), - Arc::clone(&self.user_fs), + self.user_fs.clone(), Arc::clone(&self.llm_manager), self.max_history_messages, self.max_tool_rounds, @@ -197,4 +198,12 @@ impl ChatSessionManager { self.active.lock().await.insert(session_id, handler.clone()); Ok(handler) } + + /// Swaps in a refreshed filesystem view for this owner (blueprint §6 remount). + /// Every live session's handler shares the same [`SharedFs`] cell, so the new + /// membership reaches each on its next tool call — no handler eviction, no + /// cross-session race. + pub fn refresh_fs(&self, fs: UserFs) { + self.user_fs.store(fs); + } } diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 2b3c10b..b6db46c 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -64,6 +64,55 @@ impl Skald { } fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts } + + /// The user's runtime context IF it is already live (built), **without** + /// building one — used to refresh a logged-in user in place. A user who never + /// logged in has no snapshot to refresh; their next login builds a fresh one. + pub async fn user_context_if_live(&self, user_id: &str) -> Option> { + self.rt_user_contexts().peek(user_id).await + } + + /// Applies a shared-folder membership change to a user (blueprint §6 remount). + /// + /// A container's bind mounts are fixed at `docker create` time, so the mount set + /// changes only by recreating the container — done here with a graceful stop + /// first ([`ContainerManager::recreate`](crate::container::ContainerManager::recreate)). + /// If the user is **live**, the two snapshot-bound pieces are then refreshed in + /// place against the fresh container: their filesystem view (which governs both + /// the host-side fs-tools and `execute_cmd` path routing) and their per-user MCP + /// runtime (whose `docker exec` children died with the old container). A user + /// with no live context needs only the recreate — their next login builds a + /// context that already reflects the change. + /// + /// Best-effort by contract: the membership row is already committed, so a Docker + /// hiccup must not fail the caller; the state settles at the next login/boot. + pub async fn refresh_user_shared_folders(&self, user_id: &str) -> anyhow::Result<()> { + // New mount topology (graceful stop → remove → recreate from current rows). + self.container().recreate(user_id).await?; + + let Some(ctx) = self.user_context_if_live(user_id).await else { + return Ok(()); // not logged in — next login builds a fresh context + }; + + // fs view: swap the shared cell so every live session picks it up next call. + let new_fs = crate::container::build_user_fs(self.db(), user_id).await?; + ctx.sessions.refresh_fs(new_fs); + + // per-user MCP: the old container's `docker exec` children are gone. Stop the + // stale handles, then reconnect the activated connectors against the fresh + // container (same deterministic name). + ctx.user_mcp.stop_all(); + let rows = crate::db::mcp_user_servers::all_startable(&ctx.pool).await.unwrap_or_default(); + if !rows.is_empty() { + let container = crate::container::container_name(user_id); + let mut specs = Vec::with_capacity(rows.len()); + for r in &rows { + specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await); + } + ctx.user_mcp.connect_all(specs, false).await; + } + Ok(()) + } pub fn sessions(&self) -> &Arc { &self.rt.sessions } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index d615443..379142b 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -346,7 +346,7 @@ impl Conversation { // The ownerless manager is inert (no loops, no consumers — see §19): it takes // a placeholder UserFs purely to satisfy the type, never used to resolve a path. - let ownerless_fs = Arc::new(core_api::user_fs::UserFs::new( + let ownerless_fs = core_api::user_fs::SharedFs::new(core_api::user_fs::UserFs::new( String::new(), std::path::PathBuf::from("homes"), "skald-ownerless", diff --git a/crates/skald-core/src/skald/runtime.rs b/crates/skald-core/src/skald/runtime.rs index 033b708..6619856 100644 --- a/crates/skald-core/src/skald/runtime.rs +++ b/crates/skald-core/src/skald/runtime.rs @@ -63,7 +63,7 @@ impl Runtime { users, sessions, config, - config_properties: vec![crate::tic::config_set()], + config_properties: vec![crate::i18n::config_set(), crate::tic::config_set()], system_bus, event_bus, global_tx, diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 9476ad5..f568c1e 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -35,7 +35,7 @@ use core_api::events::GlobalEvent; use core_api::inbox::InboxApi; use core_api::system_bus::SystemEventBus; use core_api::user_channel::UserChannelHandle; -use core_api::user_fs::UserFs; +use core_api::user_fs::SharedFs; use crate::approval::ApprovalManager; use crate::chat_event_bus::ChatEventBus; @@ -66,8 +66,10 @@ pub struct UserContext { pub user_id: String, pub pool: Arc, /// The owner's filesystem view (home + shared folders + container, §6), - /// threaded into every `ToolContext` this user's sessions produce. - pub fs: Arc, + /// threaded into every `ToolContext` this user's sessions produce. A shared + /// swappable cell so a shared-folder membership change is applied in place + /// (§6 remount) rather than requiring a fresh login — see [`SharedFs`]. + pub fs: SharedFs, pub event_bus: Arc, pub sessions: Arc, pub chat_hub: Arc, @@ -151,8 +153,9 @@ impl UserContextFactory { async fn build(&self, user_id: &str, pool: SqlitePool) -> Result> { let pool = Arc::new(pool); // The owner's filesystem view: private home + shared folders + container. - // Snapshotted at login; a membership change takes effect on next login (v1). - let fs = Arc::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?); + // A shared swappable cell — a shared-folder membership change is applied in + // place while the user is live (§6 remount), not deferred to next login. + let fs = SharedFs::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?); let event_bus = Arc::new(ChatEventBus::new()); let (global_tx, _) = broadcast::channel::(512); @@ -238,7 +241,7 @@ impl UserContextFactory { Arc::clone(&pool), Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection user_id.to_string(), - Arc::clone(&fs), + fs.clone(), Arc::clone(&self.llm_manager), self.max_history_messages, self.max_tool_rounds, @@ -340,6 +343,13 @@ impl UserContextRegistry { guard.insert(user_id.to_string(), Arc::clone(&ctx)); Ok(ctx) } + + /// The user's context IF already built (live), **without** building one. A user + /// who has not logged in has no live snapshot to refresh (blueprint §6 remount): + /// their next login builds a fresh context that already reflects the change. + pub(super) async fn peek(&self, user_id: &str) -> Option> { + self.contexts.lock().await.get(user_id).cloned() + } } // ── UserChannelHandle impl ──────────────────────────────────────────────────── diff --git a/crates/skald-setup/Cargo.toml b/crates/skald-setup/Cargo.toml index 0bc7f43..9038038 100644 --- a/crates/skald-setup/Cargo.toml +++ b/crates/skald-setup/Cargo.toml @@ -17,5 +17,6 @@ path = "src/main.rs" skald-core = { path = "../skald-core" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } anyhow = "1" +sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } # Reads a password without echoing it to the terminal. rpassword = "7" diff --git a/crates/skald-setup/src/main.rs b/crates/skald-setup/src/main.rs index 76e095a..6c0bb2c 100644 --- a/crates/skald-setup/src/main.rs +++ b/crates/skald-setup/src/main.rs @@ -94,10 +94,12 @@ fn usage() -> String { async fn run(mode: Mode) -> Result { // Opening the pool creates `database/system.db` and its schema if absent — // the same call the server makes, so setup and server agree on the layout. - let pool = db::init_system_pool(SYSTEM_DB_PATH) - .await - .context("opening the system database")?; - let users = UserManager::new(std::sync::Arc::new(pool)); + let pool = std::sync::Arc::new( + db::init_system_pool(SYSTEM_DB_PATH) + .await + .context("opening the system database")?, + ); + let users = UserManager::new(std::sync::Arc::clone(&pool)); let has_admin = users.count().await.context("counting users")? > 0; @@ -113,13 +115,13 @@ async fn run(mode: Mode) -> Result { // Each is idempotent: it decides for itself whether there is work to do. // Today there is one. Provider and model setup will be added here as further // steps, in order, each skipping itself when already configured. - step_first_user(&users, has_admin).await?; + step_first_user(&users, &pool, has_admin).await?; Ok(std::process::ExitCode::SUCCESS) } /// Create the first admin, or do nothing if one already exists. -async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> { +async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin: bool) -> Result<()> { if has_admin { // Idempotent re-run, or a second binary got there first. return Ok(()); @@ -142,6 +144,7 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> { let display_name = display_name.trim(); let display_name = (!display_name.is_empty()).then_some(display_name); + let locale = prompt_locale()?; let encrypt = prompt_encrypt()?; let password = prompt_new_password()?; @@ -150,6 +153,12 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> { .await .context("creating the admin user")?; + // The first-run language choice is instance-wide: the registry config + // default every user follows until they override it on their profile. + skald_core::i18n::set_default_locale(pool, &locale) + .await + .context("saving the default language")?; + println!("\n✓ Admin user '{username}' created (id {id})."); if encrypt { println!(" Their private database is encrypted. There is no recovery if the password is lost."); @@ -177,6 +186,34 @@ fn prompt_username() -> Result { } } +/// Interface language, stored as the instance default (`ui_locale`). A menu +/// rather than free text so a typo can never land in the config table. +fn prompt_locale() -> Result { + println!("Interface language / Lingua dell'interfaccia:"); + for (i, l) in skald_core::i18n::SUPPORTED_LOCALES.iter().enumerate() { + let label = match *l { + "en" => "English", + "it" => "Italiano", + other => other, + }; + println!(" {}) {}", i + 1, label); + } + loop { + let ans = prompt_line("Language [1]: ")?; + let ans = ans.trim(); + if ans.is_empty() { + return Ok(skald_core::i18n::SUPPORTED_LOCALES[0].to_string()); + } + match ans.parse::() { + Ok(n) if n >= 1 && n <= skald_core::i18n::SUPPORTED_LOCALES.len() => { + return Ok(skald_core::i18n::SUPPORTED_LOCALES[n - 1].to_string()); + } + _ if skald_core::i18n::is_supported(ans) => return Ok(ans.to_string()), + _ => println!(" Pick a number from the list."), + } + } +} + /// Default yes, with the honest caveat shown before the choice. For the admin — /// who owns the box — encryption guards against a stolen machine, not against /// the other users (§2/§4); and it has no recovery. The prompt says so. diff --git a/icons/128x128.png b/icons/128x128.png index a5b887f..8355b13 100644 Binary files a/icons/128x128.png and b/icons/128x128.png differ diff --git a/icons/128x128@2x.png b/icons/128x128@2x.png index 7175837..1a5b817 100644 Binary files a/icons/128x128@2x.png and b/icons/128x128@2x.png differ diff --git a/icons/32x32.png b/icons/32x32.png index 62795e5..a7ce1e8 100644 Binary files a/icons/32x32.png and b/icons/32x32.png differ diff --git a/icons/icon.png b/icons/icon.png index 760987d..79b8ed7 100644 Binary files a/icons/icon.png and b/icons/icon.png differ diff --git a/icons/tray-template.png b/icons/tray-template.png index 76dd02c..5aa0ff1 100644 Binary files a/icons/tray-template.png and b/icons/tray-template.png differ diff --git a/src/frontend/api/auth.rs b/src/frontend/api/auth.rs index 160bf53..d0d9f2a 100644 --- a/src/frontend/api/auth.rs +++ b/src/frontend/api/auth.rs @@ -58,9 +58,19 @@ pub async fn login( #[derive(Serialize)] pub struct MeResponse { - pub username: String, - pub display_name: Option, - pub role_id: String, + pub username: String, + pub display_name: Option, + pub role_id: String, + /// Interface mode resolved from the role's `attrs.ui_mode` — "full" unless + /// the role opts into the simplified UI. Never hardcoded per-role: it is + /// data on the role row (§0.1), and `admin` is always "full". + pub ui_mode: String, + /// The user's own locale override (NULL = follow the instance default). + pub locale: Option, + /// Whether the user's database is encrypted (drives the profile UI). + pub encrypted: bool, + /// Instance default locale (registry config `ui_locale`). + pub default_locale: String, } /// Returns the authenticated user's profile, or 401 if no valid session. @@ -84,14 +94,44 @@ pub async fn me( .await? .ok_or_else(|| ApiError::not_found("user not found"))?; + let ui_mode = resolve_ui_mode(&skald, &user.role_id).await; + let default_locale = skald + .config() + .get(skald_core::i18n::DEFAULT_LOCALE_KEY) + .await? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "en".into()); + Ok(Json(MeResponse { username: user.username, display_name: user.display_name, role_id: user.role_id, + ui_mode, + locale: user.locale, + encrypted: user.encrypted, + default_locale, }) .into_response()) } +/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key +/// resolves to "full" — the simplified UI is strictly opt-in. +async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String { + if role_id == skald_core::db::roles::ADMIN_ROLE_ID { + return "full".into(); + } + let attrs = skald_core::db::roles::get(skald.db(), role_id) + .await + .ok() + .flatten() + .and_then(|r| r.attrs); + attrs + .and_then(|a| serde_json::from_str::(&a).ok()) + .and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned)) + .filter(|m| m == "simple" || m == "full") + .unwrap_or_else(|| "full".into()) +} + // ── POST /api/auth/logout ──────────────────────────────────────────────────── pub async fn logout( @@ -127,11 +167,16 @@ fn extract_session_token(headers: &HeaderMap) -> Option { None } -// ── PUT /api/auth/profile — update display name ────────────────────────────── +// ── PUT /api/auth/profile — update display name / locale ───────────────────── +// Tri-state fields: absent = don't touch, `null` = clear, value = set. Serde +// maps them onto `Option>` with `#[serde(default)]`. #[derive(Deserialize)] pub struct UpdateProfileBody { - pub display_name: Option, + #[serde(default)] + pub display_name: Option>, + #[serde(default)] + pub locale: Option>, } pub async fn update_profile( @@ -145,13 +190,27 @@ pub async fn update_profile( .await? .ok_or_else(|| ApiError::not_found("user not found"))?; - skald_core::db::users::rename( - skald.db(), - &auth.user_id, - &user.username, - body.display_name.as_deref(), - ) - .await?; + if let Some(display_name) = body.display_name { + skald_core::db::users::rename( + skald.db(), + &auth.user_id, + &user.username, + display_name.as_deref().filter(|s| !s.trim().is_empty()), + ) + .await?; + } + + if let Some(locale) = body.locale { + match locale.as_deref().map(str::trim) { + None | Some("") => { + skald_core::db::users::set_locale(skald.db(), &auth.user_id, None).await?; + } + Some(l) if skald_core::i18n::is_supported(l) => { + skald_core::db::users::set_locale(skald.db(), &auth.user_id, Some(l)).await?; + } + Some(_) => return Err(ApiError::bad_request("unsupported locale")), + } + } Ok(Json(serde_json::json!({ "ok": true }))) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 485beb0..1a6a521 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -22,6 +22,7 @@ pub mod roles; pub mod run_context; pub mod sessions; pub mod setup; +pub mod shared_folders; pub mod transcribe_audio; pub mod transcribe_models; pub mod tts_models; @@ -176,6 +177,12 @@ pub fn router() -> Router> { .route("/users", get(users_mgmt::list).post(users_mgmt::create)) .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) .route("/users/{id}/password", post(users_mgmt::reset_password)) + + // Shared on-disk folders (blueprint §6) — admin-curated, capability-gated. + .route("/shared-folders", get(shared_folders::list).post(shared_folders::create)) + .route("/shared-folders/{id}", patch(shared_folders::update_description).delete(shared_folders::delete)) + .route("/shared-folders/{id}/members", post(shared_folders::add_member)) + .route("/shared-folders/{id}/members/{user_id}", delete(shared_folders::remove_member)) // Images (generated by image_generate tool) .route("/images/{task_id}", get(images::get_image)) // MCP tool-result media (images/audio/files returned by MCP servers) diff --git a/src/frontend/api/setup.rs b/src/frontend/api/setup.rs index 22279b5..0f6a0bb 100644 --- a/src/frontend/api/setup.rs +++ b/src/frontend/api/setup.rs @@ -30,6 +30,9 @@ pub struct CreateUserBody { pub password: String, #[serde(default)] pub encrypted: bool, + /// Chosen interface language — becomes the instance default (`ui_locale`). + #[serde(default)] + pub locale: Option, } #[derive(Serialize)] @@ -54,11 +57,23 @@ pub async fn create_user( if body.password.is_empty() { return Err(ApiError::bad_request("password must not be empty")); } + let locale = body.locale.as_deref().map(str::trim).filter(|s| !s.is_empty()); + if let Some(l) = locale { + if !skald_core::i18n::is_supported(l) { + return Err(ApiError::bad_request("unsupported locale")); + } + } let id = skald .users() .register_user(username, None, "admin", Some(&body.password), body.encrypted) .await?; + // The first-run language choice is instance-wide: it lands in the registry + // config as the default every user follows until they override it. + if let Some(l) = locale { + skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?; + } + Ok(Json(CreateUserResult { user_id: id })) } diff --git a/src/frontend/api/shared_folders.rs b/src/frontend/api/shared_folders.rs new file mode 100644 index 0000000..9a47812 --- /dev/null +++ b/src/frontend/api/shared_folders.rs @@ -0,0 +1,233 @@ +//! Shared on-disk folders management API (blueprint §6/§0.1). +//! +//! Admin-curated shared directories. The admin creates a folder, describes what it +//! holds (the description is injected into the agent's system context so it knows +//! what to store there and when to read it), and grants members read-only or +//! read-write access. Capability-gated on `MANAGE_SHARED_FOLDERS` — admin-only for +//! now, but a single `grant` opens it to any role (§0.1). Never agent-driven. +//! +//! The folder rows + membership live in the registry (`system.db`); the physical +//! directory `{WD}/shared/{name}` is created here so the bind-mount has a source +//! and the admin can drop files in immediately. Propagating a membership change +//! into a *running* container (recreate) and into a logged-in user's fs view + +//! system prompt is the follow-on step — see blueprint §6. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use skald_core::db::{role_capabilities, shared_folders, users}; +use skald_core::skald::Skald; + +use super::guard::AuthUser; +use super::ApiError; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// Fails with 403 unless the caller's role may manage shared folders. `admin` +/// holds every capability by construction (`role_capabilities::has`). +async fn require_manage(skald: &Skald, user_id: &str) -> Result<(), ApiError> { + let user = users::get(skald.db(), user_id) + .await? + .ok_or_else(|| ApiError::unauthorized("unknown user"))?; + if role_capabilities::has(skald.db(), &user.role_id, role_capabilities::MANAGE_SHARED_FOLDERS) + .await? + { + Ok(()) + } else { + Err(ApiError::forbidden("your role cannot manage shared folders")) + } +} + +/// Creates `{WD}/shared/{name}` — the bind-mount source. `name` is already +/// validated as a single safe component, so it cannot escape the shared root. +fn create_shared_dir(name: &str) -> Result<(), ApiError> { + let dir = std::env::current_dir()? + .join(skald_core::container::SHARED_DIR) + .join(name); + std::fs::create_dir_all(&dir) + .map_err(|e| ApiError::bad_request(format!("failed to create folder directory: {e}")))?; + Ok(()) +} + +/// Applies a membership change to a user's live environment — recreate their +/// container with the new mounts and, if they are logged in, refresh their fs view +/// + per-user MCP in place (blueprint §6 remount). Best-effort: the membership row +/// is already committed, so a Docker hiccup is logged, not surfaced — it settles at +/// the user's next login/boot. +async fn remount(skald: &Skald, user_id: &str) { + if let Err(e) = skald.refresh_user_shared_folders(user_id).await { + tracing::warn!(user = %user_id, error = %e, + "shared-folder remount failed (settles at next login/boot)"); + } +} + +// ── response / request types ────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct MemberView { + pub user_id: String, + pub can_write: bool, +} + +/// A folder plus its membership. Member identities are just ids — the frontend +/// joins them against `/api/users`, which it already loads for the member picker. +#[derive(Serialize)] +pub struct FolderView { + pub id: i64, + pub folder_name: String, + pub description: String, + pub created_at: String, + pub members: Vec, +} + +async fn folder_view(skald: &Skald, f: shared_folders::SharedFolder) -> Result { + let members = shared_folders::members(skald.db(), f.id) + .await? + .into_iter() + .map(|m| MemberView { user_id: m.user_id, can_write: m.can_write }) + .collect(); + Ok(FolderView { + id: f.id, + folder_name: f.folder_name, + description: f.description, + created_at: f.created_at, + members, + }) +} + +// ── GET /api/shared-folders ─────────────────────────────────────────────────── + +pub async fn list( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + require_manage(&skald, &auth.user_id).await?; + let folders = shared_folders::list_all(skald.db()).await?; + let mut out = Vec::with_capacity(folders.len()); + for f in folders { + out.push(folder_view(&skald, f).await?); + } + Ok(Json(out)) +} + +// ── POST /api/shared-folders ────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct CreateBody { + pub folder_name: String, + #[serde(default)] + pub description: String, +} + +pub async fn create( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + require_manage(&skald, &auth.user_id).await?; + let name = body.folder_name.trim(); + if !shared_folders::is_valid_folder_name(name) { + return Err(ApiError::bad_request( + "folder name must be a single path component (no '/', '\\', '.' or '..')", + )); + } + if shared_folders::get_by_name(skald.db(), name).await?.is_some() { + return Err(ApiError::bad_request(format!("a folder named '{name}' already exists"))); + } + + let id = shared_folders::create(skald.db(), name, body.description.trim()).await?; + // The bind-mount needs a real directory to point at; make it now. + create_shared_dir(name)?; + + let folder = shared_folders::get(skald.db(), id) + .await? + .ok_or_else(|| ApiError::not_found("folder vanished after creation"))?; + Ok(Json(folder_view(&skald, folder).await?)) +} + +// ── PATCH /api/shared-folders/{id} — description only (no rename) ────────────── + +#[derive(Deserialize)] +pub struct DescriptionBody { + pub description: String, +} + +pub async fn update_description( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + require_manage(&skald, &auth.user_id).await?; + if shared_folders::get(skald.db(), id).await?.is_none() { + return Err(ApiError::not_found("no such folder")); + } + shared_folders::set_description(skald.db(), id, body.description.trim()).await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ── DELETE /api/shared-folders/{id} ─────────────────────────────────────────── + +pub async fn delete( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, ApiError> { + require_manage(&skald, &auth.user_id).await?; + // Capture the members before the cascade delete so each can be unmounted after. + let members = shared_folders::members(skald.db(), id).await.unwrap_or_default(); + shared_folders::delete(skald.db(), id).await?; + // The on-disk directory is deliberately left in place: unsharing a folder must + // not destroy the files inside it. The admin removes them by hand if intended. + for m in &members { + remount(&skald, &m.user_id).await; + } + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ── POST /api/shared-folders/{id}/members ── add or re-grant (RO/RW) ─────────── + +#[derive(Deserialize)] +pub struct MemberBody { + pub user_id: String, + #[serde(default)] + pub can_write: bool, +} + +pub async fn add_member( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + require_manage(&skald, &auth.user_id).await?; + if shared_folders::get(skald.db(), id).await?.is_none() { + return Err(ApiError::not_found("no such folder")); + } + // Catch an unknown user id here for a clean 400 — the membership FK would + // otherwise surface it as an opaque 500. + if users::get(skald.db(), &body.user_id).await?.is_none() { + return Err(ApiError::bad_request("no such user")); + } + shared_folders::add_member(skald.db(), id, &body.user_id, body.can_write).await?; + // Mount the folder into (or re-grant RO/RW inside) the member's environment. + remount(&skald, &body.user_id).await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ── DELETE /api/shared-folders/{id}/members/{user_id} ───────────────────────── + +pub async fn remove_member( + State(skald): State>, + Extension(auth): Extension, + Path((id, user_id)): Path<(i64, String)>, +) -> Result, ApiError> { + require_manage(&skald, &auth.user_id).await?; + shared_folders::remove_member(skald.db(), id, &user_id).await?; + // Unmount the folder from the (former) member's environment. + remount(&skald, &user_id).await; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/web/app.js b/web/app.js index ff8483e..14507ca 100644 --- a/web/app.js +++ b/web/app.js @@ -11,6 +11,7 @@ import { TasksPage } from './components/tasks/index.js'; import { AgentsPage } from './components/agents.js'; import { UsersPage } from './components/users-page.js'; import { RolesPage } from './components/roles-page.js'; +import { SharedFoldersPage } from './components/shared-folders.js'; import { ConnectorsPage } from './components/connectors.js'; import { ConnectorDetailPage } from './components/connector-detail.js'; import { MarketplacePage } from './components/marketplace.js'; @@ -18,9 +19,9 @@ import { CatalogPage } from './components/catalog.js'; import { ProfilePage } from './components/profile-page.js'; import { ApprovalGroupsPage } from './components/approval-groups.js'; import { ApprovalRulesPage } from './components/approval-rules.js'; -import { ConfigPage } from './components/config-page.js'; -import { AgentInboxPage } from './components/agent-inbox.js'; -import { HomePage } from './components/home-page.js'; +import { ConfigPage } from './components/config-page.js'; +import { DashboardPage } from './components/dashboard-page.js'; +import { AgentInboxPage } from './components/agent-inbox.js'; import { LlmRequestsPage } from './components/llm-requests.js'; import { LlmRequestDetail } from './components/llm-request-detail.js'; import { SessionDetailPage } from './components/session-detail.js'; @@ -32,6 +33,7 @@ import { LoginPage } from './components/login-page.js'; // Register the global `openFile(path)` helper (window.openFile → location.hash). import './lib/open-file.js'; +import { initI18n } from './lib/i18n.js'; customElements.define('app-topbar', AppTopbar); customElements.define('app-sidebar', AppSidebar); @@ -46,6 +48,7 @@ customElements.define('tasks-page', TasksPage); customElements.define('agents-page', AgentsPage); customElements.define('users-page', UsersPage); customElements.define('roles-page', RolesPage); +customElements.define('shared-folders-page', SharedFoldersPage); customElements.define('connectors-page', ConnectorsPage); customElements.define('connector-detail-page', ConnectorDetailPage); customElements.define('marketplace-page', MarketplacePage); @@ -53,9 +56,9 @@ customElements.define('catalog-page', CatalogPage); customElements.define('profile-page', ProfilePage); customElements.define('approval-groups-page', ApprovalGroupsPage); customElements.define('approval-rules-page', ApprovalRulesPage); -customElements.define('config-page', ConfigPage); -customElements.define('agent-inbox-page', AgentInboxPage); -customElements.define('home-page', HomePage); +customElements.define('config-page', ConfigPage); +customElements.define('dashboard-page', DashboardPage); +customElements.define('agent-inbox-page', AgentInboxPage); customElements.define('llm-requests-page', LlmRequestsPage); customElements.define('llm-request-detail', LlmRequestDetail); customElements.define('session-detail-page', SessionDetailPage); @@ -98,5 +101,7 @@ window.addEventListener('llm-page-change', (e) => { if (login) login.style.display = ''; return; } + // Logged in: resolve the effective locale (user pref → instance default). + initI18n(); } catch { /* show app by default */ } })(); diff --git a/web/assets/icons/favicon.ico b/web/assets/icons/favicon.ico index 29da6d0..8c963c2 100644 Binary files a/web/assets/icons/favicon.ico and b/web/assets/icons/favicon.ico differ diff --git a/web/assets/icons/icon-1024.png b/web/assets/icons/icon-1024.png index 2e7f569..79b8ed7 100644 Binary files a/web/assets/icons/icon-1024.png and b/web/assets/icons/icon-1024.png differ diff --git a/web/components/agent-inbox.js b/web/components/agent-inbox.js index 8ed4779..62aba20 100644 --- a/web/components/agent-inbox.js +++ b/web/components/agent-inbox.js @@ -1,8 +1,9 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { InboxMixin } from '../lib/inbox-mixin.js'; +import { t, I18nMixin } from '../lib/i18n.js'; -export class AgentInboxPage extends InboxMixin(LightElement) { +export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) { static get properties() { return { @@ -58,7 +59,7 @@ export class AgentInboxPage extends InboxMixin(LightElement) {
- Agent Inbox + ${t('nav.inbox')} ${total > 0 ? html`${total}` : nothing}
${this._strengthDot(m.strength)} ${m.name} - ${m.is_default ? html`default` : ''} + ${m.is_default ? html`${t('agents.detail.default')}` : ''} ${m.model_id} @@ -178,17 +188,16 @@ export class AgentsPage extends LightElement { } _renderDetail() { - if (this._loading && !this._detail) return html`
Loading…
`; + if (this._loading && !this._detail) return html`
${t('agents.loading')}
`; if (!this._detail) return ''; const { meta, prompt, models } = this._detail; return html`
-
${meta.icon ? html` @@ -204,28 +213,27 @@ export class AgentsPage extends LightElement { ${this._error ? html`
${this._error}
` : ''}
-
-

Metadata

+

${t('agents.detail.meta')}

- + ${meta.strength ? html` - + ` : ''} ${meta.scope ? html` - + ` : ''} ${meta.client ? html` - + ` : ''} ${meta.inject_memory?.length ? html` - + ` : ''} @@ -233,25 +241,23 @@ export class AgentsPage extends LightElement {
ID${meta.id}
${t('agents.detail.id')}${meta.id}
Strength
${t('agents.detail.strength')} ${this._strengthDot(meta.strength)} - ${STRENGTH_LABELS[meta.strength] ?? meta.strength} + ${this._strengthLabel(meta.strength)}
Scope${this._scopePill(meta.scope)}
${t('agents.detail.scope')}${this._scopePill(meta.scope)}
Pinned model${meta.client}
${t('agents.detail.pinned_model')}${meta.client}
Memory files
${t('agents.detail.memory_files')} ${meta.inject_memory.map(f => html`
${f}
`)}
-
-

Model resolution order

+

${t('agents.detail.model_order')}

- Models sorted by how well they match this agent's requirements. - The system uses the first available model from the top. + ${t('agents.detail.model_order_desc')}

${models.length === 0 - ? html`

No models configured.

` + ? html`

${t('agents.detail.no_models')}

` : html`
- - - - - + + + + + @@ -263,9 +269,8 @@ export class AgentsPage extends LightElement { } -
-

System prompt

+

${t('agents.detail.prompt')}

${unsafeHTML(renderMarkdown(prompt))}
@@ -284,17 +289,14 @@ export class AgentsPage extends LightElement { ? this._renderDetail() : html`
-

Agents

+

${t('agents.title')}

-

Read-only view. Agents are defined by files in agents/ - — to add, remove, or modify an agent, edit the corresponding AGENT.md file in that - directory.

-

You can also ask Copilot (top bar) to create a new agent for you - — just describe what it should do and it will set up all the files automatically.

+

${unsafeHTML(t('agents.banner.title'))}

+

${unsafeHTML(t('agents.banner.text'))}

diff --git a/web/components/approval-groups.js b/web/components/approval-groups.js index f0c9289..6439105 100644 --- a/web/components/approval-groups.js +++ b/web/components/approval-groups.js @@ -1,5 +1,7 @@ import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; export class ApprovalGroupsPage extends LightElement { static properties = { @@ -31,6 +33,8 @@ export class ApprovalGroupsPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', async (e) => { this._open = e.detail.page === 'approval'; this.style.display = this._open ? 'flex' : 'none'; @@ -58,6 +62,11 @@ export class ApprovalGroupsPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async _load() { this._error = null; try { @@ -65,8 +74,8 @@ export class ApprovalGroupsPage extends LightElement { fetch('/api/tool-permission-groups'), fetch('/api/approval/rules'), ]); - if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); - if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`); + if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); + if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); const groups = await gRes.json(); this._groups = groups.sort((a, b) => { if (a.id === 'default') return -1; @@ -114,8 +123,8 @@ export class ApprovalGroupsPage extends LightElement { async _saveGroup() { const isNew = this._groupEditId === 'new'; - if (!this._groupForm.name.trim()) { this._error = 'Group name is required.'; return; } - if (isNew && !this._groupForm.id.trim()) { this._error = 'Group ID is required.'; return; } + if (!this._groupForm.name.trim()) { this._error = t('security.error.group_name_required'); return; } + if (isNew && !this._groupForm.id.trim()) { this._error = t('security.error.group_id_required'); return; } this._groupSaving = true; this._error = null; try { @@ -141,8 +150,8 @@ export class ApprovalGroupsPage extends LightElement { async _deleteGroup(group) { const count = this._rulesForGroup(group.id).length; const msg = count > 0 - ? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?` - : `Delete group "${group.name}"?`; + ? t('security.confirm.delete_with_rules', { name: group.name, n: count, s: count === 1 ? '' : 's' }) + : t('security.confirm.delete', { name: group.name }); if (!confirm(msg)) return; try { const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' }); @@ -159,7 +168,7 @@ export class ApprovalGroupsPage extends LightElement { this._duplicateOf = group; this._dupForm = { id: `${group.id}_copy`, - name: `Copy of ${group.name}`, + name: `${t('security.duplicate')} ${group.name}`, }; this._groupEditId = null; // close any open create/rename form } @@ -167,8 +176,8 @@ export class ApprovalGroupsPage extends LightElement { _cancelDuplicate() { this._duplicateOf = null; } async _saveDuplicate() { - if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; } - if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; } + if (!this._dupForm.name.trim()) { this._error = t('security.error.name_required'); return; } + if (!this._dupForm.id.trim()) { this._error = t('security.error.id_required'); return; } this._dupSaving = true; this._error = null; try { @@ -196,7 +205,7 @@ export class ApprovalGroupsPage extends LightElement {
- ${isNew ? 'New group' : 'Rename group'} + ${isNew ? t('security.new_group') : t('security.rename_group')} @@ -205,41 +214,41 @@ export class ApprovalGroupsPage extends LightElement {
${isNew ? html`
- + this._patchGroup('id', e.target.value)} /> -
Lowercase slug, no spaces. Cannot be changed later.
+
${t('security.form.id_hint')}
` : nothing}
- + this._patchGroup('name', e.target.value)} />
- + this._patchGroup('description', e.target.value)} />
- +
@@ -256,7 +265,7 @@ export class ApprovalGroupsPage extends LightElement {
- Duplicate ${src.name} + ${t('security.duplicate_title', { name: src.name })} @@ -264,7 +273,7 @@ export class ApprovalGroupsPage extends LightElement {
- +
- + { this._dupForm = { ...this._dupForm, id: e.target.value }; }} /> -
Lowercase slug, no spaces. Cannot be changed later.
+
${t('security.form.id_hint')}
- All ${this._rulesForGroup(src.id).length} rule${this._rulesForGroup(src.id).length === 1 ? '' : 's'} from ${src.name} will be copied. + ${unsafeHTML(t('security.form.copy_info', { n: this._rulesForGroup(src.id).length, s: this._rulesForGroup(src.id).length === 1 ? '' : 's', name: src.name }))}
- +
@@ -308,24 +317,24 @@ export class ApprovalGroupsPage extends LightElement { return html`
this._navigateTo(group)}>
- ${isDefault ? html`Default` : nothing} + ${isDefault ? html`${t('security.card.default_badge')}` : nothing} ${group.name} - + ${count}
e.stopPropagation()}> - -
@@ -362,15 +371,8 @@ export class ApprovalGroupsPage extends LightElement {
-

- Permission groups are named sets of approval rules. - A session's active Agent Profile determines which group applies — - that group's rules are evaluated first, with the Default group as fallback. -

-

- Click a group to view and manage its rules. - The Default group cannot be deleted, but its rules can be edited freely. -

+

${unsafeHTML(t('security.banner.text1'))}

+

${unsafeHTML(t('security.banner.text2'))}

@@ -385,9 +387,9 @@ export class ApprovalGroupsPage extends LightElement { ${this._groups.length === 0 ? html`
-

No groups yet.

+

${t('security.empty.title')}

` : this._groups.map(g => this._renderGroupCard(g))} diff --git a/web/components/approval-rules.js b/web/components/approval-rules.js index 5c7ba40..1f2e1cc 100644 --- a/web/components/approval-rules.js +++ b/web/components/approval-rules.js @@ -1,40 +1,29 @@ import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; const DEFAULT_PRIORITY = 999999; const ACTIONS = ['require', 'allow', 'deny']; const ACTION_STYLE = { - require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' }, - allow: { icon: 'bi-check-circle', label: 'Allow', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' }, - deny: { icon: 'bi-slash-circle', label: 'Deny', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' }, + require: { icon: 'bi-person-check', bg: 'rgba(234,179,8,0.12)', color: '#a16207' }, + allow: { icon: 'bi-check-circle', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' }, + deny: { icon: 'bi-slash-circle', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' }, }; -const CATEGORY_LABELS = { - filesystem: 'File System', - shell: 'Shell', - subagent: 'Agents', - introspection: 'Introspection', - config: 'Config', - // Tools injected dynamically outside the ToolRegistry (interface/plugin/ - // provider tools), surfaced via runtime discovery — see docs/approval. - dynamic: 'Dynamic', -}; - -const CATEGORY_ORDER = [ - 'File System', 'Shell', 'Agents', 'Introspection', 'Config', 'Dynamic', -]; +const CATEGORY_ORDER = ['filesystem', 'shell', 'subagent', 'introspection', 'config', 'dynamic']; // File System permission model. Each path row maps to exactly one approval rule via a // synthetic `@fs_*` tool_pattern token (understood by the backend matcher). A single // selector collapses the (access-class × action) axes into the mental model from the // mockup: Allow read / Allow write / Deny / Require. const FS_ACCESS = { - allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' }, - allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' }, - deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' }, - require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' }, + allow_read: { tool_pattern: '@fs_read', action: 'allow' }, + allow_write: { tool_pattern: '@fs_any', action: 'allow' }, + deny: { tool_pattern: '@fs_any', action: 'deny' }, + require: { tool_pattern: '@fs_any', action: 'require' }, }; // Priority band for the settable "Default" row (below specific fs path rules, above the // global `*` catch-all at 999999). @@ -91,6 +80,8 @@ export class ApprovalRulesPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { if (e.detail.page !== 'approval') { this._open = false; @@ -118,6 +109,11 @@ export class ApprovalRulesPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async _load() { this._error = null; try { @@ -125,8 +121,8 @@ export class ApprovalRulesPage extends LightElement { fetch('/api/approval/rules'), fetch('/api/approval/tools'), ]); - if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`); - if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`); + if (!rulesRes.ok) throw new Error(`HTTP ${rulesRes.status}`); + if (!toolsRes.ok) throw new Error(`HTTP ${toolsRes.status}`); this._rules = await rulesRes.json(); this._tools = await toolsRes.json(); } catch (e) { @@ -331,7 +327,7 @@ export class ApprovalRulesPage extends LightElement { async _addFsRule() { const clean = this._normalizeFsPath(this._fsNewPath); - if (!clean) { this._error = 'Enter a directory path.'; return; } + if (!clean) { this._error = t('approval.error.enter_path'); return; } const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read; this._fsSaving = new Set([...this._fsSaving, 'new']); this._error = null; @@ -386,7 +382,7 @@ export class ApprovalRulesPage extends LightElement { } async _deleteFsRule(rule) { - if (!confirm(`Remove File System rule for "${this._fsDisplayPath(rule)}"?`)) return; + if (!confirm(t('approval.confirm.delete_fs', { path: this._fsDisplayPath(rule) }))) return; this._fsSaving = new Set([...this._fsSaving, rule.id]); this._error = null; try { @@ -443,15 +439,25 @@ export class ApprovalRulesPage extends LightElement { // ── Tool grouping ───────────────────────────────────────────────────────────── + _catLabel(key) { + return { + filesystem: t('approval.category.filesystem'), + shell: t('approval.category.shell'), + subagent: t('approval.category.subagent'), + introspection: t('approval.category.introspection'), + config: t('approval.category.config'), + dynamic: t('approval.category.dynamic'), + }[key] ?? key; + } + _groupedTools() { if (!this._tools) return []; const map = new Map(); - const metaMap = new Map(); // category key → { description } + const metaMap = new Map(); for (const t of this._tools.built_in) { - // Filesystem tools are gated by path in the File System panel, not per-tool here. if (t.category === 'filesystem') continue; - const cat = t.category ? (CATEGORY_LABELS[t.category] ?? t.category) : 'Other'; + const cat = t.category || 'other'; if (!map.has(cat)) map.set(cat, []); map.get(cat).push(t); } @@ -459,7 +465,7 @@ export class ApprovalRulesPage extends LightElement { for (const t of this._tools.mcp) { const serverId = t.server ?? t.name; const meta = servers[serverId] ?? {}; - const key = `MCP · ${meta.friendly_name ?? serverId}`; + const key = `mcp:${serverId}`; if (!map.has(key)) { map.set(key, []); if (meta.description) metaMap.set(key, meta.description); @@ -472,9 +478,9 @@ export class ApprovalRulesPage extends LightElement { if (map.has(cat)) result.push([cat, map.get(cat), null]); } for (const [key, tools] of map.entries()) { - if (!CATEGORY_ORDER.includes(key) && key !== 'Other') result.push([key, tools, metaMap.get(key) ?? null]); + if (!CATEGORY_ORDER.includes(key) && key !== 'other') result.push([key, tools, metaMap.get(key) ?? null]); } - if (map.has('Other')) result.push(['Other', map.get('Other'), null]); + if (map.has('other')) result.push(['other', map.get('other'), null]); return result; } @@ -513,14 +519,14 @@ export class ApprovalRulesPage extends LightElement { _selectTool(name) { this._form = { ...this._form, tool_pattern: name }; } async _save() { - if (!this._form.tool_pattern.trim()) { this._error = 'Tool pattern is required.'; return; } + if (!this._form.tool_pattern.trim()) { this._error = t('approval.error.tool_required'); return; } const p = Number(this._form.priority); if (this._formMode === 'override' && p >= 0) { - this._error = 'Override rules must have priority < 0.'; return; + this._error = t('approval.error.override_prio'); return; } if (this._formMode === 'lowprio' && (p <= 0 || p >= DEFAULT_PRIORITY)) { - this._error = `Low priority rules must have priority between 1 and ${DEFAULT_PRIORITY - 1}.`; return; + this._error = t('approval.error.lowprio_range', { max: DEFAULT_PRIORITY - 1 }); return; } this._saving = true; @@ -555,7 +561,7 @@ export class ApprovalRulesPage extends LightElement { } async _delete(rule) { - if (!confirm(`Delete rule for "${rule.tool_pattern}"?`)) return; + if (!confirm(t('approval.confirm.delete_rule', { pattern: rule.tool_pattern }))) return; try { const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); @@ -582,8 +588,8 @@ export class ApprovalRulesPage extends LightElement { const current = this._form.tool_pattern; const allTools = [ - { name: '*', description: 'Any tool', source: 'glob', server: null }, - { name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null }, + { name: '*', description: t('approval.tool.any'), source: 'glob', server: null }, + { name: 'mcp__*', description: t('approval.tool.any_mcp'), source: 'glob', server: null }, ...this._tools.built_in, ...this._tools.mcp, ]; @@ -596,17 +602,21 @@ export class ApprovalRulesPage extends LightElement { ); const groups = {}; - for (const t of filtered) { - const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob'; + for (const tool of filtered) { + const key = tool.source === 'mcp' + ? t('approval.tool.group_mcp', { server: tool.server }) + : tool.source === 'built-in' + ? t('approval.tool.group_builtin') + : t('approval.tool.group_glob'); if (!groups[key]) groups[key] = []; - groups[key].push(t); + groups[key].push(tool); } return html`
{ this._toolFilter = e.target.value; }} /> @@ -624,7 +634,7 @@ export class ApprovalRulesPage extends LightElement { `)} `)} - ${filtered.length === 0 ? html`
No results
` : nothing} + ${filtered.length === 0 ? html`
${t('approval.tool.no_results')}
` : nothing}
`; @@ -640,8 +650,8 @@ export class ApprovalRulesPage extends LightElement {
${this._editingId === 'new' - ? (isOverride ? 'New override rule' : 'New low priority rule') - : 'Edit rule'} + ? (isOverride ? t('approval.form.new_override') : t('approval.form.new_lowprio')) + : t('approval.form.edit')} @@ -649,31 +659,31 @@ export class ApprovalRulesPage extends LightElement {
- + this._patch('tool_pattern', e.target.value)} /> -
Use * as a trailing wildcard, e.g. mcp__whatsapp__*
+
${unsafeHTML(t('approval.form.tool_pattern_hint'))}
- + ${this._renderToolPicker()}
- + this._patch('path_pattern', e.target.value)} /> -
Filter by file path. Use * as a wildcard.
+
${unsafeHTML(t('approval.form.path_pattern_hint'))}
- +
${isOverride - ? html`Must be < 0 (e.g. −10)` - : html`Must be 1 – ${DEFAULT_PRIORITY - 1}`} + ? unsafeHTML(t('approval.form.priority_override_hint')) + : unsafeHTML(t('approval.form.priority_lowprio_hint', { max: DEFAULT_PRIORITY - 1 }))}
- +
- + this._patch('agent_id', e.target.value)} />
- + this._patch('note', e.target.value)} />
- +
@@ -750,18 +760,18 @@ export class ApprovalRulesPage extends LightElement {
- ${s.label} + ${{ require: t('approval.action.require'), allow: t('approval.action.allow'), deny: t('approval.action.deny') }[rule.action] ?? rule.action} ${rule.tool_pattern} - + ${rule.priority}
- -
@@ -784,10 +794,10 @@ export class ApprovalRulesPage extends LightElement { _renderChipGroup(currentAction, onChange) { const chips = [ - { action: null, label: '—' }, - { action: 'allow', label: 'Allow' }, - { action: 'require', label: 'Req' }, - { action: 'deny', label: 'Deny' }, + { action: null, label: t('approval.chip.unset') }, + { action: 'allow', label: t('approval.action.allow') }, + { action: 'require', label: t('approval.chip.req') }, + { action: 'deny', label: t('approval.action.deny') }, ]; return html`
@@ -827,11 +837,14 @@ export class ApprovalRulesPage extends LightElement { const open = this._openSections.has(key); const groupId = this._selectedGroup.id; const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length; + const label = key.startsWith('mcp:') + ? t('approval.tool.group_mcp', { server: key.slice(4) }) + : this._catLabel(key); return html`
this._toggleSection(key)}> - ${key} + ${label} ${description ? html`${description}` : nothing} ${configured > 0 ? `${configured}/` : ''}${tools.length} @@ -851,12 +864,12 @@ export class ApprovalRulesPage extends LightElement { return html`
- Per-tool - priority = 0 · exact tool name · no path/source filters + ${t('approval.matrix.title')} + ${t('approval.matrix.subtitle')}
${groups.length === 0 - ? html`
Loading tools…
` + ? html`
${t('approval.matrix.loading')}
` : groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
@@ -865,6 +878,15 @@ export class ApprovalRulesPage extends LightElement { // ── File System panel ───────────────────────────────────────────────────────── + _fsAccessLabel(key) { + return { + allow_read: t('approval.fs.allow_read'), + allow_write: t('approval.fs.allow_write'), + deny: t('approval.fs.deny'), + require: t('approval.fs.require'), + }[key] ?? key; + } + _renderFsAccessSelect(value, onChange, allowUnset) { return html` `; @@ -892,7 +914,7 @@ export class ApprovalRulesPage extends LightElement { ? html`` : html` ${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)} - `} @@ -907,7 +929,7 @@ export class ApprovalRulesPage extends LightElement { { this._fsNewPath = e.target.value; }} @keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }} @@ -916,8 +938,8 @@ export class ApprovalRulesPage extends LightElement { class="form-select form-select-sm apr-fs-select" @change=${(e) => { this._fsNewAccess = e.target.value; }} > - ${Object.entries(FS_ACCESS).map(([k, v]) => html` - + ${Object.entries(FS_ACCESS).map(([k]) => html` + `)} + >${t('approval.sidebar.add')}
${isOpen ? html`
${formActive ? this._renderForm() : nothing} ${rules.length === 0 && !formActive - ? html`
No rules yet.
` + ? html`
${t('approval.sidebar.empty')}
` : rules.map(r => this._renderCard(r))}
` : nothing} @@ -998,12 +1020,12 @@ export class ApprovalRulesPage extends LightElement {
- Default action - if no rule matches + ${t('approval.default_bar.title')} + ${t('approval.default_bar.hint')}
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))} ${action === null - ? html`system default: allow` + ? html`${t('approval.default_bar.unset')}` : nothing}
`; @@ -1029,11 +1051,11 @@ export class ApprovalRulesPage extends LightElement {

- ${isDefault ? html`Default` : nothing} + ${isDefault ? html`${t('approval.header.default_badge')}` : nothing} ${group.name}

- ${totalRules} rule${totalRules === 1 ? '' : 's'} + ${totalRules === 1 ? t('approval.header.rule_count', { n: totalRules }) : t('approval.header.rule_count_plural', { n: totalRules })}
@@ -1044,9 +1066,9 @@ export class ApprovalRulesPage extends LightElement {
${this._renderSidePanel( 'override', - 'Overrides', + t('approval.sidebar.overrides'), 'bi-exclamation-triangle-fill', - 'priority < 0 · evaluated first', + t('approval.sidebar.overrides_sub'), overrides, this._overrideOpen, () => { this._overrideOpen = !this._overrideOpen; }, @@ -1059,9 +1081,9 @@ export class ApprovalRulesPage extends LightElement { ${this._renderSidePanel( 'lowprio', - 'Low Priority', + t('approval.sidebar.lowprio'), 'bi-arrow-down-circle-fill', - 'priority 1–999998 · evaluated after per-tool', + t('approval.sidebar.lowprio_sub'), lowPrio, this._lowPrioOpen, () => { this._lowPrioOpen = !this._lowPrioOpen; }, diff --git a/web/components/catalog.js b/web/components/catalog.js index e4ca2da..3bd70f0 100644 --- a/web/components/catalog.js +++ b/web/components/catalog.js @@ -1,5 +1,7 @@ import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; // Connector catalog — blueprint §14/§15. Admin only. // @@ -54,15 +56,21 @@ export class CatalogPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'catalog'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); - // Close the chooser when clicking anywhere else. document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { @@ -108,7 +116,7 @@ export class CatalogPage extends LightElement { async _saveManual() { const f = this._modal.form; - if (!f.name.trim()) { this._error = 'Name is required.'; return; } + if (!f.name.trim()) { this._error = t('catalog.error.name'); return; } const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); try { await jf('/api/mcp/catalog', { @@ -135,7 +143,7 @@ export class CatalogPage extends LightElement { } async _delete(row) { - if (!confirm(`Remove "${row.name}" from the catalog?\n\nAnything already activated from it keeps running.`)) return; + if (!confirm(t('catalog.confirm.delete', { name: row.name }))) return; try { await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); await this._load(); @@ -152,7 +160,7 @@ export class CatalogPage extends LightElement { return html`
-

Connector Catalog

+

${t('catalog.title')}

${this._isAdmin ? this._renderAddButton() : nothing}
@@ -165,20 +173,13 @@ export class CatalogPage extends LightElement { ${this._me && !this._isAdmin ? html`
-

The catalog is managed by the admin.

-

- What you can activate is on the - { e.preventDefault(); this._goConnectors(); }}>Connectors page. -

+

${t('catalog.not_admin')}

+

${unsafeHTML(t('catalog.not_admin_link'))}

` : loading ? html` -

Loading…

+

${t('catalog.loading')}

` : html` -
- What this box offers. Nothing here is running — a global entry still needs - enabling, a per-user one still needs each user to activate it, both on the - { e.preventDefault(); this._goConnectors(); }}>Connectors page. -
+
${unsafeHTML(t('catalog.desc'))}
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} `}
@@ -194,27 +195,23 @@ export class CatalogPage extends LightElement { return html` `; @@ -224,10 +221,10 @@ export class CatalogPage extends LightElement { return html`
-

The catalog is empty.

-

Add a connector from the marketplace to get started.

+

${t('catalog.empty.title')}

+

${t('catalog.empty.hint')}

`; } @@ -235,7 +232,7 @@ export class CatalogPage extends LightElement { _renderTable(rows) { return html`
#StrengthNameModel IDScope${t('agents.table.rank')}${t('agents.table.strength')}${t('agents.table.name')}${t('agents.table.model_id')}${t('agents.table.scope')}
- + ${rows.map(r => html` @@ -247,12 +244,12 @@ export class CatalogPage extends LightElement { text-overflow:ellipsis;white-space:nowrap" title=${r.description}>${r.description}` : nothing} + ${r.scope === 'global' ? t('catalog.badge.global') : t('catalog.badge.per_user')} + ${r.source === 'local_script' ? t('catalog.badge.local_script') : t('catalog.badge.remote')}`)} @@ -285,35 +282,31 @@ export class CatalogPage extends LightElement {
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
- Add connector manually + ${t('catalog.modal.title')}
${this._error ? html`
${this._error}
` : nothing} ${isScript ? html` -
- A local script runs code on this box. - Nothing verifies it — unlike the marketplace path, there is no digest to check. -
` : nothing} - ${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'slug', mono: true })} - ${this._select('Scope', f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} - ${this._select('Type', f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} - ${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} +
${unsafeHTML(t('catalog.modal.script_warn'))}
` : nothing} + ${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })} + ${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} + ${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} + ${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} ${isScript - ? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })} - ${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as /, under ./connectors', mono: true })}` - : this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })} - ${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })} - ${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })} - ${this._select('Auth', f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} - ${this._field('Friendly name', f.friendly_name, e => this._patch('friendly_name', e.target.value))} - ${this._field('Description', f.description, e => this._patch('description', e.target.value), - { hint: 'the LLM reads this when deciding to activate the connector' })} + ? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })} + ${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}` + : this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} + ${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })} + ${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })} + ${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} + ${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} + ${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })}
`; diff --git a/web/components/config-page.js b/web/components/config-page.js index 36715fc..6dca4d4 100644 --- a/web/components/config-page.js +++ b/web/components/config-page.js @@ -1,5 +1,6 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; export class ConfigPage extends LightElement { static properties = { @@ -9,6 +10,8 @@ export class ConfigPage extends LightElement { _saving: { state: true }, // Set _saved: { state: true }, // Set (brief flash) _error: { state: true }, + _debugMode: { state: true }, + _debugLoading: { state: true }, }; constructor() { @@ -19,17 +22,55 @@ export class ConfigPage extends LightElement { this._saving = new Set(); this._saved = new Set(); this._error = null; + this._debugMode = false; + this._debugLoading = true; } connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'config'; this.style.display = this._open ? 'flex' : 'none'; - if (this._open) this._load(); + if (this._open) { this._load(); this._loadDebugMode(); } }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + + async _loadDebugMode() { + try { + const res = await fetch('/api/dev/debug_mode'); + if (!res.ok) throw new Error(); + const data = await res.json(); + this._debugMode = data.enabled; + } catch { + // ignore, keep current value + } finally { + this._debugLoading = false; + } + } + + async _toggleDebugMode() { + const next = !this._debugMode; + this._debugMode = next; + try { + const res = await fetch('/api/dev/debug_mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: next }), + }); + if (!res.ok) throw new Error(); + window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } })); + } catch { + this._debugMode = !next; + } + } + async _load() { this._error = null; try { @@ -70,7 +111,7 @@ export class ConfigPage extends LightElement { this._saved = new Set([...this._saved].filter(k => k !== key)); }, 1500); } catch (e) { - alert(`Error saving ${prop.name}: ${e.message}`); + alert(t('config.error_save', { name: prop.name, msg: e.message })); } finally { this._saving = new Set([...this._saving].filter(k => k !== key)); } @@ -164,18 +205,45 @@ export class ConfigPage extends LightElement { return html`
-

Config

+

${t('config.title')}

${this._error ? html`
${this._error}
` : nothing} ${this._properties.length === 0 && !this._error ? html` -

Loading…

` : nothing} +

${t('config.loading')}

` : nothing}
${this._properties.map(s => this._renderSet(s))}
+ +
+
+
${t('config.developer')}
+
+
+
+
+
+
${t('config.debug')}
+
${t('config.debug.desc')}
+
+
+
+ this._toggleDebugMode()} /> + +
+
+
+
+
`; } } diff --git a/web/components/connector-detail.js b/web/components/connector-detail.js index 242222a..8eabab2 100644 --- a/web/components/connector-detail.js +++ b/web/components/connector-detail.js @@ -1,5 +1,6 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; import { announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf, } from './shared/connector-common.js'; @@ -75,6 +76,8 @@ export class ConnectorDetailPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; @@ -85,6 +88,11 @@ export class ConnectorDetailPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } get _status() { return statusOf({ _act: this._act, _glob: this._glob }); } @@ -112,7 +120,7 @@ export class ConnectorDetailPage extends LightElement { const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null; if (!entry && !glob) { - this._error = `No connector named “${this._name}” is available to you.`; + this._error = t('connectors.error.no_connector', { name: this._name }); return; } this._entry = entry; @@ -193,7 +201,7 @@ export class ConnectorDetailPage extends LightElement { }); if (res?.auth_state === 'pending') { this._test = res.verify ?? { ok: false, message: 'Verification failed.' }; - this._error = 'Saved, but the credentials did not check out — fix them and test again.'; + this._error = t('connectors.detail.test.error_saved'); } else if (res?.error) { this._error = res.error; } @@ -204,7 +212,7 @@ export class ConnectorDetailPage extends LightElement { } async _deactivate() { - if (!confirm(`Deactivate “${this._entry?.friendly_name || this._name}”?`)) return; + if (!confirm(t('connectors.detail.confirm.deactivate', { name: this._entry?.friendly_name || this._name }))) return; this._busy = true; try { await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' }); @@ -273,7 +281,7 @@ export class ConnectorDetailPage extends LightElement { }); if (res?.verify && !res.verify.ok && !res.verify.skipped) { this._test = res.verify; - this._error = 'Verification failed — the connector stays disabled until the credentials are fixed.'; + this._error = t('connectors.detail.test.error_verify'); } else if (res?.error) { this._error = res.error; } @@ -284,7 +292,7 @@ export class ConnectorDetailPage extends LightElement { } async _disableGlobal() { - if (!confirm(`Disable “${this._glob.friendly_name || this._name}”?\n\nIt stops for everyone who can use it.`)) return; + if (!confirm(t('connectors.detail.confirm.disable_global', { name: this._glob.friendly_name || this._name }))) return; this._busy = true; try { await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' }); @@ -328,7 +336,7 @@ export class ConnectorDetailPage extends LightElement { } if (!this._entry && !this._glob) { return html`
${this._renderHeader()} -
Loading…
`; +
${t('connectors.loading')}
`; } return html` @@ -349,7 +357,7 @@ export class ConnectorDetailPage extends LightElement { return html`
-

${title}

@@ -362,6 +370,7 @@ export class ConnectorDetailPage extends LightElement { const isScript = e?.source === 'local_script'; const status = this._status; const desc = e?.description || this._glob?.description; + const _statusText = (s) => ({ active: t('connectors.detail.status.active'), pending: t('connectors.detail.status.needs_fix'), needs_login: t('connectors.detail.status.needs_signin') })[s] ?? s; return html`
@@ -382,24 +391,24 @@ export class ConnectorDetailPage extends LightElement { ${desc ? html`
${desc}
` : nothing}
- ${this._isGlobal ? 'global' : 'per-user'} + ${this._isGlobal ? t('connectors.detail.detail_scope_global') : t('connectors.chip.per_user')} ${isScript ? html` - runs code on this box + ${t('connectors.detail.scope_local')} ` : nothing} ${e?.auth_kind && e.auth_kind !== 'none' ? html` ${e.auth_kind}` : nothing} ${status === 'active' ? html` - active` : nothing} + ${t('connectors.detail.status.active')}` : nothing} ${status === 'pending' ? html` - needs fixing` : nothing} + ${t('connectors.detail.status.needs_fix')}` : nothing} ${status === 'needs_login' ? html` - needs sign-in` : nothing} + ${t('connectors.detail.status.needs_signin')}` : nothing}
${this._isGlobal ? html`
- Runs once for the household, on the host. Nobody reaches it until they are granted access. + ${t('connectors.detail.global_note')}
` : nothing}
`; } @@ -412,8 +421,8 @@ export class ConnectorDetailPage extends LightElement { return html`
-

This connector is managed for you.

-

It is enabled by an admin and granted to you — there is nothing to configure.

+

${t('connectors.detail.managed.title')}

+

${t('connectors.detail.managed.desc')}

`; } @@ -436,7 +445,7 @@ export class ConnectorDetailPage extends LightElement { return html`
-

Sign in

+

${t('connectors.detail.oauth.title')}

${this._renderOauth()}
`; @@ -446,20 +455,20 @@ export class ConnectorDetailPage extends LightElement {

- ${active ? 'Configuration' : 'Set up'} + ${active ? t('connectors.detail.config.title_active') : t('connectors.detail.config.title_setup')}

${active ? html`
${this._isGlobal - ? 'Already enabled. Re-submitting replaces the stored credentials.' - : 'Already active. Re-submitting replaces the stored credentials.'} + ? t('connectors.detail.config.already_global') + : t('connectors.detail.config.already_user')}
` : nothing} ${e.auth_kind === 'api_key' && !schemaHasSecret ? html`
- + { this._form = { ...this._form, api_key: ev.target.value }; }} />
` : nothing} @@ -472,25 +481,25 @@ export class ConnectorDetailPage extends LightElement { ` : nothing} ${this._isGlobal ? html` ${this._glob ? html` ` : nothing}` : html` ${this._act ? html` ` : nothing}`}
`; @@ -504,40 +513,38 @@ export class ConnectorDetailPage extends LightElement { const scopes = parseJson(this._entry?.oauth_scopes_json, []); return html` -
- Signs in with ${label}. You approve access in a browser tab, then paste back the - code the page shows you — nothing is stored on this box until you do. -
+
${t('connectors.detail.oauth.desc', { provider: label })}
${scopes.length ? html`
-
It will request access to:
+
${t('connectors.detail.oauth.scopes')}
    ${scopes.map(s => html`
  • ${s}
  • `)}
` : nothing} ${active ? html`
- Signed in and active. + ${t('connectors.detail.oauth.signed_in')}
` : nothing} ${!this._oauth ? html`
${this._act ? html` ` : nothing}
` : html`
- A tab opened for ${label}. Approve access there. - + ${t('connectors.detail.oauth.step1', { provider: label })} +
- Paste the code the page gave you: + ${t('connectors.detail.oauth.step2')}
+ @click=${() => { this._oauth = null; }}>${t('connectors.detail.oauth.cancel')}
`} `; @@ -574,20 +581,20 @@ export class ConnectorDetailPage extends LightElement { } _renderVerifyBox() { - const t = this._test; - if (t === null) return nothing; - if (t === 'running') { + const result = this._test; + if (result === null) return nothing; + if (result === 'running') { return html`
- Testing credentials…
`; + ${t('connectors.detail.test.running')}`; } - if (t.skipped) { + if (result.skipped) { return html`
- ${t.message || 'No verification step for this connector.'}
`; + ${result.message || t('connectors.detail.test.skipped')}`; } return html` -
- - ${t.ok ? 'OK' : 'Failed'} — ${t.message} +
+ + ${result.ok ? t('connectors.detail.test.ok_label') : t('connectors.detail.test.fail_label')} — ${result.message} ${t.details ? html`
${JSON.stringify(t.details, null, 2)}
` : nothing} @@ -602,13 +609,11 @@ export class ConnectorDetailPage extends LightElement { return html`
-

Who can use it

-
-
- Ticking a box grants this connector's tools to that person's agent. Saving replaces the whole list. +

${t('connectors.detail.access.title')}

+
${t('connectors.detail.access.desc')}
${users.length === 0 - ? html`

No users.

` + ? html`

${t('connectors.detail.access.empty')}

` : html`
${users.map(u => html` @@ -624,7 +629,7 @@ export class ConnectorDetailPage extends LightElement {
`}
`; } diff --git a/web/components/connectors.js b/web/components/connectors.js index 505cb30..d3bdf14 100644 --- a/web/components/connectors.js +++ b/web/components/connectors.js @@ -1,6 +1,7 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; -import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js'; +import { t } from '../lib/i18n.js'; +import { connectorIconUrl, statusOf, STATUS_LABEL, statusText } from './shared/connector-common.js'; // Connectors (MCP) — blueprint §7/§14/§15. // @@ -65,16 +66,21 @@ export class ConnectorsPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'connectors'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); - // Coming back from a connector's page must show its new state, not the state - // captured before the user activated it. window.addEventListener('connectors-changed', () => { if (this._open) this._load(); }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { @@ -153,11 +159,11 @@ export class ConnectorsPage extends LightElement { async _saveProvider() { const f = this._pForm; if (!f.name.trim() || !f.client_id.trim()) { - this._pError = 'Name and client id are required.'; + this._pError = t('connectors.providers.error.name_client'); return; } if (f._isNew && !f.client_secret.trim()) { - this._pError = 'A client secret is required for a new provider.'; + this._pError = t('connectors.providers.error.secret'); return; } this._pError = null; @@ -173,7 +179,7 @@ export class ConnectorsPage extends LightElement { } async _deleteProvider(name) { - if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return; + if (!confirm(t('connectors.providers.delete_confirm', { name }))) return; try { await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); this._providers = await jf('/api/mcp/providers'); @@ -240,17 +246,17 @@ export class ConnectorsPage extends LightElement { return html`
-

Connectors

+

${t('connectors.title')}

${this._isAdmin ? html` ` : nothing}
@@ -259,13 +265,13 @@ export class ConnectorsPage extends LightElement {
${this._error}
` : nothing} ${loading - ? html`
Loading…
` + ? html`
${t('connectors.loading')}
` : html`
@@ -283,15 +289,12 @@ export class ConnectorsPage extends LightElement { @click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
-

Sign-in providers

+

${t('connectors.providers.title')}

-
- OAuth apps that per-user connectors sign in through. One app (e.g. Google) covers all of - its services. The client secret is stored on this box and never shown again. -
+
${t('connectors.providers.desc')}
${this._pError ? html`
${this._pError}
` : nothing} ${this._pForm ? this._renderProviderForm() : this._renderProviderList()} @@ -304,7 +307,7 @@ export class ConnectorsPage extends LightElement { return html` ${list.length === 0 ? html`
-

No sign-in providers yet.

` : html` +

${t('connectors.providers.empty')}

` : html`
${list.map(p => html`
${p.name}
${p.has_client_secret - ? html` secret set` - : html` no secret`} - · ${p.client_id || '(no client id)'} + ? html` ${t('connectors.providers.secret_set')}` + : html` ${t('connectors.providers.no_secret')}`} + · ${p.client_id || t('connectors.providers.no_client_id')}
@@ -329,10 +332,10 @@ export class ConnectorsPage extends LightElement {
`}
`; } @@ -350,24 +353,24 @@ export class ConnectorsPage extends LightElement { ${opts.help ? html`
${opts.help}
` : nothing}
`; return html` - ${field('name', 'Provider id', { req: true, mono: true, ph: 'google', - help: 'The slug a connector references (must match the manifest\'s auth.provider).' })} - ${field('display_name', 'Display name', { ph: 'Google' })} - ${field('client_id', 'Client id', { req: true, mono: true })} - ${field('client_secret', 'Client secret', { secret: true, mono: true, - help: f._isNew ? 'Required.' : 'Leave blank to keep the stored secret.' })} - ${field('auth_url', 'Authorization URL', { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })} - ${field('token_url', 'Token URL', { mono: true, ph: 'https://oauth2.googleapis.com/token' })} - ${field('redirect_uri', 'Redirect URI', { mono: true, - help: 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.' })} - ${field('extra_params', 'Extra params (JSON)', { mono: true, ph: '{"access_type":"offline","prompt":"consent"}', - help: 'Merged into the consent URL. Google needs these two to return a refresh token.' })} + ${field('name', t('connectors.providers.field.name'), { req: true, mono: true, ph: 'google', + help: t('connectors.providers.field.name_help') })} + ${field('display_name', t('connectors.providers.field.display'), { ph: 'Google' })} + ${field('client_id', t('connectors.providers.field.client_id'), { req: true, mono: true })} + ${field('client_secret', t('connectors.providers.field.client_secret'), { secret: true, mono: true, + help: f._isNew ? t('connectors.providers.field.secret_help_new') : t('connectors.providers.field.secret_help_edit') })} + ${field('auth_url', t('connectors.providers.field.auth_url'), { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })} + ${field('token_url', t('connectors.providers.field.token_url'), { mono: true, ph: 'https://oauth2.googleapis.com/token' })} + ${field('redirect_uri', t('connectors.providers.field.redirect'), { mono: true, + help: t('connectors.providers.field.redirect_help') })} + ${field('extra_params', t('connectors.providers.field.extra'), { mono: true, ph: '{"access_type":"offline","prompt":"consent"}', + help: t('connectors.providers.field.extra_help') })}
`; } @@ -375,14 +378,14 @@ export class ConnectorsPage extends LightElement { _renderEmpty() { if (this._q.trim()) { return html`
-

No connector matches “${this._q}”.

`; +

${t('connectors.empty.match', { query: this._q })}

`; } return html`
-

${this._isAdmin ? 'No connectors installed yet.' : 'Nothing available to you yet.'}

+

${this._isAdmin ? t('connectors.empty.installed') : t('connectors.empty.available')}

${this._isAdmin - ? html`

Install one from the Marketplace to get started.

` - : html`

Ask an admin to make one available.

`} + ? html`

${t('connectors.empty.install_hint')}

` + : html`

${t('connectors.empty.ask_admin')}

`}
`; } @@ -407,7 +410,7 @@ export class ConnectorsPage extends LightElement {
${r.name}
- ${STATUS_LABEL[status].text} + ${statusText(status)}
@@ -415,11 +418,11 @@ export class ConnectorsPage extends LightElement {
- ${isGlobal ? 'global' : 'per-user'} + ${isGlobal ? t('connectors.chip.global') : t('connectors.chip.per_user')} ${isScript ? html` - local script + ${t('connectors.chip.local_script')} ` : nothing} ${r.auth_kind && r.auth_kind !== 'none' ? html` ${r.auth_kind}` : nothing} diff --git a/web/components/copilot-render.js b/web/components/copilot-render.js index e4c53d3..8f3ea1c 100644 --- a/web/components/copilot-render.js +++ b/web/components/copilot-render.js @@ -2,6 +2,7 @@ import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdown } from '../lib/base.js'; import { openFile } from '../lib/open-file.js'; +import { t } from '../lib/i18n.js'; // ── Utilities ──────────────────────────────────────────────────────────────── @@ -33,7 +34,7 @@ function renderPath(seg, path) { if (!path || seg !== path) return html`${seg}`; const open = (e) => { e.stopPropagation(); openFile(seg); }; return html` { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }} >${seg}`; @@ -90,7 +91,7 @@ export function renderDiff(oldText, newText) { result.push(html`${eqBuf.join('\n')}\n`); } else { result.push(html`${eqBuf.slice(0, 3).join('\n')}\n`); - result.push(html`⋯ ${eqBuf.length - 6} unchanged lines ⋯`); + result.push(html`${t('copilot.unchanged_lines', { n: eqBuf.length - 6 })}`); result.push(html`\n${eqBuf.slice(-3).join('\n')}\n`); } eqBuf = []; @@ -118,15 +119,15 @@ export function renderPendingWrite(host, msg) {
openFile(msg.path)} @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }} >${msg.path} ${msg.status === 'pending' - ? html`Pending approval` + ? html`${t('approval.pending')}` : msg.status === 'approved' - ? html`Approved` - : html`Rejected`} + ? html`${t('approval.approved')}` + : html`${t('approval.rejected')}`}
${renderDiff(msg.old_content, msg.new_content)}
@@ -137,33 +138,33 @@ export function renderPendingWrite(host, msg) {
` : html`
- -
`} @@ -183,13 +184,13 @@ export function renderTool(host, msg) { msg.status === 'running' ? html`` : isPending - ? html`` + ? html`` : msg.status === 'done' ? html`` : msg.status === 'cancelled' - ? html`` + ? html`` : msg.status === 'rejected' - ? html`` + ? html`` : html``; return html` @@ -197,7 +198,7 @@ export function renderTool(host, msg) { ${isOpen ? html` @@ -226,7 +227,7 @@ export function renderTool(host, msg) {
` : html`
${msg.request_id != null ? html` - - ` : nothing}
@@ -284,7 +285,7 @@ export function renderTool(host, msg) { `) : msg.status !== 'running' ? ( msg.status === 'done' && msg.result_type === 'json' ? html`
- result · json + ${t('copilot.result_json')}
${
                 truncate(prettyJson(msg.result))
               }
@@ -292,7 +293,7 @@ export function renderTool(host, msg) { ` : html`
- ${msg.status === 'done' ? 'result' : 'error'} + ${msg.status === 'done' ? t('copilot.result') : t('copilot.error_label')}
${
                 truncate(msg.status === 'done' ? msg.result : msg.error)
@@ -316,7 +317,7 @@ export function renderAgent(msg) {
           
           ${msg.agent_id}
         
-        ${msg.done ? html`done` : html`running…`}
+        ${msg.done ? html`${t('copilot.agent_done')}` : html`${t('copilot.agent_running')}`}
       
${msg.prompt_preview ? html`
${msg.prompt_preview}
@@ -335,7 +336,7 @@ export function renderAgentEnd(msg) { ${msg.parent_agent_id ?? 'main'} - finished + ${t('copilot.agent_finished')}
${msg.result_preview ? html`
${msg.result_preview}
@@ -345,7 +346,7 @@ export function renderAgentEnd(msg) { } function failedBadge() { - return html` + return html` `; } @@ -393,7 +394,7 @@ export function renderAttachmentChips(host, attachments, { removable = false } = ${att.name} ${att.filesize != null ? html`${fmtSize(att.filesize)}` : nothing} ${removable ? html` - ` : nothing} diff --git a/web/components/copilot.js b/web/components/copilot.js index e48a0a1..0ac9fb1 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -1,26 +1,29 @@ import { html, nothing } from 'lit'; import { ChatSession } from '../lib/chat-session.js'; +import { t, I18nMixin } from '../lib/i18n.js'; import { renderMsg, renderAttachmentChips } from './copilot-render.js'; // Built-in (server-handled) slash commands shown at the top of the composer // autocomplete. Custom commands (from `commands//`) are fetched from // `/api/commands` and appended below. const SYSTEM_COMMAND_ITEMS = [ - { name: 'help', description: 'Show available commands' }, - { name: 'clear', description: 'Start a new conversation' }, - { name: 'new', description: 'Alias for /clear' }, - { name: 'models', description: 'List available LLM models' }, - { name: 'model', description: 'Select the model for this chat' }, - { name: 'context', description: "Last turn's token usage" }, - { name: 'cost', description: 'Session spend (USD)' }, - { name: 'compact', description: 'Force context compaction' }, - { name: 'resettools', description: 'Remove activated tool groups' }, - { name: 'sethome', description: 'Set web as notification home' }, + { name: 'help', description: () => t('copilot.cmd.help') }, + { name: 'clear', description: () => t('copilot.cmd.clear') }, + { name: 'new', description: () => t('copilot.cmd.new') }, + { name: 'models', description: () => t('copilot.cmd.models') }, + { name: 'model', description: () => t('copilot.cmd.model') }, + { name: 'context', description: () => t('copilot.cmd.context') }, + { name: 'cost', description: () => t('copilot.cmd.cost') }, + { name: 'compact', description: () => t('copilot.cmd.compact') }, + { name: 'resettools', description: () => t('copilot.cmd.resettools') }, + { name: 'sethome', description: () => t('copilot.cmd.sethome') }, ]; -export class AppCopilot extends ChatSession { +export class AppCopilot extends I18nMixin(ChatSession) { static properties = { _collapsed: { state: true }, + _mode: { state: true }, + _me: { state: true }, _modelOpen: { state: true }, _tabs: { state: true }, _activeSource: { state: true }, @@ -31,6 +34,9 @@ export class AppCopilot extends ChatSession { constructor() { super(); this._collapsed = false; + // 'full' fills the workspace (home route), 'dock' is the side panel. + this._mode = 'dock'; + this._me = null; this._modelOpen = false; this._resizing = false; // Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown @@ -41,23 +47,53 @@ export class AppCopilot extends ChatSession { this._allCommands = null; // Browser-style tabs: 'General' (the default 'web' source) is always present and // not closable; project chats are added on demand and addressed by their source. - this._tabs = [{ source: 'web', label: 'General' }]; + this._tabs = [{ source: 'web', label: t('chat.tab.general') }]; this._onResizeMove = this._onResizeMove.bind(this); this._onResizeUp = this._onResizeUp.bind(this); this._onKeydown = this._onKeydown.bind(this); this._onKeyup = this._onKeyup.bind(this); this._onProjectChatOpen = this._onProjectChatOpen.bind(this); this._onCopilotOpen = this._onCopilotOpen.bind(this); + this._onPageChange = this._onPageChange.bind(this); } connectedCallback() { super.connectedCallback?.(); this._restoreState(); this._loadCommands(); + this._loadMe(); + // Same element, two layouts: the chat is the home page ('full') and docks + // to the side on every other route — state is never lost, it only resizes. + this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock'); window.addEventListener('keydown', this._onKeydown); window.addEventListener('keyup', this._onKeyup); window.addEventListener('project-chat-open', this._onProjectChatOpen); window.addEventListener('copilot-open', this._onCopilotOpen); + window.addEventListener('llm-page-change', this._onPageChange); + } + + _pageFromHash() { + const m = location.hash.slice(1).match(/^([^/?]+)/); + const seg = m ? m[1] : ''; + const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer']; + return known.includes(seg) ? seg : 'home'; + } + + _onPageChange(e) { + this._applyMode(e.detail?.page === 'home' ? 'full' : 'dock'); + } + + _applyMode(mode) { + if (mode === this._mode && this.getAttribute('mode') === mode) return; + this._mode = mode; + this.setAttribute('mode', mode); + } + + async _loadMe() { + try { + const res = await fetch('/api/auth/me'); + if (res.ok) this._me = await res.json(); + } catch { /* ignore */ } } _restoreState() { @@ -74,6 +110,7 @@ export class AppCopilot extends ChatSession { window.removeEventListener('keyup', this._onKeyup); window.removeEventListener('project-chat-open', this._onProjectChatOpen); window.removeEventListener('copilot-open', this._onCopilotOpen); + window.removeEventListener('llm-page-change', this._onPageChange); } _onCopilotOpen() { @@ -254,36 +291,82 @@ export class AppCopilot extends ChatSession { // ── Render ──────────────────────────────────────────────────────────────────── + _sendSuggestion(text) { + const el = this._inputEl(); + if (!el) return; + el.value = text; + this._send(); + } + + _renderEmptyState() { + // Dock mode keeps the compact greeting bubble; full mode (home) shows the + // welcome hero with a few prompt suggestions to get a conversation going. + if (this._mode !== 'full') { + return html`
${t('chat.hello')}
`; + } + const name = this._me?.display_name || this._me?.username; + const suggestions = [ + { icon: 'bi-stars', text: t('chat.suggest.1') }, + { icon: 'bi-calendar-check', text: t('chat.suggest.2') }, + { icon: 'bi-book', text: t('chat.suggest.3') }, + { icon: 'bi-heart', text: t('chat.suggest.4') }, + ]; + return html` +
+ +

${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}

+

${t('chat.greeting.sub')}

+
+ ${suggestions.map(s => html` + + `)} +
+
+ `; + } + render() { - if (this._collapsed) return nothing; + // Collapse applies to the dock only: on the home route the chat IS the page. + if (this._collapsed && this._mode !== 'full') return nothing; + const full = this._mode === 'full'; return html` -
this._startResize(e)}>
+ ${!full ? html` +
this._startResize(e)}>
+ ` : nothing}
- Copilot - + ${t('chat.title')} + + ${t('chat.privacy')} + + ${!full ? html` + + ` : nothing}
${this._tabs.length > 1 ? html`
- ${this._tabs.map(t => html` + ${this._tabs.map(tab => html`
this._selectTab(t.source)} - title=${t.label} + class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}" + @click=${() => this._selectTab(tab.source)} + title=${tab.label} > - ${t.label} - ${t.source !== 'web' ? html` - ` : nothing} @@ -293,16 +376,14 @@ export class AppCopilot extends ChatSession { ` : nothing}
- ${this._messages.length === 0 ? html` -
- Hello! How can I help you today? -
- ` : this._messages.map(m => renderMsg(this, m))} + ${this._messages.length === 0 + ? this._renderEmptyState() + : this._messages.map(m => renderMsg(this, m))} ${this._waiting ? html`
- Thinking… + ${t('chat.thinking')}
` : nothing}
@@ -319,7 +400,7 @@ export class AppCopilot extends ChatSession { @mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }} > /${c.name} - ${c.description} + ${typeof c.description === 'function' ? c.description() : c.description} `)}
@@ -335,7 +416,7 @@ export class AppCopilot extends ChatSession { -
Voice/tone guidance injected into the LLM system prompt when this model is active.
+
${t('models.form.instructions_hint')}
-
- Audio format requested from the provider. Leave empty unless the model requires - a specific one — e.g. Gemini TTS only accepts pcm. -
+
${unsafeHTML(t('models.form.response_hint'))}
- + this._form = { ...this._form, priority: e.target.value }} /> -
Lower number = used first. Default: 100.
+
${t('models.priority_hint_short')}
- +
@@ -424,17 +427,17 @@ export class ModelsTtsSection extends LightElement {
${this.onback ? html` - ` : ''}
-

Text-to-Speech Models

- ${this._models.length} model${this._models.length !== 1 ? 's' : ''} +

${t('models.tts.title')}

+ ${t('models.hub.count.many', { n: this._models.length })}
@@ -442,7 +445,7 @@ export class ModelsTtsSection extends LightElement {
-

No provider supports TTS yet. Add an OpenAI provider first.

+

${t('models.no_providers_tts')}

` : ''} @@ -451,7 +454,7 @@ export class ModelsTtsSection extends LightElement {
-

Models with the Plugin badge are read-only — managed automatically by the plugin that registered them.

+

${t('models.readonly_plugin')}

` : ''} @@ -464,10 +467,10 @@ export class ModelsTtsSection extends LightElement { ${this._models.length === 0 ? html`
-

No TTS models configured.

+

${t('models.list_empty_tts')}

${canAdd ? html` ` : ''}
diff --git a/web/components/profile-page.js b/web/components/profile-page.js index b7089d5..f166256 100644 --- a/web/components/profile-page.js +++ b/web/components/profile-page.js @@ -1,7 +1,8 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t, I18nMixin, LOCALES, setLocale, getLocale } from '../lib/i18n.js'; -export class ProfilePage extends LightElement { +export class ProfilePage extends I18nMixin(LightElement) { static get properties() { return { @@ -10,6 +11,8 @@ export class ProfilePage extends LightElement { _displayName: { state: true }, _savingName: { state: true }, _nameMsg: { state: true }, + _locale: { state: true }, + _localeMsg: { state: true }, _pwCurrent: { state: true }, _pwNew: { state: true }, _pwConfirm: { state: true }, @@ -25,6 +28,8 @@ export class ProfilePage extends LightElement { this._displayName = ''; this._savingName = false; this._nameMsg = null; + this._locale = ''; + this._localeMsg = null; this._pwCurrent = ''; this._pwNew = ''; this._pwConfirm = ''; @@ -47,6 +52,7 @@ export class ProfilePage extends LightElement { if (res.ok) { this._me = await res.json(); this._displayName = this._me.display_name ?? ''; + this._locale = this._me.locale ?? ''; } } catch { /* ignore */ } } @@ -62,7 +68,7 @@ export class ProfilePage extends LightElement { body: JSON.stringify({ display_name: this._displayName.trim() || null }), }); if (!res.ok) throw new Error(await res.text()); - this._nameMsg = { type: 'ok', text: 'Saved.' }; + this._nameMsg = { type: 'ok', text: t('profile.saved') }; await this._load(); } catch (e) { this._nameMsg = { type: 'err', text: e.message }; @@ -71,15 +77,34 @@ export class ProfilePage extends LightElement { } } + // '' → back to the instance default; otherwise a concrete locale id. + async _changeLocale(value) { + this._localeMsg = null; + try { + const res = await fetch('/api/auth/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locale: value === '' ? null : value }), + }); + if (!res.ok) throw new Error(await res.text()); + this._locale = value; + // Apply immediately: the explicit choice, or the instance default when reset. + setLocale(value === '' ? (this._me?.default_locale || 'en') : value); + this._localeMsg = { type: 'ok', text: t('profile.saved') }; + } catch (e) { + this._localeMsg = { type: 'err', text: e.message }; + } + } + async _savePassword() { if (this._savingPw) return; this._pwMsg = null; if (this._pwNew.length < 4) { - this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' }; + this._pwMsg = { type: 'err', text: t('profile.pw.short') }; return; } if (this._pwNew !== this._pwConfirm) { - this._pwMsg = { type: 'err', text: 'Passwords do not match.' }; + this._pwMsg = { type: 'err', text: t('profile.pw.mismatch') }; return; } this._savingPw = true; @@ -93,7 +118,7 @@ export class ProfilePage extends LightElement { }), }); if (!res.ok) throw new Error(await res.text()); - this._pwMsg = { type: 'ok', text: 'Password changed.' }; + this._pwMsg = { type: 'ok', text: t('profile.pw.changed') }; this._pwCurrent = ''; this._pwNew = ''; this._pwConfirm = ''; @@ -107,71 +132,89 @@ export class ProfilePage extends LightElement { render() { if (!this._open) return nothing; const me = this._me; + const defaultLabel = LOCALES.find(l => l.id === me?.default_locale)?.label ?? me?.default_locale ?? 'English'; return html`
-

Profile

+

${t('profile.title')}

${me ? html` -
+
-
Account
+
${t('profile.account')}
- +
- +
` : nothing} -
+
-
Display name
+
${t('profile.name')}
- this._displayName = e.target.value} />
${this._nameMsg ? html`
${this._nameMsg.text}
` : nothing}
-
+
-
Change password
+
${t('profile.language')}
+
+ +
+ ${this._localeMsg ? html`
${this._localeMsg.text}
` : nothing} +
+
+ +
+
+
${t('profile.pw')}
${me?.role_id === 'admin' || me?.encrypted ? html`
- + this._pwCurrent = e.target.value} />
` : nothing}
- + this._pwNew = e.target.value} />
- + this._pwConfirm = e.target.value} />
${this._pwMsg ? html`
${this._pwMsg.text}
` : nothing}
diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index 7a5f1e1..86a261e 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -1,6 +1,7 @@ import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement, renderMarkdown } from '../../lib/base.js'; +import { t } from '../../lib/i18n.js'; import { formatDate } from '../tasks/utils.js'; export class ProjectBoardSection extends LightElement { @@ -35,7 +36,14 @@ export class ProjectBoardSection extends LightElement { this._activeTab = 'tickets'; } + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); this._stopPolling(); } @@ -166,7 +174,7 @@ export class ProjectBoardSection extends LightElement { } async _deleteTicket(ticket) { - if (!confirm(`Delete ticket "${ticket.title}"?`)) return; + if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return; try { const res = await fetch( `/api/projects/${ticket.project_id}/tickets/${ticket.id}`, @@ -272,7 +280,7 @@ export class ProjectBoardSection extends LightElement { ${ticket.status === 'todo' ? html` ${ticket.session_id != null ? html` ${isDone ? html`
- ${unsafeHTML(renderMarkdown(ticket.result ?? '(no output)'))} + ${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))}
` - : html`
${ticket.error ?? '(no error message)'}
`} + : html`
${ticket.error ?? t('project_board.ticket.no_error')}
`}
` : nothing}
@@ -341,7 +349,7 @@ export class ProjectBoardSection extends LightElement {
`; @@ -351,9 +359,9 @@ export class ProjectBoardSection extends LightElement { const { running, todo, completed } = this._groupTickets(); return html`
- ${this._renderSection('Running', 'activity', 'ticket-section-header--running', running, 'No tickets running')} - ${this._renderSection('Todo', 'circle', '', todo, 'No tickets to do')} - ${this._renderSection('Completed', 'check-circle', 'ticket-section-header--completed', completed, 'No completed tickets')} + ${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))} + ${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))} + ${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))}
`; } @@ -364,7 +372,7 @@ export class ProjectBoardSection extends LightElement {
- New Ticket + ${t('project_board.modal.title')} + @click=${() => this._closeModal()}>${t('projects.modal.cancel')}
@@ -158,11 +170,11 @@ export class ProjectListSection extends LightElement {
${project.name}
e.stopPropagation()}> - - @@ -172,7 +184,7 @@ export class ProjectListSection extends LightElement { ${project.description ? html`
${project.description}
` : nothing} -
Updated ${formatDate(project.updated_at)}
+
${t('projects.card.updated')} ${formatDate(project.updated_at)}
`; } @@ -181,9 +193,9 @@ export class ProjectListSection extends LightElement { return html`
-

Projects

+

${t('projects.title')}

@@ -194,7 +206,7 @@ export class ProjectListSection extends LightElement { ${this._projects.length === 0 ? html`
-

No projects yet. Create one to get started.

+

${t('projects.empty')}

` : html`
diff --git a/web/components/roles-page.js b/web/components/roles-page.js index 2de3320..8c7a74d 100644 --- a/web/components/roles-page.js +++ b/web/components/roles-page.js @@ -1,5 +1,7 @@ import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; const ADMIN_ID = 'admin'; @@ -26,6 +28,8 @@ export class RolesPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'roles'; this.style.display = this._open ? 'flex' : 'none'; @@ -33,6 +37,11 @@ export class RolesPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async _load() { this._error = null; try { @@ -40,8 +49,8 @@ export class RolesPage extends LightElement { fetch('/api/roles'), fetch('/api/tool-permission-groups'), ]); - if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`); - if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); + if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); + if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); this._roles = await rRes.json(); this._groups = await gRes.json(); } catch (e) { @@ -51,10 +60,25 @@ export class RolesPage extends LightElement { // ── Modal helpers ──────────────────────────────────────────────────────────── + // `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI + // surfaces it as a first-class select without hardcoding any role semantics. + _attrsUiMode(attrs) { + try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; } + catch { return 'full'; } + } + + _mergeAttrs(attrs, uiMode) { + let o = {}; + try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; } + if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode; + const keys = Object.keys(o); + return keys.length ? JSON.stringify(o) : null; + } + _openCreate() { this._modal = { mode: 'create', - form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' }, + form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' }, }; } @@ -62,7 +86,7 @@ export class RolesPage extends LightElement { this._modal = { mode: 'edit', role, - form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' }, + form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) }, }; } @@ -79,7 +103,7 @@ export class RolesPage extends LightElement { this._error = null; if (mode === 'create') { - if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; } + if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; } try { const res = await fetch('/api/roles', { method: 'POST', @@ -88,7 +112,7 @@ export class RolesPage extends LightElement { id: form.id.trim(), label: form.label.trim(), permission_group: form.permission_group, - attrs: form.attrs.trim() || null, + attrs: this._mergeAttrs(form.attrs, form.ui_mode), }), }); if (!res.ok) throw new Error(await res.text()); @@ -97,7 +121,7 @@ export class RolesPage extends LightElement { } catch (e) { this._error = e.message; } } else { const { role } = this._modal; - if (!form.label.trim()) { this._error = 'Label is required.'; return; } + if (!form.label.trim()) { this._error = t('roles.error.label'); return; } try { const res = await fetch(`/api/roles/${role.id}`, { method: 'PUT', @@ -105,7 +129,7 @@ export class RolesPage extends LightElement { body: JSON.stringify({ label: form.label.trim(), permission_group: form.permission_group, - attrs: form.attrs.trim() || null, + attrs: this._mergeAttrs(form.attrs, form.ui_mode), }), }); if (!res.ok) throw new Error(await res.text()); @@ -116,7 +140,7 @@ export class RolesPage extends LightElement { } async _delete(role) { - if (!confirm(`Delete role "${role.label}"?`)) return; + if (!confirm(t('roles.confirm.delete', { name: role.label }))) return; try { const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); @@ -133,7 +157,7 @@ export class RolesPage extends LightElement { _renderModal() { if (!this._modal) return nothing; const { mode, form, role } = this._modal; - const title = mode === 'create' ? 'New role' : `Edit ${role.label}`; + const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label }); return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> @@ -148,33 +172,41 @@ export class RolesPage extends LightElement { ${mode === 'create' ? html`
- - ${t('roles.form.id')} ${t('roles.form.id_hint')} + this._patch('id', e.target.value)} /> -
Lowercase, no spaces. Cannot be changed later.
+
${t('roles.form.id_desc')}
` : nothing}
- + this._patch('label', e.target.value)} />
- +
- - ${t('roles.form.interface')} + +
${unsafeHTML(t('roles.form.interface_hint'))}
+
+
+ + this._patch('attrs', e.target.value)} />
@@ -190,11 +222,11 @@ export class RolesPage extends LightElement { return html`
-

Roles

+

${t('roles.title')}

- ${roles.length} role${roles.length === 1 ? '' : 's'} + ${roles.length === 1 ? t('roles.count', { n: roles.length }) : t('roles.count_plural', { n: roles.length })}
@@ -204,15 +236,16 @@ export class RolesPage extends LightElement { ` : nothing}
- ${loading ? html`
Loading…
` : roles.length === 0 ? html` -

No roles.

+ ${loading ? html`
${t('roles.loading')}
` : roles.length === 0 ? html` +

${t('roles.empty')}

` : html`
ConnectorScopeTypeAuth
${t('catalog.table.connector')}${t('catalog.table.scope')}${t('catalog.table.type')}${t('catalog.table.auth')}
- ${r.scope === 'global' ? 'global' : 'per-user'} - ${r.source === 'local_script' ? 'local script' : 'remote'} ${r.auth_kind}
-
- - - + + + + @@ -224,14 +257,17 @@ export class RolesPage extends LightElement { + - - - - + + + + @@ -131,7 +139,7 @@ export class TicSessionsPage extends LightElement { @click=${() => this._fetch(cur - 1)}> - Page ${cur} of ${pages} — ${this._total} sessions + ${t('tic.pagination', { cur, pages, total: this._total })} ${this._renderTable()} diff --git a/web/components/topbar.js b/web/components/topbar.js index 3ff8c5c..a17ad09 100644 --- a/web/components/topbar.js +++ b/web/components/topbar.js @@ -1,7 +1,15 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t, I18nMixin } from '../lib/i18n.js'; -export class AppTopbar extends LightElement { +// Stable per-user avatar color: same user, same hue, everywhere. +function avatarColor(name) { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; + return `hsl(${h % 360}, 55%, 52%)`; +} + +export class AppTopbar extends I18nMixin(LightElement) { static properties = { _theme: { state: true }, _copilotCollapsed: { state: true }, @@ -65,23 +73,28 @@ export class AppTopbar extends LightElement { return name.charAt(0).toUpperCase(); } + get _avatarColor() { + const name = this._me?.username || ''; + return name ? avatarColor(name) : 'var(--accent)'; + } + render() { const isDark = this._theme === 'dark'; return html` - Skald + ${t('topbar.brand')} ${this._copilotCollapsed ? html` - ` : ''} -
- ${this._menuOpen ? html` @@ -91,10 +104,10 @@ export class AppTopbar extends LightElement {
@${this._me?.username || ''}
` : nothing} diff --git a/web/components/users-page.js b/web/components/users-page.js index cc21074..34ee365 100644 --- a/web/components/users-page.js +++ b/web/components/users-page.js @@ -1,5 +1,7 @@ -import { html, nothing } from 'lit'; -import { LightElement } from '../lib/base.js'; +import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; export class UsersPage extends LightElement { @@ -24,6 +26,8 @@ export class UsersPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'users'; this.style.display = this._open ? 'flex' : 'none'; @@ -31,6 +35,11 @@ export class UsersPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async _load() { this._error = null; try { @@ -81,7 +90,7 @@ export class UsersPage extends LightElement { this._error = null; if (mode === 'create') { - if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; return; } + if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; } try { const res = await fetch('/api/users', { method: 'POST', @@ -100,7 +109,7 @@ export class UsersPage extends LightElement { } catch (e) { this._error = e.message; } } else if (mode === 'edit') { const { user } = this._modal; - if (!form.username.trim()) { this._error = 'Username is required.'; return; } + if (!form.username.trim()) { this._error = t('users.error.required_username'); return; } try { const res = await fetch(`/api/users/${user.id}`, { method: 'PUT', @@ -118,7 +127,7 @@ export class UsersPage extends LightElement { } catch (e) { this._error = e.message; } } else if (mode === 'password') { const { user } = this._modal; - if (!form.password) { this._error = 'Password must not be empty.'; return; } + if (!form.password) { this._error = t('users.error.password_empty'); return; } try { const res = await fetch(`/api/users/${user.id}/password`, { method: 'POST', @@ -132,7 +141,7 @@ export class UsersPage extends LightElement { } async _delete(user) { - if (!confirm(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return; + if (!confirm(t('users.confirm.delete', { username: user.username }))) return; try { const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); @@ -149,9 +158,9 @@ export class UsersPage extends LightElement { _renderModal() { if (!this._modal) return nothing; const { mode, form, user } = this._modal; - const title = mode === 'create' ? 'New user' - : mode === 'edit' ? `Edit ${user.username}` - : `Reset password — ${user.username}`; + const title = mode === 'create' ? t('users.modal.create_title') + : mode === 'edit' ? t('users.modal.edit_title', { username: user.username }) + : t('users.modal.reset_title', { username: user.username }); return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> @@ -166,45 +175,43 @@ export class UsersPage extends LightElement { ${mode === 'create' ? html`
- + this._patch('username', e.target.value)} />
- + this._patch('display_name', e.target.value)} />
- +
- + this._patch('password', e.target.value)} />
this._patch('encrypted', e.target.checked)} /> - +
${form.encrypted ? html` -
- Warning: if the password is lost, the conversation history is permanently unrecoverable. -
+
${unsafeHTML(t('users.modal.encrypt_warn'))}
` : nothing} ` : mode === 'edit' ? html`
- + this._patch('username', e.target.value)} />
- + this._patch('display_name', e.target.value)} />
- + @@ -213,23 +220,22 @@ export class UsersPage extends LightElement { this._patch('active', e.target.checked)} /> - +
` : html`
- - Only works for cleartext (non-encrypted) users. + ${t('users.modal.only_cleartext')}
- + this._patch('password', e.target.value)} />
`}
@@ -245,11 +251,11 @@ export class UsersPage extends LightElement { return html`
-

Users

+

${t('users.title')}

- ${users.length} user${users.length === 1 ? '' : 's'} + ${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}
@@ -259,17 +265,17 @@ export class UsersPage extends LightElement { ` : nothing}
- ${loading ? html`
Loading…
` : users.length === 0 ? html` -

No users.

+ ${loading ? html`
${t('users.loading')}
` : users.length === 0 ? html` +

${t('users.empty')}

` : html`
IDLabelPermission group${t('roles.col.id')}${t('roles.col.label')}${t('roles.col.group')}${t('roles.col.interface')}
${r.id} ${r.label} ${this._groupLabel(r.permission_group)}${this._attrsUiMode(r.attrs) === 'simple' + ? html`${t('roles.badge.simple')}` + : html`${t('roles.badge.full')}`}
- - ${session.source} - agent: ${session.agent_id} - id: ${session.id} - ${session.is_ephemeral ? html`ephemeral` : nothing} - ${!session.is_interactive ? html`automated` : nothing} + ${t('session.agent')} ${session.agent_id} + ${t('session.id')} ${session.id} + ${session.is_ephemeral ? html`${t('session.ephemeral')}` : nothing} + ${!session.is_interactive ? html`${t('session.automated')}` : nothing} ${this._live - ? html`live` + ? html`${t('session.live')}` : nothing}
${formatDate(session.created_at)}
@@ -272,13 +282,13 @@ export class SessionDetailPage extends LightElement {
${item.is_synthetic - ? html`synthetic` + ? html`${t('session.synthetic')}` : nothing} - User + ${t('session.user_role')} ${time ? html`${time}` : nothing}
${item.content}
- ${item.failed ? html`
failed
` : nothing} + ${item.failed ? html`
${t('session.failed')}
` : nothing}
`; } @@ -291,14 +301,14 @@ export class SessionDetailPage extends LightElement { return html`
- Assistant + ${t('session.assistant_role')} ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> - Reasoning + ${t('session.reasoning_label')}
${expanded ? html`
${item.reasoning}
` : nothing} @@ -316,14 +326,14 @@ export class SessionDetailPage extends LightElement { return html`
- Thinking + ${t('session.thinking_role')} ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> - Reasoning + ${t('session.reasoning_label')}
${expanded ? html`
${item.reasoning}
` : nothing} @@ -351,10 +361,10 @@ export class SessionDetailPage extends LightElement { ${item.label_full && item.label_full !== item.label_short ? html`
${item.label_full}
` : nothing} - +
${jsonPretty(item.arguments)}
${
               item.result ?? item.error ?? '—'
@@ -369,14 +379,14 @@ export class SessionDetailPage extends LightElement {
     return html`
       
- Sub-agent: ${item.agent_id} - depth ${item.depth} + ${t('session.sub_agent')} ${item.agent_id} + ${t('session.depth', { n: item.depth })}
`; } _renderAgentFrameEnd(item) { - return html`
end of ${item.agent_id}
`; + return html`
${t('session.end_of')} ${item.agent_id}
`; } _renderMessage(item, idx) { @@ -576,18 +586,18 @@ export class SessionDetailPage extends LightElement {
${this._loading ? html`
-
Loading session… +
${t('session.loading')}
` : this._error ? html`
${this._error}
` : !this._data ? html` -
No session loaded.
- Navigate to #session/{id} to view a session. +
${t('session.no_session')}
+ ${unsafeHTML(t('session.no_session_hint'))}
` : html` ${this._renderSessionHeader(this._data.session)} ${this._data.messages.length === 0 - ? html`
No messages in this session.
` + ? html`
${t('session.empty')}
` : this._data.messages.map((m, i) => this._renderMessage(m, i)) } `} diff --git a/web/components/setup-page.js b/web/components/setup-page.js index 17dd0d5..44f979a 100644 --- a/web/components/setup-page.js +++ b/web/components/setup-page.js @@ -1,7 +1,8 @@ import { html } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t, I18nMixin, LOCALES, getLocale, setLocale } from '../lib/i18n.js'; -export class SetupPage extends LightElement { +export class SetupPage extends I18nMixin(LightElement) { static get properties() { return { @@ -9,6 +10,7 @@ export class SetupPage extends LightElement { _password: { state: true }, _confirm: { state: true }, _encrypted: { state: true }, + _locale: { state: true }, _error: { state: true }, _busy: { state: true }, }; @@ -20,6 +22,7 @@ export class SetupPage extends LightElement { this._password = ''; this._confirm = ''; this._encrypted = true; + this._locale = getLocale(); this._error = null; this._busy = false; } @@ -31,15 +34,15 @@ export class SetupPage extends LightElement { this._error = null; if (!this._username.trim()) { - this._error = 'Choose a username.'; + this._error = t('setup.username'); return; } if (this._password.length < 4) { - this._error = 'Password must be at least 4 characters.'; + this._error = t('setup.pw.short'); return; } if (this._password !== this._confirm) { - this._error = 'The two passwords do not match.'; + this._error = t('setup.pw.mismatch'); return; } @@ -56,6 +59,7 @@ export class SetupPage extends LightElement { username: this._username.trim(), password: this._password, encrypted: this._encrypted, + locale: this._locale, }), }); if (!res.ok) { @@ -66,7 +70,7 @@ export class SetupPage extends LightElement { // First user created — reload into the app. window.location.reload(); } catch { - this._error = 'Network error — please try again.'; + this._error = t('setup.network'); } finally { this._busy = false; } @@ -74,8 +78,8 @@ export class SetupPage extends LightElement { render() { const btnLabel = this._busy - ? html`Creating…` - : 'Create account'; + ? html`${t('setup.creating')}` + : t('setup.submit'); return html`
@@ -83,13 +87,13 @@ export class SetupPage extends LightElement { -

Welcome to Skald

-

Create the admin account to get started.

+

${t('setup.title')}

+

${t('setup.subtitle')}

${this._error ? html`
${this._error}
` : null}
- +
- +
- +
+
+ + +
+
this._encrypted = e.target.checked} ?disabled=${this._busy} />
${this._encrypted ? html`
- Warning: your password derives the encryption key. - If you forget it, your entire conversation history will be - permanently lost — there is no recovery. + ${t('setup.warn.strong')} ${t('setup.warn')}
` : null} diff --git a/web/components/shared-folders.js b/web/components/shared-folders.js new file mode 100644 index 0000000..a35faa3 --- /dev/null +++ b/web/components/shared-folders.js @@ -0,0 +1,356 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; + +// Shared on-disk folders (blueprint §6). Admin-only surface: create a folder, +// describe what it holds (the description is fed to the assistant's system +// context), and grant members read-only or read-write access. There is no owner — +// a folder is just a name + a membership list (contrast: Projects, which will have +// an owner). Renaming is intentionally not offered (it would remount + move the +// on-disk directory). Reuses the `um-*` (users/roles) and `connector-card` styles. + +async function jf(url, opts) { + const res = await fetch(url, opts); + if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : null; +} + +export class SharedFoldersPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _folders: { state: true }, // [{ id, folder_name, description, members:[{user_id,can_write}] }] + _users: { state: true }, // /api/users — for the member picker + labels + _error: { state: true }, + _modal: { state: true }, // null | { mode:'create'|'edit', folder?, form:{folder_name,description} } + _add: { state: true }, // { [folderId]: { user_id, can_write } } — in-progress add-row + }; + } + + constructor() { + super(); + this._open = false; + this._folders = null; + this._users = null; + this._error = null; + this._modal = null; + this._add = {}; + } + + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'shared-folders'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + + async _load() { + this._error = null; + try { + const [folders, users] = await Promise.all([ + jf('/api/shared-folders'), + jf('/api/users'), + ]); + this._folders = folders; + this._users = users; + } catch (e) { + this._error = e.message; + this._folders = this._folders ?? []; + } + } + + _userLabel(id) { + const u = (this._users ?? []).find(x => x.id === id); + return u ? (u.display_name || u.username) : id; + } + + // Users not yet members of this folder (and active) — the add-picker's options. + _candidates(folder) { + const members = new Set(folder.members.map(m => m.user_id)); + return (this._users ?? []).filter(u => u.active && !members.has(u.id)); + } + + // ── create / edit-description modal ────────────────────────────────────────── + + _openCreate() { + this._modal = { mode: 'create', form: { folder_name: '', description: '' } }; + this._error = null; + } + + _openEditDesc(folder) { + this._modal = { mode: 'edit', folder, form: { folder_name: folder.folder_name, description: folder.description } }; + this._error = null; + } + + _closeModal() { this._modal = null; this._error = null; } + + _patch(field, value) { + this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; + } + + async _save() { + const { mode, form, folder } = this._modal; + this._error = null; + try { + if (mode === 'create') { + if (!form.folder_name.trim()) { this._error = t('sf.error.name'); return; } + await jf('/api/shared-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_name: form.folder_name.trim(), description: form.description.trim() }), + }); + } else { + await jf(`/api/shared-folders/${folder.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: form.description.trim() }), + }); + } + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _delete(folder) { + if (!confirm(t('sf.confirm.delete', { name: folder.folder_name }))) return; + this._error = null; + try { + await jf(`/api/shared-folders/${folder.id}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── membership ─────────────────────────────────────────────────────────────── + + _draft(folderId) { return this._add[folderId] ?? { user_id: '', can_write: false }; } + + _setAdd(folderId, patch) { + this._add = { ...this._add, [folderId]: { ...this._draft(folderId), ...patch } }; + } + + async _addMember(folder) { + const draft = this._draft(folder.id); + if (!draft.user_id) return; + this._error = null; + try { + await jf(`/api/shared-folders/${folder.id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: draft.user_id, can_write: !!draft.can_write }), + }); + this._add = { ...this._add, [folder.id]: { user_id: '', can_write: false } }; + await this._load(); + } catch (e) { this._error = e.message; } + } + + // Re-grant with a new capability — the POST upserts on (folder, user). + async _setAccess(folder, userId, canWrite) { + this._error = null; + try { + await jf(`/api/shared-folders/${folder.id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, can_write: canWrite }), + }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _removeMember(folder, userId) { + if (!confirm(t('sf.confirm.remove_member', { name: this._userLabel(userId), folder: folder.folder_name }))) return; + this._error = null; + try { + await jf(`/api/shared-folders/${folder.id}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── render ─────────────────────────────────────────────────────────────────── + + render() { + if (!this._open) return nothing; + const folders = this._folders ?? []; + const loading = this._folders === null; + + return html` +
+
+

${t('sf.title')}

+
+ + ${folders.length === 1 ? t('sf.count', { n: folders.length }) : t('sf.count_plural', { n: folders.length })} + + +
+
+ + ${this._error && !this._modal ? html` +
${this._error}
` : nothing} + +
+
+ ${t('sf.note.propagation')} +
+ ${loading + ? html`
${t('sf.loading')}
` + : folders.length === 0 + ? html` +
+ +

${t('sf.empty')}

+

${t('sf.empty_hint')}

+
` + : html`
${folders.map(f => this._renderFolder(f))}
`} +
+ + ${this._renderModal()} +
`; + } + + _renderFolder(f) { + const draft = this._draft(f.id); + const candidates = this._candidates(f); + + return html` +
+
+
+
+ ${f.folder_name} +
+ shared/${f.folder_name} +
+
+ + +
+
+ +
+ ${f.description + ? html`${f.description}` + : html`${t('sf.no_desc')}`} +
+ +
+
+ ${t('sf.members')} +
+ + ${f.members.length === 0 + ? html`
${t('sf.no_members')}
` + : html`
${f.members.map(m => this._renderMember(f, m))}
`} + + ${candidates.length > 0 ? html` +
+ + + +
` + : html`
${t('sf.all_added')}
`} +
+
`; + } + + _renderMember(f, m) { + return html` +
+
+ ${this._userLabel(m.user_id)} +
+
+
+ + +
+ +
+
`; + } + + _renderModal() { + if (!this._modal) return nothing; + const { mode, form, folder } = this._modal; + const title = mode === 'create' ? t('sf.form.new') : t('sf.form.edit', { name: folder.folder_name }); + + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> +
+
+ + ${title} + +
+
+ ${this._error ? html`
${this._error}
` : nothing} + + ${mode === 'create' ? html` +
+ + this._patch('folder_name', e.target.value)} /> +
${t('sf.form.name_desc')}
+
+ ` : html` +
+ +
shared/${folder.folder_name}
+
+ `} + +
+ + +
${t('sf.form.desc_desc')}
+
+
+ +
+
`; + } +} diff --git a/web/components/shared/chat-page.js b/web/components/shared/chat-page.js index dc1af49..ac372ec 100644 --- a/web/components/shared/chat-page.js +++ b/web/components/shared/chat-page.js @@ -1,5 +1,6 @@ import { html, nothing } from 'lit'; import { ChatSession } from '../../lib/chat-session.js'; +import { t } from '../../lib/i18n.js'; import { renderMsg, renderAttachmentChips } from '../copilot-render.js'; export class ChatPage extends ChatSession { @@ -105,17 +106,17 @@ export class ChatPage extends ChatSession {
${this._inProject ? html` - - ${this.label || 'Project'} - ` : html` Chat`} + ${this.label || t('chat.mobile.project')} + ` : html` ${t('chat.mobile.chat')}`}
@@ -125,14 +126,14 @@ export class ChatPage extends ChatSession { ${this._messages.length === 0 ? html`
-

Ask me anything

+

${t('chat.mobile.ask')}

` : this._messages.map(m => renderMsg(this, m))} ${this._waiting ? html`
- Thinking… + ${t('chat.thinking')}
` : nothing}
@@ -152,7 +153,7 @@ export class ChatPage extends ChatSession { @@ -160,7 +161,7 @@ export class ChatPage extends ChatSession {
${this._providers.length > 1 ? html` @@ -179,7 +180,7 @@ export class ChatPage extends ChatSession { ${this._hasTranscribe ? html` ` : nothing}
diff --git a/web/components/shared/connector-common.js b/web/components/shared/connector-common.js index 6701c09..d873cdd 100644 --- a/web/components/shared/connector-common.js +++ b/web/components/shared/connector-common.js @@ -1,3 +1,5 @@ +import { t } from '../../lib/i18n.js'; + // Shared vocabulary for the Connectors list and a connector's own page. // // Both surfaces have to answer "what state is this connector in?" and both draw the @@ -13,14 +15,25 @@ export function connectorIconUrl(name, size = 'sm') { /// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents /// in `web/css/connectors.css`. export const STATUS_LABEL = { - active: { text: 'active', tone: 'ok' }, - pending: { text: 'needs fix', tone: 'script' }, - needs_login: { text: 'needs sign-in', tone: 'script' }, - enabled: { text: 'enabled', tone: 'scope' }, - off: { text: 'off', tone: '' }, - available: { text: 'available', tone: '' }, + active: { tone: 'ok' }, + pending: { tone: 'script' }, + needs_login: { tone: 'script' }, + enabled: { tone: 'scope' }, + off: { tone: '' }, + available: { tone: '' }, }; +export function statusText(status) { + return { + active: t('connectors.status.active'), + pending: t('connectors.status.needs_fix'), + needs_login: t('connectors.status.needs_signin'), + enabled: t('connectors.status.enabled'), + off: t('connectors.status.off'), + available: t('connectors.status.available'), + }[status] ?? status; +} + /// The one place that decides what a connector's state *is*, from whichever runtime /// rows exist for it. /// diff --git a/web/components/shared/file-viewer-base.js b/web/components/shared/file-viewer-base.js index 00f8429..a2edb39 100644 --- a/web/components/shared/file-viewer-base.js +++ b/web/components/shared/file-viewer-base.js @@ -2,6 +2,7 @@ import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement, renderMarkdown } from '../../lib/base.js'; import { fileWatcher } from '../../lib/file-watcher.js'; +import { t } from '../../lib/i18n.js'; /** * Shared file-viewer engine. Holds all of the fetch / kind-detection / @@ -346,7 +347,7 @@ export class FileViewerBase extends LightElement { const showingSource = this._htmlMode === 'source'; return html``; @@ -389,7 +390,7 @@ export class FileViewerBase extends LightElement { if (this._kind === 'binary') { return html`
- Preview not available for this file type. + ${t('fv.binary_unavailable')}
`; } if (this._kind === 'html') { @@ -417,7 +418,7 @@ export class FileViewerBase extends LightElement { return html` ${this._compileError ? html`
-  LaTeX compilation failed — showing source instead +  ${t('fv.latex_failed')}
${this._compileError}
` : nothing} diff --git a/web/components/shared/file-viewer-mobile.js b/web/components/shared/file-viewer-mobile.js index 52ade77..0884459 100644 --- a/web/components/shared/file-viewer-mobile.js +++ b/web/components/shared/file-viewer-mobile.js @@ -1,4 +1,5 @@ import { html, nothing } from 'lit'; +import { t } from '../../lib/i18n.js'; import { FileViewerBase } from './file-viewer-base.js'; /** @@ -43,14 +44,14 @@ export class MobileFileViewerPage extends FileViewerBase {
- ${this._basename()} ${this._renderModeToggle('chat-page-back')} - diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 92a4a68..5a92c04 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -1,8 +1,9 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t, I18nMixin } from '../lib/i18n.js'; -export class AppSidebar extends LightElement { +export class AppSidebar extends I18nMixin(LightElement) { static properties = { _activePage: { state: true }, _tasksSection: { state: true }, @@ -120,7 +121,7 @@ export class AppSidebar extends LightElement { const match = hash.match(/^([^/?]+)/); const segment = match ? match[1] : ''; // `connector` (singular) is the per-connector detail page, `connectors` the list. - return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; + return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; } _tasksSectionFromHash() { @@ -183,7 +184,7 @@ export class AppSidebar extends LightElement { class="sidebar-link ${active ? 'active' : ''}" @click=${(e) => this._openTaskManager(e)}> - Task Manager + ${t('nav.tasks')} ${active ? html` @@ -191,22 +192,22 @@ export class AppSidebar extends LightElement { this._navigateTasksSection('running', e)}> - Running Tasks + ${t('nav.tasks.running')} this._navigateTasksSection('cron', e)}> - Cron Jobs + ${t('nav.tasks.cron')} this._navigateTasksSection('scheduled', e)}> - Scheduled Tasks + ${t('nav.tasks.scheduled')} this._navigateTasksSection('history', e)}> - History + ${t('nav.tasks.history')}
` : nothing} @@ -223,7 +224,7 @@ export class AppSidebar extends LightElement { ${p.name} @@ -234,10 +235,14 @@ export class AppSidebar extends LightElement { } render() { + // Simplified interface (role attrs `ui_mode: "simple"`): chat + inbox only. + // Hiding links is not access control — every route stays capability-gated + // server-side; this only shapes the navigation for less technical members. + const simple = this._me?.ui_mode === 'simple'; return html` @@ -245,26 +250,34 @@ export class AppSidebar extends LightElement { `; diff --git a/web/components/tasks/cron.js b/web/components/tasks/cron.js index f731b9e..13a2c17 100644 --- a/web/components/tasks/cron.js +++ b/web/components/tasks/cron.js @@ -1,7 +1,9 @@ -import { html, nothing } from 'lit'; -import { LightElement } from '../../lib/base.js'; +import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { LightElement } from '../../lib/base.js'; import { toString as cronToString } from 'cronstrue'; import { formatDate } from './utils.js'; +import { t } from '../../lib/i18n.js'; export class CronJobsSection extends LightElement { static properties = { @@ -15,6 +17,17 @@ export class CronJobsSection extends LightElement { this._error = null; } + connectedCallback() { + super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async load() { this._error = null; try { @@ -28,7 +41,7 @@ export class CronJobsSection extends LightElement { } async _delete(job) { - if (!confirm(`Delete job "${job.title}"?`)) return; + if (!confirm(t('cron.confirm.delete', { title: job.title }))) return; try { const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); @@ -50,10 +63,10 @@ export class CronJobsSection extends LightElement { _statusBadge(job) { if (job.running_session_id != null) - return html`running`; + return html`${t('cron.badge.running')}`; if (!job.enabled) - return html`disabled`; - return html`idle`; + return html`${t('cron.badge.disabled')}`; + return html`${t('cron.badge.idle')}`; } _renderCard(job) { @@ -64,7 +77,7 @@ export class CronJobsSection extends LightElement { ${job.title} ${this._statusBadge(job)}
-
@@ -81,15 +94,15 @@ export class CronJobsSection extends LightElement {
- Agent + ${t('cron.card.label_agent')} ${job.agent_id}
- Last run + ${t('cron.card.label_last_run')} ${formatDate(job.last_run_at)}
- Next run + ${t('cron.card.label_next_run')} ${formatDate(job.next_run_at)}
@@ -99,7 +112,7 @@ export class CronJobsSection extends LightElement { this._toggle(job)} /> - ${job.enabled ? 'Enabled' : 'Disabled'} + ${job.enabled ? t('cron.card.enabled') : t('cron.card.disabled')}
@@ -110,9 +123,9 @@ export class CronJobsSection extends LightElement { return html`
-

Cron Jobs

+

${t('cron.title')}

- ${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''} + ${t(this._jobs.length === 1 ? 'cron.count_one' : 'cron.count_other', { n: this._jobs.length })}
@@ -123,7 +136,7 @@ export class CronJobsSection extends LightElement { ${this._jobs.length === 0 ? html`
-

No recurring cron jobs. Ask the agent to create one with execute_task.

+

${t('cron.empty.title')} ${unsafeHTML(t('cron.empty.hint'))}

` : html`
diff --git a/web/components/tic-sessions.js b/web/components/tic-sessions.js index b7bff03..60671b9 100644 --- a/web/components/tic-sessions.js +++ b/web/components/tic-sessions.js @@ -1,5 +1,6 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; +import { t } from '../lib/i18n.js'; const PAGE_ID = 'tic'; const PER_PAGE = 20; @@ -42,6 +43,8 @@ export class TicSessionsPage extends LightElement { connectedCallback() { super.connectedCallback(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; @@ -49,6 +52,11 @@ export class TicSessionsPage extends LightElement { }); } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback(); + } + async _fetch(page) { this._loading = true; this._error = null; @@ -77,7 +85,7 @@ export class TicSessionsPage extends LightElement { if (this._loading) return html`
- Loading… + ${t('tic.loading')}
`; if (this._error) return html` @@ -89,7 +97,7 @@ export class TicSessionsPage extends LightElement { if (this._items.length === 0) return html`
- No TIC sessions found. + ${t('tic.empty')}
`; @@ -99,10 +107,10 @@ export class TicSessionsPage extends LightElement {
#AgentStartedMessagesLast activity${t('tic.table.agent')}${t('tic.table.started')}${t('tic.table.messages')}${t('tic.table.last_activity')}
- - - - - + + + + + @@ -280,20 +286,20 @@ export class UsersPage extends LightElement { + ? html`${t('users.badge.encrypted')}` + : html`${t('users.badge.cleartext')}`} + ? html`${t('users.badge.active')}` + : html`${t('users.badge.inactive')}`}
UsernameDisplay nameRoleDBStatus${t('users.table.username')}${t('users.table.display_name')}${t('users.table.role')}${t('users.table.db')}${t('users.table.status')}
${u.display_name ?? '—'} ${this._roleLabel(u.role_id)} ${u.encrypted - ? html`Encrypted` - : html`Cleartext`}${u.active - ? html`Active` - : html`Inactive`}
- - -
diff --git a/web/css/copilot-input.css b/web/css/copilot-input.css index 6290072..44953d7 100644 --- a/web/css/copilot-input.css +++ b/web/css/copilot-input.css @@ -17,8 +17,8 @@ } .copilot-composer:focus-within { - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); } .copilot-textarea { @@ -26,9 +26,9 @@ border: none; outline: none; background: transparent; - font-size: 0.85rem; - line-height: 1.55; - padding: 0.6rem 0.75rem 0.4rem; + font-size: 0.95rem; + line-height: 1.6; + padding: 0.65rem 0.85rem 0.45rem; min-height: 2.6rem; max-height: 14rem; overflow-y: auto; @@ -59,11 +59,11 @@ display: flex; align-items: center; justify-content: center; - width: 1.8rem; - height: 1.8rem; + width: 2rem; + height: 2rem; padding: 0; border: none; - border-radius: 0.4rem; + border-radius: 0.5rem; background: transparent; color: var(--placeholder-color); font-size: 0.85rem; @@ -99,14 +99,14 @@ .copilot-model-pill:hover, .copilot-model-pill.open { - background: rgba(99, 102, 241, 0.08); - border-color: rgba(99, 102, 241, 0.3); - color: #6366f1; + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.3); + color: var(--accent); } .copilot-model-pill i:first-child { font-size: 0.75rem; - color: #6366f1; + color: var(--accent); } .copilot-model-overlay { @@ -141,8 +141,8 @@ transition: background 0.1s; } -.copilot-model-item:hover { background: rgba(99, 102, 241, 0.07); } -.copilot-model-item.active { color: #6366f1; font-weight: 600; } +.copilot-model-item:hover { background: rgba(var(--accent-rgb), 0.07); } +.copilot-model-item.active { color: var(--accent); font-weight: 600; } /* ── Slash-command autocomplete ─────────────────────────────────────────────── */ @@ -177,9 +177,9 @@ } .copilot-cmd-item:hover, -.copilot-cmd-item.active { background: rgba(99, 102, 241, 0.1); } +.copilot-cmd-item.active { background: rgba(var(--accent-rgb), 0.1); } -.copilot-cmd-name { font-weight: 600; color: #6366f1; white-space: nowrap; } +.copilot-cmd-name { font-weight: 600; color: var(--accent); white-space: nowrap; } .copilot-cmd-desc { color: var(--text-muted, #888); @@ -199,7 +199,7 @@ padding: 0; border: none; border-radius: 0.45rem; - background: #6366f1; + background: var(--accent); color: #fff; font-size: 0.8rem; cursor: pointer; @@ -207,7 +207,7 @@ flex-shrink: 0; } -.copilot-send-btn:hover { background: #4f46e5; } +.copilot-send-btn:hover { background: var(--accent-hover); } .copilot-send-btn--stop { background: #dc2626; } .copilot-send-btn--stop:hover { background: #b91c1c; } .copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; } diff --git a/web/css/copilot-messages.css b/web/css/copilot-messages.css index 1a1f8cc..18073d9 100644 --- a/web/css/copilot-messages.css +++ b/web/css/copilot-messages.css @@ -3,19 +3,19 @@ .copilot-messages { flex: 1; overflow-y: auto; - padding: 1rem; + padding: 1.25rem; display: flex; flex-direction: column; - gap: 0.65rem; + gap: 0.7rem; } /* ── Message bubbles ───────────────────────────────────────────────────────── */ .copilot-msg { - padding: 0.6rem 0.9rem; - border-radius: 0.75rem; - font-size: 0.85rem; - line-height: 1.55; + padding: 0.65rem 1rem; + border-radius: 1rem; + font-size: 0.95rem; + line-height: 1.6; max-width: 88%; } @@ -159,17 +159,17 @@ .copilot-tool-path { font-family: var(--bs-font-monospace); font-size: inherit; - color: #6366f1; + color: var(--accent); cursor: pointer; border-radius: 0.2rem; padding: 0 0.25em; - background: rgba(99,102,241,0.10); + background: rgba(var(--accent-rgb), 0.10); text-decoration: none; } .copilot-tool-path:hover { text-decoration: underline; - background: rgba(99,102,241,0.18); + background: rgba(var(--accent-rgb), 0.18); } .copilot-tool-body { @@ -285,7 +285,7 @@ .copilot-markdown pre code { background: none; padding: 0; font-size: inherit; } .copilot-markdown blockquote { - border-left: 3px solid #6366f1; + border-left: 3px solid var(--accent); margin: 0.5rem 0; padding: 0.3rem 0.75rem; color: var(--placeholder-color); @@ -298,7 +298,7 @@ margin: 0.6rem 0; } -.copilot-markdown a { color: #6366f1; text-decoration: underline; } +.copilot-markdown a { color: var(--accent); text-decoration: underline; } .copilot-markdown strong { font-weight: 700; } .copilot-markdown em { font-style: italic; } @@ -343,7 +343,7 @@ .copilot-approval-path { font-family: var(--bs-font-monospace); font-size: 0.75rem; - color: #6366f1; + color: var(--accent); } .copilot-approval-actions { @@ -450,7 +450,7 @@ .copilot-agent, .copilot-agent-end { - border: 1px solid rgba(99, 102, 241, 0.2); + border: 1px solid rgba(var(--accent-rgb), 0.2); border-radius: 0.5rem; font-size: 0.78rem; overflow: clip; @@ -462,18 +462,18 @@ align-items: center; gap: 0.45rem; padding: 0.35rem 0.65rem; - background: rgba(99, 102, 241, 0.12); + background: rgba(var(--accent-rgb), 0.12); color: var(--msg-assistant-text); } .copilot-agent-header i { font-size: 0.85rem; flex-shrink: 0; - color: #6366f1; + color: var(--accent); } .copilot-agent-header strong { - color: #6366f1; + color: var(--accent); font-weight: 600; } @@ -505,10 +505,10 @@ margin: 0; padding: 0.45rem 0.65rem; color: var(--placeholder-color); - border-top: 1px solid rgba(99, 102, 241, 0.15); + border-top: 1px solid rgba(var(--accent-rgb), 0.15); max-height: 120px; overflow-y: auto; - background: rgba(99, 102, 241, 0.05); + background: rgba(var(--accent-rgb), 0.05); } .copilot-agent-preview--result { @@ -519,7 +519,7 @@ @media (prefers-color-scheme: dark) { .copilot-agent-badge.running { background: rgba(234, 179, 8, 0.2); color: #fbbf24; } .copilot-agent-badge.done { background: rgba(22, 163, 74, 0.18); color: #4ade80; } - .copilot-agent-header { background: rgba(99, 102, 241, 0.1); } + .copilot-agent-header { background: rgba(var(--accent-rgb), 0.1); } } /* ── Attachment chips (composer pending + sent user bubble) ──────────────────── */ @@ -550,7 +550,7 @@ } .attach-chip--clickable { cursor: pointer; } -.attach-chip--clickable:hover { border-color: #6366f1; } +.attach-chip--clickable:hover { border-color: var(--accent); } .attach-chip--uploading { opacity: 0.7; } .attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; } diff --git a/web/css/copilot.css b/web/css/copilot.css index 4f3f20f..9e5e9a7 100644 --- a/web/css/copilot.css +++ b/web/css/copilot.css @@ -11,13 +11,126 @@ app-copilot { flex-shrink: 0; } -app-copilot.collapsed { +app-copilot.collapsed:not([mode="full"]) { width: 0; min-width: 0; border: none; overflow: hidden; } +/* ── Full mode (home route): the chat fills the workspace ──────────────────── */ + +app-copilot[mode="full"] { + flex: 1; + width: auto; + min-width: 0; + border-left: none; +} + +/* Center the conversation in a readable column on wide screens. */ +app-copilot[mode="full"] .copilot-header, +app-copilot[mode="full"] .copilot-tabs, +app-copilot[mode="full"] .copilot-messages, +app-copilot[mode="full"] .copilot-input-area { + padding-left: max(1.25rem, calc((100% - 860px) / 2)); + padding-right: max(1.25rem, calc((100% - 860px) / 2)); +} + +app-copilot[mode="full"] .copilot-header { + font-size: 1rem; + padding-top: 0.9rem; + padding-bottom: 0.9rem; +} + +app-copilot[mode="full"] .copilot-msg { + max-width: 80%; +} + +/* ── Welcome hero (empty state, full mode) ─────────────────────────────────── */ + +.chat-hero { + margin: auto; + text-align: center; + max-width: 560px; + padding: 2rem 1rem; +} + +.chat-hero-logo { + width: 88px; + height: 88px; + border-radius: 24px; + box-shadow: var(--card-shadow); +} + +.chat-hero-title { + font-size: 1.6rem; + font-weight: 700; + margin: 1.1rem 0 0.3rem; +} + +.chat-hero-sub { + color: var(--placeholder-color); + font-size: 1rem; + margin: 0 0 1.75rem; +} + +.chat-suggestions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + text-align: left; +} + +.chat-suggestion { + display: flex; + align-items: center; + gap: 0.6rem; + border: 1px solid var(--card-border); + background: var(--card-bg); + color: var(--msg-assistant-text); + border-radius: var(--radius-md); + padding: 0.75rem 0.95rem; + font-size: 0.92rem; + cursor: pointer; + transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s; +} + +.chat-suggestion i { + color: var(--accent); + font-size: 1rem; + flex-shrink: 0; +} + +.chat-suggestion:hover { + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: var(--card-shadow); +} + +@media (max-width: 560px) { + .chat-suggestions { grid-template-columns: 1fr; } +} + +/* ── Privacy chip (chat header) ────────────────────────────────────────────── */ + +.chat-privacy { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin-left: 0.5rem; + font-size: 0.72rem; + font-weight: 600; + padding: 0.18rem 0.6rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + cursor: default; +} + +.chat-privacy i { + font-size: 0.68rem; +} + .copilot-resize-handle { position: absolute; left: 0; @@ -31,7 +144,7 @@ app-copilot.collapsed { .copilot-resize-handle:hover, .copilot-resize-handle:active { - background: rgba(99, 102, 241, 0.35); + background: rgba(var(--accent-rgb), 0.35); } .copilot-expand-btn { @@ -43,7 +156,7 @@ app-copilot.collapsed { background: transparent; border: none; cursor: pointer; - color: #6366f1; + color: var(--accent); font-size: 1.1rem; writing-mode: vertical-rl; padding: 1rem 0; @@ -72,7 +185,7 @@ app-copilot.collapsed { .copilot-header i { font-size: 1rem; - color: #6366f1; + color: var(--accent); } /* ── Tabs (General + project chats) ─────────────────────────────────────────── */ @@ -105,7 +218,7 @@ app-copilot.collapsed { .copilot-tab--active { color: var(--bs-body-color, inherit); - border-bottom-color: #6366f1; + border-bottom-color: var(--accent); font-weight: 600; } diff --git a/web/css/file-viewer.css b/web/css/file-viewer.css index 916a4f0..2b61564 100644 --- a/web/css/file-viewer.css +++ b/web/css/file-viewer.css @@ -102,7 +102,7 @@ } .fv-md hr { border: none; border-top: 1px solid var(--bs-border-color); margin: 1.25rem 0; } -.fv-md a { color: #6366f1; text-decoration: underline; } +.fv-md a { color: var(--accent); text-decoration: underline; } .fv-md strong { font-weight: 700; } .fv-md em { font-style: italic; } diff --git a/web/css/home.css b/web/css/home.css index f2aae1a..d450cec 100644 --- a/web/css/home.css +++ b/web/css/home.css @@ -1,6 +1,6 @@ -/* ── Home page ─────────────────────────────────────────────────────────────── */ +/* ── Dashboard page ────────────────────────────────────────────────────────── */ -home-page { +dashboard-page { display: none; flex-direction: column; flex: 1; @@ -13,34 +13,6 @@ home-page { box-sizing: border-box; } -/* ── Debug toggle ──────────────────────────────────────────────────────────── */ - -.home-debug-bar { - display: flex; - justify-content: flex-end; - margin-bottom: 0.75rem; -} - -.home-debug-toggle { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.78rem; - font-weight: 500; - color: var(--bs-secondary-color); - cursor: pointer; - user-select: none; - padding: 4px 8px; - border-radius: 6px; - transition: background 0.15s; -} - -.home-debug-toggle:hover { - background: var(--bs-tertiary-bg); -} - -.home-debug-toggle i { font-size: 0.82rem; } - /* ── Hero ──────────────────────────────────────────────────────────────────── */ .home-hero { diff --git a/web/css/mobile.css b/web/css/mobile.css index c9cea05..8dd143b 100644 --- a/web/css/mobile.css +++ b/web/css/mobile.css @@ -371,7 +371,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; } .chat-page-composer:focus-within { border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); } .chat-page-textarea { @@ -428,7 +428,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; } .chat-page-model-pill:focus, .chat-page-model-pill:hover { - border-color: rgba(99, 102, 241, 0.3); + border-color: rgba(var(--accent-rgb), 0.3); } /* ── Mic + send buttons ────────────────────────────────────────────────────── */ @@ -559,8 +559,8 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; } justify-content: center; font-size: 1.3rem; color: #fff; - background: linear-gradient(135deg, var(--accent, #6366f1), var(--accent-hover, #4f46e5)); - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); + background: linear-gradient(135deg, var(--accent, var(--accent)), var(--accent-hover, var(--accent-hover))); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35); } .project-card-main { diff --git a/web/css/models-llm.css b/web/css/models-llm.css index 74d8019..f2bc8a3 100644 --- a/web/css/models-llm.css +++ b/web/css/models-llm.css @@ -164,7 +164,7 @@ padding: 0.15em 0.5em; border-radius: 4px; background: var(--bs-primary-bg-subtle, #eef2ff); - color: var(--bs-primary, #4f46e5); + color: var(--bs-primary, var(--accent-hover)); flex-shrink: 0; white-space: nowrap; } @@ -261,7 +261,7 @@ } .llm-params-pill { - background: #6366f1 !important; + background: var(--accent) !important; color: #fff !important; } diff --git a/web/css/page-shell.css b/web/css/page-shell.css index ae5ee47..9273f3f 100644 --- a/web/css/page-shell.css +++ b/web/css/page-shell.css @@ -73,6 +73,7 @@ file-viewer-page { users-page, roles-page, +shared-folders-page, connectors-page, connector-detail-page, marketplace-page, diff --git a/web/css/sidebar.css b/web/css/sidebar.css index 8f16217..0914a7d 100644 --- a/web/css/sidebar.css +++ b/web/css/sidebar.css @@ -17,8 +17,8 @@ app-sidebar { gap: 0.55rem; padding: 0 1rem 1.25rem; color: var(--sidebar-brand-color); - font-size: 0.95rem; - font-weight: 600; + font-size: 1.05rem; + font-weight: 700; letter-spacing: 0.01em; } @@ -89,11 +89,11 @@ app-sidebar { display: flex; align-items: center; gap: 0.6rem; - padding: 0.20rem 0.30rem; + padding: 0.4rem 0.55rem; color: var(--sidebar-text); text-decoration: none; - font-size: 0.875rem; - border-radius: 0.25rem; + font-size: 0.92rem; + border-radius: 0.5rem; transition: background 0.12s, color 0.12s; } @@ -119,8 +119,8 @@ app-sidebar { .sidebar-link.active { background: var(--sidebar-active-bg); color: var(--sidebar-text-active); - font-weight: 500; - border-radius: 0.25rem; + font-weight: 600; + border-radius: 0.5rem; } .sidebar-link.active i { @@ -183,11 +183,11 @@ app-sidebar { display: flex; align-items: center; gap: 0.55rem; - padding: 0.18rem 0.40rem; + padding: 0.32rem 0.55rem; color: var(--sidebar-text); text-decoration: none; - font-size: 0.84rem; - border-radius: 0.25rem; + font-size: 0.86rem; + border-radius: 0.5rem; transition: background 0.12s, color 0.12s; } diff --git a/web/css/tasks/base.css b/web/css/tasks/base.css index 8f5bddd..a7b2a72 100644 --- a/web/css/tasks/base.css +++ b/web/css/tasks/base.css @@ -130,13 +130,13 @@ } .task-badge--cron { - background: rgba(99, 102, 241, 0.12); - color: var(--bs-primary, #6366f1); + background: rgba(var(--accent-rgb), 0.12); + color: var(--bs-primary, var(--accent)); } .task-badge--sync { - background: rgba(99, 102, 241, 0.12); - color: var(--bs-primary, #6366f1); + background: rgba(var(--accent-rgb), 0.12); + color: var(--bs-primary, var(--accent)); } .task-badge--async { diff --git a/web/css/topbar.css b/web/css/topbar.css index 3e3ce61..981d313 100644 --- a/web/css/topbar.css +++ b/web/css/topbar.css @@ -12,8 +12,8 @@ app-topbar { } .topbar-title { - font-size: 0.8rem; - font-weight: 600; + font-size: 0.9rem; + font-weight: 700; color: var(--sidebar-brand-color); letter-spacing: 0.02em; } @@ -26,10 +26,10 @@ app-topbar { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 30px; + height: 30px; border: none; - border-radius: 5px; + border-radius: 8px; background: transparent; color: var(--sidebar-text); cursor: pointer; @@ -50,12 +50,12 @@ app-topbar { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 30px; + height: 30px; border: none; - border-radius: 5px; + border-radius: 8px; background: transparent; - color: #6366f1; + color: var(--accent); cursor: pointer; transition: color 0.15s, background 0.15s; padding: 0; @@ -81,13 +81,13 @@ app-topbar { display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 30px; + height: 30px; border: none; border-radius: 50%; background: var(--accent); color: #fff; - font-size: 0.72rem; + font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background 0.15s, transform 0.1s; @@ -95,7 +95,7 @@ app-topbar { } .topbar-avatar:hover { - background: var(--accent-hover); + filter: brightness(0.9); } .topbar-dropdown { @@ -105,7 +105,7 @@ app-topbar { min-width: 200px; background: var(--card-bg); border: 1px solid var(--card-border); - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: var(--card-shadow); padding: 6px; z-index: 100; diff --git a/web/css/users-roles.css b/web/css/users-roles.css index 0ead655..d4a80f4 100644 --- a/web/css/users-roles.css +++ b/web/css/users-roles.css @@ -71,7 +71,7 @@ font-weight: 600; } -.um-badge-encrypted { background: rgba(99, 102, 241, .15); color: #6366f1; } +.um-badge-encrypted { background: rgba(var(--accent-rgb), .15); color: var(--accent); } .um-badge-clear { background: rgba(108, 117, 125, .15); color: #6c757d; } .um-badge-active { background: rgba(25, 135, 84, .15); color: #198754; } .um-badge-inactive { background: rgba(220, 53, 69, .15); color: #dc3545; } diff --git a/web/css/variables.css b/web/css/variables.css index 735ac80..8a6a98b 100644 --- a/web/css/variables.css +++ b/web/css/variables.css @@ -1,87 +1,104 @@ /* ── Layout variables ──────────────────────────────────────────────────────── */ :root { - --topbar-height: 32px; - --sidebar-width: 220px; + --topbar-height: 40px; + --sidebar-width: 240px; --copilot-width: 420px; - /* Brand accent */ - --accent: #6366f1; - --accent-hover: #4f46e5; + /* Brand accent — warm terracotta */ + --accent: #d95d4e; + --accent-rgb: 217, 93, 78; + --accent-hover: #c04a3c; + --accent-soft: rgba(217, 93, 78, 0.12); + --accent-ring: rgba(217, 93, 78, 0.28); - /* Sidebar — indigo, dark in both modes */ - --sidebar-bg: #1a1740; - --sidebar-hover: rgba(255, 255, 255, 0.06); - --sidebar-active-bg: rgba(99, 102, 241, 0.22); - --sidebar-label-color: #4a4880; - --sidebar-text: #9b99d4; - --sidebar-text-active: #e0e7ff; - --sidebar-divider: rgba(255, 255, 255, 0.07); - --sidebar-brand-color: #e0e7ff; + /* Radius scale — friendly, generous */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + + /* Sidebar — warm cream */ + --sidebar-bg: #f6f0e6; + --sidebar-hover: rgba(63, 52, 40, 0.06); + --sidebar-active-bg: rgba(217, 93, 78, 0.13); + --sidebar-label-color: #9c8a70; + --sidebar-text: #7a6a58; + --sidebar-text-active: #a63f31; + --sidebar-divider: #e9dfcf; + --sidebar-brand-color: #3f3428; /* Main area — light mode */ - --toolbar-border: #e2e8f0; - --placeholder-color: #94a3b8; - --copilot-bg: #f8fafc; + --toolbar-border: #e9dfd3; + --placeholder-color: #a89a85; + --copilot-bg: #fbf8f3; /* Copilot messages — light mode */ - --msg-assistant-bg: #e8ecf8; - --msg-assistant-text: #1e293b; - --msg-user-bg: #6366f1; + --msg-assistant-bg: #f4ede2; + --msg-assistant-text: #3f3428; + --msg-user-bg: #d95d4e; --msg-user-text: #ffffff; /* Card surfaces */ --card-bg: #ffffff; - --card-border: #dde1f0; - --card-shadow: 0 1px 3px rgba(99, 102, 241, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05); - --card-radius: 3px; + --card-border: #e9dfd3; + --card-shadow: 0 1px 3px rgba(63, 52, 40, 0.06), 0 1px 2px rgba(63, 52, 40, 0.04); + --card-radius: var(--radius-md); /* Bootstrap overrides */ - --bs-primary: #6366f1; - --bs-primary-rgb: 99, 102, 241; - --bs-link-color: #6366f1; - --bs-link-color-rgb: 99, 102, 241; - --bs-body-bg: #eef0f8; - --bs-body-bg-rgb: 238, 240, 248; - --bs-secondary-bg: #e4e7f2; - --bs-tertiary-bg: #f4f5fb; - --bs-border-color: #dde1f0; + --bs-primary: #d95d4e; + --bs-primary-rgb: 217, 93, 78; + --bs-link-color: #c04a3c; + --bs-link-color-rgb: 192, 74, 60; + --bs-body-bg: #faf7f2; + --bs-body-bg-rgb: 250, 247, 242; + --bs-secondary-bg: #f0e9dc; + --bs-tertiary-bg: #f6f0e6; + --bs-border-color: #e9dfd3; + --bs-border-radius: 0.6rem; + --bs-border-radius-sm: 0.45rem; + --bs-border-radius-lg: 0.9rem; } [data-bs-theme="dark"] { - --sidebar-bg: #0e0c22; - --sidebar-hover: rgba(255, 255, 255, 0.05); - --sidebar-active-bg: rgba(99, 102, 241, 0.18); - --sidebar-label-color: #2a2860; - --sidebar-text: #6360b8; - --sidebar-text-active: #c7d2fe; - --sidebar-divider: rgba(255, 255, 255, 0.06); - --sidebar-brand-color: #c7d2fe; + --accent: #e8836f; + --accent-rgb: 232, 131, 111; + --accent-hover: #f0967f; + --accent-soft: rgba(232, 131, 111, 0.16); + --accent-ring: rgba(232, 131, 111, 0.35); - --toolbar-border: #1e1b3a; - --placeholder-color: #4a4880; - --copilot-bg: #13112a; + --sidebar-bg: #241f1a; + --sidebar-hover: rgba(236, 225, 211, 0.06); + --sidebar-active-bg: rgba(232, 131, 111, 0.16); + --sidebar-label-color: #7a6a55; + --sidebar-text: #b3a28c; + --sidebar-text-active: #f0a495; + --sidebar-divider: #3d332a; + --sidebar-brand-color: #ece1d3; - --msg-assistant-bg: #1a1830; - --msg-assistant-text: #c7d2fe; - --msg-user-bg: #4f46e5; + --toolbar-border: #3d332a; + --placeholder-color: #8a7a66; + --copilot-bg: #211c17; + + --msg-assistant-bg: #2e2721; + --msg-assistant-text: #ece1d3; + --msg-user-bg: #b6493b; --msg-user-text: #ffffff; - --bs-primary: #818cf8; - --bs-primary-rgb: 129, 140, 248; - --bs-link-color: #818cf8; - --bs-link-color-rgb: 129, 140, 248; + --bs-primary: #e8836f; + --bs-primary-rgb: 232, 131, 111; + --bs-link-color: #e8836f; + --bs-link-color-rgb: 232, 131, 111; - --bs-body-bg: #111128; - --bs-body-bg-rgb: 17, 17, 40; - --bs-secondary-bg: #1a1838; - --bs-tertiary-bg: #1e1c3e; - --bs-border-color: #2a2850; + --bs-body-bg: #1c1814; + --bs-body-bg-rgb: 28, 24, 20; + --bs-secondary-bg: #2e2721; + --bs-tertiary-bg: #2e2721; + --bs-border-color: #3d332a; /* Card surfaces — dark mode */ - --card-bg: #1d1b3e; - --card-border: #2e2b58; - --card-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4); + --card-bg: #27211b; + --card-border: #3d332a; + --card-shadow: 0 2px 10px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3); } /* ── Reset ─────────────────────────────────────────────────────────────────── */ @@ -98,6 +115,27 @@ body { font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; } +::selection { + background: var(--accent-soft); +} + +/* Keyboard users get a clear, warm focus ring everywhere. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Respect users who ask for less motion. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + /* ── Root layout ───────────────────────────────────────────────────────────── */ #app { diff --git a/web/i18n/en.js b/web/i18n/en.js new file mode 100644 index 0000000..58601bd --- /dev/null +++ b/web/i18n/en.js @@ -0,0 +1,1078 @@ +export default { + // ── Navigation ───────────────────────────────────────────────────────────── + 'nav.chat': 'Chat', + 'nav.inbox': 'Inbox', + 'nav.dashboard': 'Dashboard', + 'nav.projects': 'Projects', + 'nav.tasks': 'Task Manager', + 'nav.tasks.running': 'Running Tasks', + 'nav.tasks.cron': 'Cron Jobs', + 'nav.tasks.scheduled': 'Scheduled Tasks', + 'nav.tasks.history': 'History', + 'nav.models': 'Models', + 'nav.providers': 'Providers', + 'nav.security': 'Security', + 'nav.agents': 'Agents', + 'nav.users': 'Users', + 'nav.roles': 'Roles', + 'nav.connectors': 'Connectors', + 'nav.catalog': 'Catalog', + 'nav.config': 'Settings', + 'nav.llm_requests': 'LLM Requests', + 'nav.tic': 'TIC Sessions', + + // ── Top bar ──────────────────────────────────────────────────────────────── + 'topbar.profile': 'Profile', + 'topbar.logout': 'Log out', + 'topbar.account': 'Account', + 'topbar.open_chat': 'Open chat', + 'topbar.brand': 'Skald', + 'topbar.to_light': 'Switch to light mode', + 'topbar.to_dark': 'Switch to dark mode', + + // ── Chat ─────────────────────────────────────────────────────────────────── + 'chat.title': 'Skald', + 'chat.tab.general': 'General', + 'chat.hello': 'Hello! How can I help you today?', + 'chat.greeting': 'Hi there!', + 'chat.greeting.named': 'Hi, {name}!', + 'chat.greeting.sub': 'What can I do for you today?', + 'chat.placeholder': 'Message… (Enter to send, Shift+Enter for a new line)', + 'chat.mobile.placeholder': 'Type a message…', + 'chat.mobile.ask': 'Ask me anything', + 'chat.mobile.back_general': 'Back to General', + 'chat.mobile.project': 'Project', + 'chat.mobile.chat': 'Chat', + 'chat.mobile.record_voice': 'Record voice', + 'chat.mobile.stop_record': 'Stop recording', + + 'mobile.coming_soon': 'Coming soon', + 'mobile.nav.inbox': 'Inbox', + 'mobile.nav.projects': 'Projects', + 'mobile.nav.chat': 'Chat', + 'mobile.nav.alerts': 'Alerts', + 'mobile.nav.settings': 'Settings', + 'chat.send': 'Send', + 'chat.stop': 'Stop', + 'chat.thinking': 'Thinking…', + 'chat.attach': 'Attach files', + 'chat.new_session': 'New conversation', + 'chat.collapse': 'Hide chat', + 'chat.close_tab': 'Close tab', + 'chat.privacy': 'Private to you', + 'chat.privacy.hint': 'Only you can see this conversation. Sharing anything with the group always asks for approval first.', + 'chat.suggest.1': 'What can you do?', + 'chat.suggest.2': 'Help me plan my day', + 'chat.suggest.3': 'Tell me a story', + 'chat.suggest.4': 'What do you remember about me?', + 'chat.rejected': 'Denied.', + 'chat.rejected_by_user': 'Denied by user.', + 'chat.truncated': 'Response truncated by the token limit (↓{tokens} tok).', + + // ── Copilot render ───────────────────────────────────────────────────────── + 'copilot.open_in_viewer': 'Open in viewer', + 'copilot.unchanged_lines': '⋯ {n} unchanged lines ⋯', + 'copilot.cancel': 'Cancel', + 'copilot.clarification_ph': 'Type your answer…', + 'copilot.send': 'Send', + 'copilot.result': 'result', + 'copilot.error_label': 'error', + 'copilot.not_sent_to_llm': 'This message is not sent to the LLM', + 'copilot.remove': 'Remove', + 'copilot.result_json': 'result · json', + 'copilot.agent_done': 'done', + 'copilot.agent_running': 'running…', + 'copilot.agent_finished': 'finished', + 'copilot.status_awaiting': 'Awaiting approval', + 'copilot.status_cancelled': 'Cancelled by user', + 'copilot.status_denied': 'Denied by policy', + 'copilot.bypass_session': 'Session', + 'copilot.bypass_15min': '15 min', + + // ── Copilot slash commands ───────────────────────────────────────────────── + 'copilot.cmd.help': 'Show available commands', + 'copilot.cmd.clear': 'Start a new conversation', + 'copilot.cmd.new': 'Alias for /clear', + 'copilot.cmd.models': 'List available LLM models', + 'copilot.cmd.model': 'Select the model for this chat', + 'copilot.cmd.context': 'Last turn\'s token usage', + 'copilot.cmd.cost': 'Session spend (USD)', + 'copilot.cmd.compact': 'Force context compaction', + 'copilot.cmd.resettools': 'Remove activated tool groups', + 'copilot.cmd.sethome': 'Set web as notification home', + + // ── LLM Requests ──────────────────────────────────────────────────────────── + 'llmr.title': 'LLM Requests', + 'llmr.loading': 'Loading…', + 'llmr.empty': 'No requests found.', + 'llmr.total': '{n} rows', + 'llmr.filter.agent_id': 'Agent ID', + 'llmr.filter.agent_ph': 'e.g. main', + 'llmr.filter.source': 'Source', + 'llmr.filter.source_ph': 'e.g. web, tic, cron', + 'llmr.filter.from': 'From', + 'llmr.filter.to': 'To', + 'llmr.filter.apply': 'Apply', + 'llmr.filter.reset': 'Reset', + 'llmr.table.agent': 'Agent', + 'llmr.table.source': 'Source', + 'llmr.table.model': 'Model', + 'llmr.table.date': 'Date', + 'llmr.table.in_tokens': 'In tokens', + 'llmr.table.out_tokens': 'Out tokens', + 'llmr.table.cache_hit': 'Cache hit', + 'llmr.cache_read': 'read: {n} tk', + 'llmr.cache_write': 'write: {n} tk', + 'llmr.pagination': 'Page {cur} of {pages} — {total} results', + + // ── LLM Request Detail ────────────────────────────────────────────────────── + 'llmr.detail.back': 'Back', + 'llmr.detail.loading': 'Loading…', + 'llmr.detail.request': 'Request', + 'llmr.detail.no_agent': 'no agent', + 'llmr.detail.error_badge': 'error', + 'llmr.detail.purged': 'Payload not available — this request has been purged by the retention policy.', + 'llmr.detail.reasoning_label': 'reasoning', + 'llmr.detail.tool_params': 'Parameters', + 'llmr.detail.tool_result': 'Result', + 'llmr.detail.system_role': 'system', + 'llmr.detail.cache_label': 'cache {pct}', + 'llmr.detail.stat_input': 'Input tokens', + 'llmr.detail.stat_output': 'Output tokens', + + 'llmr.detail.section_req_headers': 'Request Headers', + 'llmr.detail.section_resp_headers':'Response Headers', + 'llmr.detail.section_params': 'Parameters', + 'llmr.detail.section_system': 'System Prompt', + 'llmr.detail.section_conversation':'Conversation', + 'llmr.detail.section_tools': 'Tools Defined', + 'llmr.detail.section_response': 'Response', + + // ── Config ────────────────────────────────────────────────────────────────── + 'config.title': 'Config', + 'config.loading': 'Loading…', + 'config.developer': 'Developer', + 'config.error_save':'Error saving "{name}": {msg}', + + // ── Projects ──────────────────────────────────────────────────────────────── + 'projects.title': 'Projects', + 'projects.btn.new': 'New Project', + 'projects.empty': 'No projects yet. Create one to get started.', + 'projects.action.edit': 'Edit', + 'projects.action.delete': 'Delete', + 'projects.card.updated': 'Updated', + 'projects.confirm.delete': 'Delete project "{name}"?\nAll tickets will also be deleted.', + + 'projects.modal.title_edit': 'Edit Project', + 'projects.modal.title_new': 'New Project', + 'projects.modal.name': 'Name', + 'projects.modal.name_ph': 'My Project', + 'projects.modal.path': 'Path', + 'projects.modal.path_ph': '/path/to/project', + 'projects.modal.desc': 'Description', + 'projects.modal.desc_ph': 'What this project is about', + 'projects.modal.cancel': 'Cancel', + 'projects.modal.saving': 'Saving…', + 'projects.modal.save': 'Save', + 'projects.modal.create': 'Create', + + // ── Project board ─────────────────────────────────────────────────────────── + 'project_board.back': 'Projects', + 'project_board.open_chat': 'Open Chat', + 'project_board.new_ticket': 'New Ticket', + 'project_board.tab.tickets': 'Tickets', + 'project_board.section.running': 'Running', + 'project_board.section.running_empty': 'No tickets running', + 'project_board.section.todo': 'Todo', + 'project_board.section.todo_empty': 'No tickets to do', + 'project_board.section.completed': 'Completed', + 'project_board.section.completed_empty': 'No completed tickets', + 'project_board.ticket.start': 'Start', + 'project_board.ticket.running': 'Running…', + 'project_board.ticket.reset': 'Reset', + 'project_board.ticket.result': 'Result', + 'project_board.ticket.error': 'Error', + 'project_board.ticket.no_output': '(no output)', + 'project_board.ticket.no_error': '(no error message)', + 'project_board.modal.title': 'New Ticket', + 'project_board.modal.title_label': 'Title', + 'project_board.modal.title_ph': 'What needs to be done', + 'project_board.modal.desc_label': 'Description / Prompt', + 'project_board.modal.desc_ph': 'Detailed instructions for the agent…', + 'project_board.modal.agent': 'Agent', + 'project_board.modal.security_group':'Security Group', + 'project_board.modal.inherit': '— inherit from project —', + 'project_board.modal.cancel': 'Cancel', + 'project_board.modal.saving': 'Saving…', + 'project_board.modal.create': 'Create', + 'project_board.confirm.delete': 'Delete ticket "{title}"?', + + // ── Session detail ────────────────────────────────────────────────────────── + 'session.back': 'Back', + 'session.loading': 'Loading session…', + 'session.no_session': 'No session loaded.', + 'session.no_session_hint': 'Navigate to #session/{id} to view a session.', + 'session.empty': 'No messages in this session.', + 'session.user_role': 'User', + 'session.assistant_role': 'Assistant', + 'session.reasoning_label': 'Reasoning', + 'session.thinking_role': 'Thinking', + 'session.failed': 'failed', + 'session.ephemeral': 'ephemeral', + 'session.automated': 'automated', + 'session.live': 'live', + 'session.agent': 'agent:', + 'session.id': 'id:', + 'session.sub_agent': 'Sub-agent:', + 'session.end_of': 'end of', + 'session.depth': 'depth {n}', + 'session.synthetic': 'synthetic', + 'session.tool_args': 'Arguments', + 'session.tool_result': 'Result', + 'session.tool_error': 'Error', + + // ── Inbox ────────────────────────────────────────────────────────────────── + 'inbox.empty': 'No pending requests', + 'inbox.reject_prompt': 'Rejection reason (optional):', + + // ── Approvals ────────────────────────────────────────────────────────────── + 'approval.pending': 'Waiting for your OK', + 'approval.approved': 'Allowed', + 'approval.rejected': 'Denied', + 'approval.approve': 'Allow', + 'approval.reject': 'Deny', + 'approval.confirm_reject': 'Confirm deny', + 'approval.reject_hint': 'Optional: say why (the assistant will read it)', + 'approval.bypass_15': 'Allow and skip similar requests for 15 minutes', + 'approval.bypass_all': 'Allow and skip all requests for this session', + + // ── Login ────────────────────────────────────────────────────────────────── + 'login.title': 'Welcome back', + 'login.subtitle': 'Sign in to your account.', + 'login.username': 'Username', + 'login.password': 'Password', + 'login.submit': 'Sign in', + 'login.signing': 'Signing in…', + 'login.missing': 'Enter your username and password.', + 'login.error': 'Invalid username or password.', + 'login.network': 'Network error — please try again.', + + // ── Profile ──────────────────────────────────────────────────────────────── + 'profile.title': 'Profile', + 'profile.account': 'Account', + 'profile.username': 'Username', + 'profile.role': 'Role', + 'profile.name': 'Display name', + 'profile.name.ph': 'Your name', + 'profile.language': 'Language', + 'profile.language.default': 'Group default ({locale})', + 'profile.saved': 'Saved.', + 'profile.pw': 'Change password', + 'profile.pw.current': 'Current password', + 'profile.pw.new': 'New password', + 'profile.pw.confirm': 'Confirm new password', + 'profile.pw.short': 'Password must be at least 4 characters.', + 'profile.pw.mismatch': 'Passwords do not match.', + 'profile.pw.changed': 'Password changed.', + 'profile.pw.submit': 'Change password', + + // ── Settings (config page extras) ────────────────────────────────────────── + 'config.debug': 'Debug mode', + 'config.debug.desc': 'Show developer pages (LLM requests, TIC sessions) in the sidebar.', + + // ── First-run setup ──────────────────────────────────────────────────────── + 'setup.title': 'Welcome to Skald', + 'setup.subtitle': 'Create the admin account to get started.', + 'setup.username': 'Choose a username.', + 'setup.pw.short': 'Password must be at least 4 characters.', + 'setup.pw.mismatch': 'The two passwords do not match.', + 'setup.confirm': 'Confirm password', + 'setup.language': 'Interface language', + 'setup.encrypt': 'Encrypt my conversation history', + 'setup.warn': 'your password derives the encryption key. If you forget it, your entire conversation history will be permanently lost — there is no recovery.', + 'setup.warn.strong': 'Warning:', + 'setup.submit': 'Create account', + 'setup.creating': 'Creating…', + 'setup.network': 'Network error — please try again.', + + // ── Dashboard ─────────────────────────────────────────────────────────────── + 'dashboard.status.loading': 'Loading…', + 'dashboard.status.no_models': 'No LLM models', + 'dashboard.status.online': 'Online & ready', + 'dashboard.status.degraded': 'Degraded', + 'dashboard.status.offline': 'All models offline', + + 'dashboard.stats.loading': 'Loading stats…', + 'dashboard.stats.empty': 'No LLM requests in the selected range.', + 'dashboard.stats.requests': 'Requests {per}', + 'dashboard.stats.tokens': 'Tokens {per}', + 'dashboard.stats.latency': 'Avg latency (ms)', + 'dashboard.stats.models': 'Models', + 'dashboard.stats.per_min': '/ min', + 'dashboard.stats.per_hour': '/ hour', + 'dashboard.stats.per_day': '/ day', + + 'dashboard.stats.range.hour': '1h', + 'dashboard.stats.range.day': '24h', + 'dashboard.stats.range.week': '7d', + 'dashboard.stats.range.month': '30d', + + 'dashboard.stats.chart.input': 'Input', + 'dashboard.stats.chart.output': 'Output', + 'dashboard.stats.chart.cached': 'Cached', + 'dashboard.stats.chart.non_cached': 'Non-cached', + 'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', + + 'dashboard.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.', + + 'dashboard.banner.no_models.title': 'No LLM models configured.', + 'dashboard.banner.no_models.desc': 'Start by adding a provider (Anthropic, OpenAI, OpenRouter…), then add at least one model in the Models section.', + 'dashboard.banner.no_models.action': 'Add a provider', + + 'dashboard.section.stats': 'LLM Stats', + 'dashboard.section.pending': 'Pending', + 'dashboard.section.guide': 'Quick guide', + + 'dashboard.tip.honcho.title': 'Enable Honcho', + 'dashboard.tip.honcho.desc': 'Persistent long-term memory — the agent learns your preferences over time. Ask the Copilot to enable it.', + + 'dashboard.refresh': 'Refresh', + + 'dashboard.guide.chat.title': 'Chat', + 'dashboard.guide.chat.desc': 'Your home page is a conversation — it knows everything: ask it to run agents, enable plugins, write code, or search the web.', + 'dashboard.guide.inbox.title': 'Inbox', + 'dashboard.guide.inbox.desc': 'Pending approvals and agent questions that need your input before background tasks can continue.', + 'dashboard.guide.agents.title': 'Agents', + 'dashboard.guide.agents.desc': 'Specialized sub-agents (engineer, architect, QA…). Each has a focused system prompt, tool set, and model selection.', + 'dashboard.guide.cron.title': 'Cron', + 'dashboard.guide.cron.desc': 'Scheduled tasks that run automatically at set intervals, even when the Copilot is idle.', + 'dashboard.guide.models.title': 'Models', + 'dashboard.guide.models.desc': 'Manage LLM, transcription, and image generation models. Drag to reorder priority.', + 'dashboard.guide.providers.title': 'Providers', + 'dashboard.guide.providers.desc': 'Add API keys for LLM providers (Anthropic, OpenAI, OpenRouter, Ollama…).', + 'dashboard.guide.security.title': 'Security', + 'dashboard.guide.security.desc': 'Define rules to auto-approve or auto-reject tool calls — skip repetitive confirmation prompts.', + + // ── Models (hub + sections) ───────────────────────────────────────────────── + 'models.hub.title': 'Models', + 'models.hub.subtitle': 'Configure LLM, transcription, and image generation providers.', + 'models.hub.count.none': 'No models', + 'models.hub.count.one': '1 model', + 'models.hub.count.many': '{n} models', + + 'models.hub.card.llm.title': 'LLM', + 'models.hub.card.llm.desc': 'Chat & completion models for agents and tools', + 'models.hub.card.transcribe.title': 'Transcription', + 'models.hub.card.transcribe.desc': 'Speech-to-text models via cloud or local plugin', + 'models.hub.card.image.title': 'Image Generation', + 'models.hub.card.image.desc': 'Text-to-image models via cloud API', + 'models.hub.card.tts.title': 'Text-to-Speech', + 'models.hub.card.tts.desc': 'Speech synthesis models via cloud or local plugin', + + 'models.back': 'Back to models', + 'models.add': 'Add', + 'models.add_model': 'Add model', + 'models.add_first': 'Add your first model', + 'models.edit': 'Edit', + 'models.delete': 'Delete', + 'models.saving': 'Saving…', + 'models.save_changes': 'Save changes', + 'models.cancel': 'Cancel', + 'models.search': 'Search models…', + 'models.loading': 'Loading models…', + 'models.no_results': 'No models found', + 'models.enter_id': 'Enter model ID manually', + 'models.managed_plugin': 'Managed by plugin', + 'models.readonly_plugin': 'Models with the Plugin badge are read-only — managed automatically by the plugin that registered them.', + 'models.readonly_plugin_full': 'Models with the Plugin badge are read-only — managed automatically by the plugin that registered them. To add, modify, or remove them, ask the agent directly: it has all the documentation it needs.', + 'models.default': 'default', + 'models.move_up': 'Move up', + 'models.move_down': 'Move down', + 'models.strength.very_high': 'Very High', + 'models.strength.high': 'High', + 'models.strength.average': 'Average', + 'models.strength.low': 'Low', + 'models.strength.very_low': 'Very Low', + 'models.strength.none': '— none —', + 'models.reasoning.off': '— off —', + 'models.reasoning.label': 'Reasoning', + 'models.reasoning.thinking': 'Reasoning (thinking)', + 'models.strength': 'Strength', + 'models.priority': 'Priority', + 'models.scope': 'Scope', + 'models.default_model': 'Default model', + 'models.extra_params': 'Extra params', + 'models.extra_params_hint': '(JSON, optional)', + 'models.model_id': 'Model ID', + 'models.model_id_hint': '(sent to API)', + 'models.model_id_immutable': 'Model ID cannot be changed after creation.', + 'models.name_alias': 'Name / Alias', + 'models.name_alias_hint': '(optional)', + 'models.name_alias_ph': 'same as model ID', + 'models.edit_title': 'Edit {name}', + 'models.edit_info': 'Model ID and provider cannot be changed. To use a different model, add a new entry.', + 'models.name_help': 'Used to reference this model (e.g. in an agent\'s client). Must be unique.', + 'models.no_providers_llm': 'Add a Provider first, then come back here to add models.', + 'models.no_providers_transcribe': 'No provider supports transcription yet. Add an OpenAI or OpenRouter provider first.', + 'models.no_providers_image': 'No provider supports image generation yet. Add an OpenRouter provider first.', + 'models.no_providers_tts': 'No provider supports TTS yet. Add an OpenAI provider first.', + 'models.list_empty_llm': 'No models configured yet.', + 'models.list_empty_transcribe': 'No transcription models configured.', + 'models.list_empty_image': 'No image generation models configured.', + 'models.list_empty_tts': 'No TTS models configured.', + 'models.list_empty_add_hint': 'Click Add to add a cloud model.', + 'models.list_empty_whisper': 'Activate the Whisper Local plugin for on-device transcription.', + 'models.source': 'Source', + 'models.source_plugin': 'Plugin', + 'models.source_cloud': 'Cloud', + 'models.name_col': 'Name', + 'models.provider_col': 'Provider', + 'models.model_id_col': 'Model ID', + 'models.language_col': 'Language', + 'models.language_auto': 'auto', + 'models.choose_provider': 'Choose Provider', + 'models.add_model_title': 'Add Model', + 'models.model_label': 'Model', + 'models.add_model_provider': 'Add {type} Model — Choose Provider', + 'models.add_model_type': 'Add {type} Model', + 'models.priority_hint': 'Lower number = tried first. Default: 100.', + 'models.priority_hint_short': 'Lower number = used first. Default: 100.', + 'models.status_healthy': 'Healthy', + 'models.status_degraded': 'Degraded', + 'models.status_down': 'Down', + + 'models.llm.title': 'LLM Models', + 'models.transcribe.title': 'Transcription Models', + 'models.image.title': 'Image Generation Models', + 'models.tts.title': 'Text-to-Speech Models', + + 'models.confirm_delete': 'Delete {type} model "{name}"?', + + 'models.error.save_order': 'Failed to save order: {msg}', + 'models.error.load_models': 'Failed to load models: {msg}', + 'models.error.invalid_json': 'Extra params: invalid JSON', + 'models.error.select_model': 'Select a model', + + 'models.label.sent_to_api': '(sent to API)', + 'models.label.optional': '(optional)', + 'models.label.bcp47': '(BCP-47, optional)', + 'models.label.required_elevenlabs': '(optional — required for ElevenLabs)', + 'models.label.shown_to_llm': '(optional — shown to LLM)', + 'models.label.response_fmt': 'Response format', + 'models.label.response_fmt_hint': '(optional)', + 'models.label.description': 'Description', + 'models.label.description_hint': '(optional)', + 'models.label.instructions': 'Instructions', + 'models.label.instructions_hint': '(optional — shown to LLM)', + 'models.label.voice_id': 'Voice ID', + 'models.label.voice_id_hint': '(optional — required for ElevenLabs)', + 'models.label.max_output': 'Max output tokens', + 'models.label.max_output_hint': '(optional)', + + 'models.ph.model_id': 'e.g. gpt-4o', + 'models.ph.model_id_transcribe': 'e.g. openai/whisper-1', + 'models.ph.model_id_tts': 'e.g. tts-1-hd', + 'models.ph.model_id_image': 'e.g. x-ai/grok-2-vision', + 'models.ph.name_alias': 'same as model ID', + 'models.ph.language': 'e.g. it, en — leave blank for auto-detect', + 'models.ph.voice_id': 'e.g. alloy, Kore, 21m00Tcm4TlvDq8ikWAM', + 'models.ph.description': 'e.g. High quality, slow — best for long responses', + 'models.ph.instructions': 'e.g. Speak in a calm, neutral tone. Pause slightly between sentences.', + 'models.ph.max_tokens': 'up to {n}', + + 'models.form.model_lock': 'Model ID cannot be changed after creation.', + 'models.form.voice_hint': 'Speaker voice. OpenAI: alloy/echo/nova… (default alloy if empty); Gemini: Kore/Puck/Zephyr…; ElevenLabs: the voice ID.', + 'models.form.instructions_hint': 'Voice/tone guidance injected into the LLM system prompt when this model is active.', + 'models.form.response_hint': 'Audio format requested from the provider. Leave empty unless the model requires a specific one — e.g. Gemini TTS only accepts pcm.', + 'models.form.response_default': 'Provider default (mp3)', + 'models.form.priority_img': 'Lower number = tried first. Default: 100.', + 'models.form.name_as_provider': 'Name / Alias (used as provider_id in the LLM tool)', + + 'models.tts.cost_multiplier': 'Cost multiplier relative to base rate', + 'models.llm.price_tooltip': 'Input/Output per 1M tokens', + + // ── Approval rules ───────────────────────────────────────────────────────── + 'approval.action.require': 'Require', + 'approval.action.allow': 'Allow', + 'approval.action.deny': 'Deny', + 'approval.chip.unset': '—', + 'approval.chip.req': 'Req', + + 'approval.category.filesystem': 'File System', + 'approval.category.shell': 'Shell', + 'approval.category.subagent': 'Agents', + 'approval.category.introspection': 'Introspection', + 'approval.category.config': 'Config', + 'approval.category.dynamic': 'Dynamic', + + 'approval.fs.allow_read': 'Allow read', + 'approval.fs.allow_write': 'Allow write', + 'approval.fs.deny': 'Deny', + 'approval.fs.require': 'Require', + 'approval.fs.default': 'Require (system default)', + + 'approval.error.enter_path': 'Enter a directory path.', + 'approval.error.tool_required': 'Tool pattern is required.', + 'approval.error.override_prio': 'Override rules must have priority < 0.', + 'approval.error.lowprio_range': 'Low priority rules must have priority between 1 and {max}.', + + 'approval.tool.any': 'Any tool', + 'approval.tool.any_mcp': 'Any MCP tool', + 'approval.tool.search': 'Search tools…', + 'approval.tool.no_results': 'No results', + 'approval.tool.group_builtin': 'Built-in', + 'approval.tool.group_glob': 'Glob', + 'approval.tool.group_mcp': 'MCP · {server}', + + 'approval.form.new_override': 'New override rule', + 'approval.form.new_lowprio': 'New low priority rule', + 'approval.form.edit': 'Edit rule', + 'approval.form.tool_pattern': 'Tool pattern', + 'approval.form.tool_pattern_ph': 'e.g. mcp__whatsapp__* or execute_cmd', + 'approval.form.tool_pattern_hint': 'Use * as a trailing wildcard, e.g. mcp__whatsapp__*', + 'approval.form.select_tool': 'Select tool', + 'approval.form.path_pattern': 'Path pattern', + 'approval.form.path_pattern_ph': 'e.g. data/* or data/notes/*', + 'approval.form.path_pattern_hint': 'Filter by file path. Use * as a wildcard.', + 'approval.form.action': 'Action', + 'approval.form.priority': 'Priority', + 'approval.form.priority_override_hint': 'Must be < 0 (e.g. −10)', + 'approval.form.priority_lowprio_hint': 'Must be 1 – {max}', + 'approval.form.source': 'Source', + 'approval.form.source_any': 'Any', + 'approval.form.agent_id': 'Agent ID', + 'approval.form.agent_id_ph': 'main (empty = any)', + 'approval.form.note': 'Note', + 'approval.form.note_ph': 'Short description…', + 'approval.form.cancel': 'Cancel', + 'approval.form.saving': 'Saving…', + 'approval.form.save': 'Save', + + 'approval.card.priority': 'Priority', + 'approval.card.edit': 'Edit', + 'approval.card.delete': 'Delete', + 'approval.card.remove': 'Remove', + + 'approval.matrix.title': 'Per-tool', + 'approval.matrix.subtitle': 'priority = 0 · exact tool name · no path/source filters', + 'approval.matrix.loading': 'Loading tools…', + + 'approval.fs.title': 'File System', + 'approval.fs.subtitle': 'path-scoped read / write access', + 'approval.fs.empty': 'No path rules yet — add one below.', + 'approval.fs.add_ph': 'Add directory path, e.g. docs', + 'approval.fs.default_label': 'Default', + 'approval.fs.default_hint': 'unmatched paths', + + 'approval.sidebar.overrides': 'Overrides', + 'approval.sidebar.overrides_sub': 'priority < 0 · evaluated first', + 'approval.sidebar.lowprio': 'Low Priority', + 'approval.sidebar.lowprio_sub': 'priority 1–999998 · evaluated after per-tool', + 'approval.sidebar.add': 'Add', + 'approval.sidebar.empty': 'No rules yet.', + + 'approval.default_bar.title': 'Default action', + 'approval.default_bar.hint': 'if no rule matches', + 'approval.default_bar.unset': 'system default: allow', + + 'approval.header.default_badge': 'Default', + 'approval.header.rule_count': '{n} rule', + 'approval.header.rule_count_plural': '{n} rules', + + 'approval.confirm.delete_fs': 'Remove File System rule for "{path}"?', + 'approval.confirm.delete_rule': 'Delete rule for "{pattern}"?', + 'approval.label.optional': '(optional)', + + // ── Security (groups) ─────────────────────────────────────────────────────── + 'security.title': 'Security', + 'security.group_count': '{n} group', + 'security.group_count_plural': '{n} groups', + 'security.new_group': 'New group', + 'security.rename_group': 'Rename group', + 'security.duplicate': 'Duplicate', + 'security.duplicate_title': 'Duplicate {name}', + 'security.duplicating': 'Duplicating…', + 'security.create_first': 'Create first group', + + 'security.form.id': 'ID', + 'security.form.id_ph': 'e.g. cron_strict', + 'security.form.id_hint': 'Lowercase slug, no spaces. Cannot be changed later.', + 'security.form.name': 'Name', + 'security.form.name_ph': 'e.g. Cron strict', + 'security.form.description': 'Description', + 'security.form.description_ph': 'Short description…', + 'security.form.new_name': 'New name', + 'security.form.new_id': 'New ID', + 'security.form.copy_info': 'All {n} rule{s} from {name} will be copied.', + 'security.form.cancel': 'Cancel', + 'security.form.saving': 'Saving…', + 'security.form.save': 'Save', + + 'security.card.default_badge': 'Default', + 'security.card.rule_count': '{n} rule', + 'security.card.rule_count_plural': '{n} rules', + 'security.card.duplicate': 'Duplicate', + 'security.card.rename': 'Rename', + 'security.card.delete_disabled': 'Cannot delete the default group', + 'security.card.delete': 'Delete group', + + 'security.confirm.delete': 'Delete group "{name}"?', + 'security.confirm.delete_with_rules': 'Delete group "{name}" and its {n} rule{s}?', + + 'security.banner.text1': 'Permission groups are named sets of approval rules. A session\'s active Agent Profile determines which group applies — that group\'s rules are evaluated first, with the Default group as fallback.', + 'security.banner.text2': 'Click a group to view and manage its rules. The Default group cannot be deleted, but its rules can be edited freely.', + + 'security.empty.title': 'No groups yet.', + + 'security.error.name_required': 'Name is required.', + 'security.error.id_required': 'ID is required.', + 'security.error.group_name_required': 'Group name is required.', + 'security.error.group_id_required': 'Group ID is required.', + + // ── Roles ─────────────────────────────────────────────────────────────────── + 'roles.title': 'Roles', + 'roles.count': '{n} role', + 'roles.count_plural': '{n} roles', + 'roles.new_role': 'New role', + 'roles.loading': 'Loading…', + 'roles.empty': 'No roles.', + + 'roles.col.id': 'ID', + 'roles.col.label': 'Label', + 'roles.col.group': 'Permission group', + 'roles.col.interface': 'Interface', + + 'roles.badge.simple': 'Simple', + 'roles.badge.full': 'Full', + + 'roles.form.new': 'New role', + 'roles.form.edit': 'Edit {name}', + 'roles.form.id': 'ID', + 'roles.form.id_hint': '(slug)', + 'roles.form.id_ph': 'e.g. editor', + 'roles.form.id_desc': 'Lowercase, no spaces. Cannot be changed later.', + 'roles.form.label': 'Label', + 'roles.form.group': 'Permission group', + 'roles.form.interface': 'Interface', + 'roles.form.interface_full': 'Full — all pages', + 'roles.form.interface_simple': 'Simple — chat only', + 'roles.form.interface_hint': 'Members with the simple interface see only chat and inbox. Stored as ui_mode in the attrs JSON below.', + 'roles.form.attrs': 'Attrs', + 'roles.form.attrs_hint': '(JSON, optional)', + 'roles.form.attrs_ph': '{}', + 'roles.form.cancel': 'Cancel', + 'roles.form.create': 'Create', + 'roles.form.save': 'Save', + + 'roles.error.id_label': 'ID and label are required.', + 'roles.error.label': 'Label is required.', + + 'roles.tooltip.locked': 'Built-in role — locked', + 'roles.tooltip.edit': 'Edit', + 'roles.tooltip.delete': 'Delete', + + 'roles.confirm.delete': 'Delete role "{name}"?', + + // ── Agents ────────────────────────────────────────────────────────────────── + 'agents.title': 'Agents', + 'agents.loading': 'Loading…', + 'agents.empty': 'No agents found.', + 'agents.back': 'Agents', + + 'agents.section.chat': 'Chat', + 'agents.section.task': 'Task Executors', + 'agents.section.system': 'System', + + 'agents.strength.very_high': 'Very High', + 'agents.strength.high': 'High', + 'agents.strength.average': 'Average', + 'agents.strength.low': 'Low', + 'agents.strength.very_low': 'Very Low', + + 'agents.detail.meta': 'Metadata', + 'agents.detail.id': 'ID', + 'agents.detail.strength': 'Strength', + 'agents.detail.scope': 'Scope', + 'agents.detail.pinned_model': 'Pinned model', + 'agents.detail.memory_files': 'Memory files', + 'agents.detail.model_order': 'Model resolution order', + 'agents.detail.model_order_desc': 'Models sorted by how well they match this agent\'s requirements. The system uses the first available model from the top.', + 'agents.detail.no_models': 'No models configured.', + 'agents.detail.prompt': 'System prompt', + 'agents.detail.default': 'default', + + 'agents.table.rank': '#', + 'agents.table.strength': 'Strength', + 'agents.table.name': 'Name', + 'agents.table.model_id': 'Model ID', + 'agents.table.scope': 'Scope', + + 'agents.banner.title': 'Read-only view. Agents are defined by files in agents/ — to add, remove, or modify an agent, edit the corresponding AGENT.md file in that directory.', + 'agents.banner.text': 'You can also ask Copilot (top bar) to create a new agent for you — just describe what it should do and it will set up all the files automatically.', + + // ── Connectors ────────────────────────────────────────────────────────────── + 'connectors.title': 'Connectors', + 'connectors.loading': 'Loading…', + 'connectors.search': 'Search connectors…', + 'connectors.btn.signin_providers': 'Sign-in providers', + 'connectors.btn.catalog': 'Catalog', + 'connectors.btn.marketplace': 'Marketplace', + 'connectors.empty.installed': 'No connectors installed yet.', + 'connectors.empty.available': 'Nothing available to you yet.', + 'connectors.empty.install_hint': 'Install one from the Marketplace to get started.', + 'connectors.empty.ask_admin': 'Ask an admin to make one available.', + 'connectors.empty.match': 'No connector matches "{query}".', + + 'connectors.chip.global': 'global', + 'connectors.chip.per_user': 'per-user', + 'connectors.chip.local_script': 'local script', + + 'connectors.status.active': 'active', + 'connectors.status.needs_fix': 'needs fix', + 'connectors.status.needs_signin': 'needs sign-in', + 'connectors.status.enabled': 'enabled', + 'connectors.status.off': 'off', + 'connectors.status.available': 'available', + + 'connectors.providers.title': 'Sign-in providers', + 'connectors.providers.desc': 'OAuth apps that per-user connectors sign in through. One app (e.g. Google) covers all of its services. The client secret is stored on this box and never shown again.', + 'connectors.providers.empty': 'No sign-in providers yet.', + 'connectors.providers.secret_set': 'secret set', + 'connectors.providers.no_secret': 'no secret', + 'connectors.providers.no_client_id': '(no client id)', + 'connectors.providers.add_google': 'Add Google', + 'connectors.providers.add_other': 'Add other', + 'connectors.providers.save': 'Save', + 'connectors.providers.cancel': 'Cancel', + 'connectors.providers.delete_confirm': 'Delete the "{name}" sign-in provider?\n\nConnectors that use it will no longer be able to sign in.', + 'connectors.providers.error.name_client': 'Name and client id are required.', + 'connectors.providers.error.secret': 'A client secret is required for a new provider.', + 'connectors.providers.field.name': 'Provider id', + 'connectors.providers.field.name_help': 'The slug a connector references (must match the manifest\'s auth.provider).', + 'connectors.providers.field.display': 'Display name', + 'connectors.providers.field.client_id': 'Client id', + 'connectors.providers.field.client_secret': 'Client secret', + 'connectors.providers.field.secret_help_new': 'Required.', + 'connectors.providers.field.secret_help_edit': 'Leave blank to keep the stored secret.', + 'connectors.providers.field.auth_url': 'Authorization URL', + 'connectors.providers.field.token_url': 'Token URL', + 'connectors.providers.field.redirect': 'Redirect URI', + 'connectors.providers.field.redirect_help': 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.', + 'connectors.providers.field.extra': 'Extra params (JSON)', + 'connectors.providers.field.extra_help': 'Merged into the consent URL. Google needs these two to return a refresh token.', + + 'connectors.detail.back': 'Back', + 'connectors.detail.global_note': 'Runs once for the household, on the host. Nobody reaches it until they are granted access.', + 'connectors.detail.managed.title': 'This connector is managed for you.', + 'connectors.detail.managed.desc': 'It is enabled by an admin and granted to you — there is nothing to configure.', + 'connectors.detail.config.title_active': 'Configuration', + 'connectors.detail.config.title_setup': 'Set up', + 'connectors.detail.config.already_global': 'Already enabled. Re-submitting replaces the stored credentials.', + 'connectors.detail.config.already_user': 'Already active. Re-submitting replaces the stored credentials.', + 'connectors.detail.config.api_key': 'API key', + 'connectors.detail.config.btn_test': 'Test credentials', + 'connectors.detail.config.btn_testing': 'Testing…', + 'connectors.detail.config.btn_enable_global': 'Enable globally', + 'connectors.detail.config.btn_save_restart': 'Save & restart', + 'connectors.detail.config.btn_disable': 'Disable', + 'connectors.detail.config.btn_activate': 'Activate', + 'connectors.detail.config.btn_deactivate': 'Deactivate', + 'connectors.detail.detail_scope_global': 'global', + 'connectors.detail.scope_local': 'runs code on this box', + 'connectors.detail.status.active': 'active', + 'connectors.detail.status.needs_fix': 'needs fixing', + 'connectors.detail.status.needs_signin': 'needs sign-in', + 'connectors.detail.confirm.deactivate': 'Deactivate "{name}"?', + 'connectors.detail.confirm.disable_global': 'Disable "{name}"?\n\nIt stops for everyone who can use it.', + + 'connectors.detail.oauth.title': 'Sign in', + 'connectors.detail.oauth.desc': 'Signs in with {provider}. You approve access in a browser tab, then paste back the code the page shows you — nothing is stored on this box until you do.', + 'connectors.detail.oauth.scopes': 'It will request access to:', + 'connectors.detail.oauth.signed_in': 'Signed in and active.', + 'connectors.detail.oauth.btn_signin': 'Sign in with {provider}', + 'connectors.detail.oauth.btn_signin_again':'Sign in again', + 'connectors.detail.oauth.btn_finish': 'Finish sign-in', + 'connectors.detail.oauth.btn_complete': 'Complete sign-in', + 'connectors.detail.oauth.step1': 'A tab opened for {provider}. Approve access there.', + 'connectors.detail.oauth.step1_link': 'Re-open the sign-in page', + 'connectors.detail.oauth.step2': 'Paste the code the page gave you:', + 'connectors.detail.oauth.cancel': 'Cancel', + 'connectors.detail.oauth.deactivate': 'Deactivate', + + 'connectors.detail.test.running': 'Testing credentials…', + 'connectors.detail.test.skipped': 'No verification step for this connector.', + 'connectors.detail.test.ok_label': 'OK', + 'connectors.detail.test.fail_label': 'Failed', + 'connectors.detail.test.error_saved': 'Saved, but the credentials did not check out — fix them and test again.', + 'connectors.detail.test.error_verify': 'Verification failed — the connector stays disabled until the credentials are fixed.', + + 'connectors.detail.access.title': 'Who can use it', + 'connectors.detail.access.desc': 'Ticking a box grants this connector\'s tools to that person\'s agent. Saving replaces the whole list.', + 'connectors.detail.access.empty': 'No users.', + 'connectors.detail.access.save': 'Save access', + + 'connectors.error.no_connector': 'No connector named "{name}" is available to you.', + + // ── Providers ─────────────────────────────────────────────────────────────── + 'providers.title': 'Providers', + 'providers.add': 'Add', + 'providers.add_first': 'Add your first provider', + 'providers.empty': 'No providers configured yet.', + 'providers.count': '{n}', + + 'providers.card.models_title': 'Models using this provider', + 'providers.card.api_key_configured': 'API key configured', + 'providers.card.api_key_missing': 'API key missing', + 'providers.card.edit': 'Edit', + 'providers.card.delete': 'Delete', + 'providers.card.base_url': 'Base URL', + + 'providers.modal.add': 'Add Provider', + 'providers.modal.edit': 'Edit Provider', + 'providers.modal.name': 'Name', + 'providers.modal.name_ph': 'e.g. My Anthropic', + 'providers.modal.type': 'Type', + 'providers.modal.api_key': 'API Key', + 'providers.modal.api_key_ph': 'Leave blank to keep existing key', + 'providers.modal.base_url': 'Base URL', + 'providers.modal.base_url_ollama': 'http://localhost:11434', + 'providers.modal.base_url_oai': 'http://localhost:1234/v1', + 'providers.modal.description': 'Description', + 'providers.modal.description_optional': '(optional)', + 'providers.modal.cancel': 'Cancel', + 'providers.modal.saving': 'Saving…', + 'providers.modal.save_changes': 'Save changes', + 'providers.modal.add_provider': 'Add provider', + + 'providers.confirm.delete': 'Delete provider "{name}"? All associated models will be deleted too.', + + // ── TIC Sessions ──────────────────────────────────────────────────────────── + 'tic.title': 'TIC Sessions', + 'tic.loading': 'Loading…', + 'tic.empty': 'No TIC sessions found.', + 'tic.total': '{n} total', + 'tic.refresh': 'Refresh', + + 'tic.table.agent': 'Agent', + 'tic.table.started': 'Started', + 'tic.table.messages': 'Messages', + 'tic.table.last_activity':'Last activity', + + 'tic.pagination': 'Page {cur} of {pages} — {total} sessions', + + // ── File viewer ───────────────────────────────────────────────────────────── + 'fv.back': 'Back', + 'fv.download': 'Download', + 'fv.mode_preview': 'Show preview', + 'fv.mode_source': 'Show source', + 'fv.binary_unavailable': 'Preview not available for this file type.', + 'fv.latex_failed': 'LaTeX compilation failed — showing source instead', + + // ── Marketplace ───────────────────────────────────────────────────────────── + 'marketplace.title': 'Marketplace', + 'marketplace.btn.catalog': 'Catalog', + 'marketplace.action.refetch': 'Refetch the feed', + 'marketplace.not_admin': 'The marketplace is managed by the admin.', + 'marketplace.not_admin_link': 'Connectors the admin has installed appear on the Connectors page.', + 'marketplace.desc': 'Vetted connectors you can add to this box\'s catalog. Installing does not activate anything — it makes a connector available.', + 'marketplace.feed_unreachable': 'Marketplace unreachable — {error}', + 'marketplace.loading': 'Loading feed…', + + 'marketplace.filter.search': 'Search connectors…', + 'marketplace.filter.scope': 'Scope', + 'marketplace.filter.type': 'Type', + 'marketplace.filter.all': 'All', + 'marketplace.filter.global': 'Global', + 'marketplace.filter.per_user': 'Per-user', + 'marketplace.filter.remote': 'Remote', + 'marketplace.filter.local': 'Local', + + 'marketplace.grid.empty_feed': 'The feed is empty.', + 'marketplace.grid.no_match': 'No connector matches these filters.', + + 'marketplace.card.installed': 'installed', + 'marketplace.card.scope_global': 'global', + 'marketplace.card.scope_per_user': 'per-user', + 'marketplace.card.type_script': 'local script', + 'marketplace.card.type_remote': 'remote', + 'marketplace.card.files_one': '{n} file, SHA-256 verified on install', + 'marketplace.card.files_other': '{n} files, SHA-256 verified on install', + 'marketplace.card.oauth_scopes_one': 'Requests {n} OAuth scope', + 'marketplace.card.oauth_scopes_other': 'Requests {n} OAuth scopes', + 'marketplace.card.installing': 'Installing…', + 'marketplace.card.reinstall': 'Reinstall', + 'marketplace.card.install': 'Install', + 'marketplace.card.homepage': 'Homepage', + + 'marketplace.confirm.install_warn': 'This puts code on this box:\n • {n} file(s), each verified against its SHA-256\n • installed into ./connectors/{id}/', + 'marketplace.confirm.install_body':'Install "{name}" into the catalog?\n\nInstalling does not activate it.', + + // ── Users ─────────────────────────────────────────────────────────────────── + 'users.title': 'Users', + 'users.loading': 'Loading…', + 'users.empty': 'No users.', + 'users.count_one': '{n} user', + 'users.count_other': '{n} users', + 'users.btn.new': 'New user', + + 'users.table.username': 'Username', + 'users.table.display_name': 'Display name', + 'users.table.role': 'Role', + 'users.table.db': 'DB', + 'users.table.status': 'Status', + + 'users.badge.encrypted': 'Encrypted', + 'users.badge.cleartext': 'Cleartext', + 'users.badge.active': 'Active', + 'users.badge.inactive': 'Inactive', + + 'users.action.reset_pw': 'Reset password', + 'users.action.edit': 'Edit', + 'users.action.delete': 'Delete', + + 'users.modal.create_title': 'New user', + 'users.modal.edit_title': 'Edit {username}', + 'users.modal.reset_title': 'Reset password — {username}', + 'users.modal.username': 'Username', + 'users.modal.display_name': 'Display name', + 'users.modal.optional': '(optional)', + 'users.modal.role': 'Role', + 'users.modal.password': 'Password', + 'users.modal.new_password': 'New password', + 'users.modal.encrypt': 'Encrypt conversation history', + 'users.modal.encrypt_warn': 'Warning: if the password is lost, the conversation history is permanently unrecoverable.', + 'users.modal.active': 'Active', + 'users.modal.only_cleartext': 'Only works for cleartext (non-encrypted) users.', + 'users.modal.cancel': 'Cancel', + 'users.modal.create_btn': 'Create', + 'users.modal.save_btn': 'Save', + 'users.modal.reset_btn': 'Reset', + + 'users.error.required_username_pw': 'Username and password are required.', + 'users.error.required_username': 'Username is required.', + 'users.error.password_empty': 'Password must not be empty.', + + 'users.confirm.delete': 'Delete user "{username}"? This permanently erases their database and all conversation history.', + + // ── Catalog ───────────────────────────────────────────────────────────────── + 'catalog.title': 'Connector Catalog', + 'catalog.loading': 'Loading…', + 'catalog.not_admin': 'The catalog is managed by the admin.', + 'catalog.not_admin_link': 'What you can activate is on the Connectors page.', + 'catalog.desc': 'What this box offers. Nothing here is running — a global entry still needs enabling, a per-user one still needs each user to activate it, both on the Connectors page.', + 'catalog.empty.title': 'The catalog is empty.', + 'catalog.empty.hint': 'Add a connector from the marketplace to get started.', + 'catalog.empty.action': 'Browse the marketplace', + + 'catalog.btn.add': 'Add connector', + 'catalog.dropdown.marketplace': 'From the marketplace', + 'catalog.dropdown.marketplace_desc': 'Vetted connectors, files verified by SHA-256.', + 'catalog.dropdown.manual': 'Manually', + 'catalog.dropdown.manual_desc': 'You supply the config, and vouch for it yourself.', + + 'catalog.table.connector': 'Connector', + 'catalog.table.scope': 'Scope', + 'catalog.table.type': 'Type', + 'catalog.table.auth': 'Auth', + + 'catalog.badge.global': 'global', + 'catalog.badge.per_user': 'per-user', + 'catalog.badge.local_script':'local script', + 'catalog.badge.remote': 'remote', + + 'catalog.action.remove': 'Remove from catalog', + + 'catalog.modal.title': 'Add connector manually', + 'catalog.modal.script_warn': 'A local script runs code on this box. Nothing verifies it — unlike the marketplace path, there is no digest to check.', + 'catalog.modal.name': 'Name', + 'catalog.modal.name_hint': 'slug', + 'catalog.modal.scope': 'Scope', + 'catalog.modal.type': 'Type', + 'catalog.modal.transport': 'Transport', + 'catalog.modal.command': 'Command', + 'catalog.modal.command_ph': 'python3', + 'catalog.modal.script_path': 'Script path', + 'catalog.modal.script_path_hint': 'as /, under ./connectors', + 'catalog.modal.url': 'URL', + 'catalog.modal.args': 'Args', + 'catalog.modal.args_hint': 'one per line', + 'catalog.modal.config_schema': 'Required secret/env keys', + 'catalog.modal.config_schema_hint': 'comma/newline', + 'catalog.modal.auth': 'Auth', + 'catalog.modal.friendly': 'Friendly name', + 'catalog.modal.desc': 'Description', + 'catalog.modal.desc_hint': 'the LLM reads this when deciding to activate the connector', + 'catalog.modal.cancel': 'Cancel', + 'catalog.modal.save': 'Add to catalog', + + 'catalog.error.name': 'Name is required.', + 'catalog.confirm.delete': 'Remove "{name}" from the catalog?\n\nAnything already activated from it keeps running.', + + // ── Cron ──────────────────────────────────────────────────────────────────── + 'cron.title': 'Cron Jobs', + 'cron.count_one': '{n} job', + 'cron.count_other': '{n} jobs', + + 'cron.empty.title': 'No recurring cron jobs.', + 'cron.empty.hint': 'Ask the agent to create one with execute_task.', + + 'cron.badge.running': 'running', + 'cron.badge.disabled': 'disabled', + 'cron.badge.idle': 'idle', + + 'cron.action.delete': 'Delete', + + 'cron.card.label_agent': 'Agent', + 'cron.card.label_last_run': 'Last run', + 'cron.card.label_next_run': 'Next run', + 'cron.card.enabled': 'Enabled', + 'cron.card.disabled': 'Disabled', + + 'cron.confirm.delete': 'Delete job "{title}"?', + + // ── Common ───────────────────────────────────────────────────────────────── + 'common.save': 'Save', + 'common.saving': 'Saving…', + 'common.cancel': 'Cancel', + 'common.loading': 'Loading…', + + // ── Shared Folders (blueprint §6) ──────────────────────────────────────────── + 'nav.shared_folders': 'Shared Folders', + 'sf.title': 'Shared Folders', + 'sf.count': '{n} folder', + 'sf.count_plural': '{n} folders', + 'sf.new': 'New folder', + 'sf.loading': 'Loading…', + 'sf.empty': 'No shared folders yet.', + 'sf.empty_hint': 'Create one to share files with other members.', + 'sf.note.propagation': 'Adding or removing a member is applied to their workspace right away.', + 'sf.members': 'Members', + 'sf.no_members': 'No members yet — only you (admin) can reach this folder.', + 'sf.no_desc': 'No description yet — add one so the assistant knows what to keep here.', + 'sf.all_added': 'Everyone available is already a member.', + 'sf.choose_user': 'Choose a member…', + 'sf.add': 'Add', + 'sf.remove': 'Remove', + 'sf.edit_desc': 'Edit description', + 'sf.delete': 'Delete', + 'sf.access.label': 'Access', + 'sf.access.read': 'Read', + 'sf.access.write': 'Write', + 'sf.access.readonly': 'Read only', + 'sf.access.readwrite': 'Read & write', + 'sf.confirm.delete': 'Delete the shared folder “{name}”?\n\nMembers lose access. The files on disk are left untouched.', + 'sf.confirm.remove_member': 'Remove {name} from “{folder}”?', + 'sf.error.name': 'A folder name is required.', + 'sf.form.new': 'New shared folder', + 'sf.form.edit': 'Edit “{name}”', + 'sf.form.name': 'Folder name', + 'sf.form.name_hint': '(letters, numbers, dashes — no slashes)', + 'sf.form.name_ph': 'documents', + 'sf.form.name_desc': 'Becomes shared/ in every member’s workspace. It can’t be renamed later.', + 'sf.form.desc': 'What’s it for?', + 'sf.form.desc_desc': 'The assistant reads this to decide what to store here and when to look. Write it as instructions to the assistant.', + 'sf.form.desc_ph': 'Household bills, contracts and warranties. Save utility PDFs and deadlines here; check here when asked about an invoice or a warranty.', + 'sf.form.create': 'Create', + 'sf.form.save': 'Save', + 'sf.form.cancel': 'Cancel', +}; diff --git a/web/i18n/fr.js b/web/i18n/fr.js new file mode 100644 index 0000000..244a092 --- /dev/null +++ b/web/i18n/fr.js @@ -0,0 +1,1078 @@ +export default { + // ── Navigation ───────────────────────────────────────────────────────────── + 'nav.chat': 'Discussion', + 'nav.inbox': 'Boîte de réception', + 'nav.dashboard': 'Tableau de bord', + 'nav.projects': 'Projets', + 'nav.tasks': 'Gestionnaire de tâches', + 'nav.tasks.running': 'Tâches en cours', + 'nav.tasks.cron': 'Tâches Cron', + 'nav.tasks.scheduled': 'Tâches planifiées', + 'nav.tasks.history': 'Historique', + 'nav.models': 'Modèles', + 'nav.providers': 'Fournisseurs', + 'nav.security': 'Sécurité', + 'nav.agents': 'Agents', + 'nav.users': 'Utilisateurs', + 'nav.roles': 'Rôles', + 'nav.connectors': 'Connecteurs', + 'nav.catalog': 'Catalogue', + 'nav.config': 'Paramètres', + 'nav.llm_requests': 'Requêtes LLM', + 'nav.tic': 'Sessions TIC', + + // ── Top bar ──────────────────────────────────────────────────────────────── + 'topbar.profile': 'Profil', + 'topbar.logout': 'Déconnexion', + 'topbar.account': 'Compte', + 'topbar.open_chat': 'Ouvrir la discussion', + 'topbar.brand': 'Skald', + 'topbar.to_light': 'Passer en mode clair', + 'topbar.to_dark': 'Passer en mode sombre', + + // ── Chat ─────────────────────────────────────────────────────────────────── + 'chat.title': 'Skald', + 'chat.tab.general': 'Général', + 'chat.hello': 'Bonjour ! Comment puis-je vous aider aujourd\'hui ?', + 'chat.greeting': 'Salut !', + 'chat.greeting.named': 'Bonjour, {name} !', + 'chat.greeting.sub': 'Que puis-je faire pour vous aujourd\'hui ?', + 'chat.placeholder': 'Message… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)', + 'chat.mobile.placeholder': 'Tapez un message…', + 'chat.mobile.ask': 'Demandez-moi n\'importe quoi', + 'chat.mobile.back_general': 'Retour au général', + 'chat.mobile.project': 'Projet', + 'chat.mobile.chat': 'Discussion', + 'chat.mobile.record_voice': 'Enregistrer la voix', + 'chat.mobile.stop_record': 'Arrêter l\'enregistrement', + + 'mobile.coming_soon': 'Bientôt disponible', + 'mobile.nav.inbox': 'Boîte de réception', + 'mobile.nav.projects': 'Projets', + 'mobile.nav.chat': 'Discussion', + 'mobile.nav.alerts': 'Alertes', + 'mobile.nav.settings': 'Paramètres', + 'chat.send': 'Envoyer', + 'chat.stop': 'Arrêter', + 'chat.thinking': 'Réflexion…', + 'chat.attach': 'Joindre des fichiers', + 'chat.new_session': 'Nouvelle conversation', + 'chat.collapse': 'Masquer la discussion', + 'chat.close_tab': 'Fermer l\'onglet', + 'chat.privacy': 'Privé pour vous', + 'chat.privacy.hint': 'Vous seul(e) pouvez voir cette conversation. Partager quoi que ce soit avec le groupe demande toujours une approbation préalable.', + 'chat.suggest.1': 'Que pouvez-vous faire ?', + 'chat.suggest.2': 'Aidez-moi à planifier ma journée', + 'chat.suggest.3': 'Racontez-moi une histoire', + 'chat.suggest.4': 'Que savez-vous de moi ?', + 'chat.rejected': 'Refusé.', + 'chat.rejected_by_user': 'Refusé par l\'utilisateur.', + 'chat.truncated': 'Réponse tronquée par la limite de tokens (↓{tokens} tok).', + + // ── Copilot render ───────────────────────────────────────────────────────── + 'copilot.open_in_viewer': 'Ouvrir dans le visualiseur', + 'copilot.unchanged_lines': '⋯ {n} lignes inchangées ⋯', + 'copilot.cancel': 'Annuler', + 'copilot.clarification_ph': 'Tapez votre réponse…', + 'copilot.send': 'Envoyer', + 'copilot.result': 'résultat', + 'copilot.error_label': 'erreur', + 'copilot.not_sent_to_llm': 'Ce message n\'est pas envoyé au LLM', + 'copilot.remove': 'Supprimer', + 'copilot.result_json': 'résultat · json', + 'copilot.agent_done': 'terminé', + 'copilot.agent_running': 'en cours…', + 'copilot.agent_finished': 'fini', + 'copilot.status_awaiting': 'En attente d\'approbation', + 'copilot.status_cancelled': 'Annulé par l\'utilisateur', + 'copilot.status_denied': 'Refusé par la politique', + 'copilot.bypass_session': 'Session', + 'copilot.bypass_15min': '15 min', + + // ── Copilot slash commands ───────────────────────────────────────────────── + 'copilot.cmd.help': 'Afficher les commandes disponibles', + 'copilot.cmd.clear': 'Démarrer une nouvelle conversation', + 'copilot.cmd.new': 'Alias pour /clear', + 'copilot.cmd.models': 'Lister les modèles LLM disponibles', + 'copilot.cmd.model': 'Sélectionner le modèle pour cette discussion', + 'copilot.cmd.context': 'Utilisation des tokens du dernier tour', + 'copilot.cmd.cost': 'Dépenses de la session (USD)', + 'copilot.cmd.compact': 'Forcer la compaction du contexte', + 'copilot.cmd.resettools': 'Supprimer les groupes d\'outils activés', + 'copilot.cmd.sethome': 'Définir le web comme accueil des notifications', + + // ── LLM Requests ──────────────────────────────────────────────────────────── + 'llmr.title': 'Requêtes LLM', + 'llmr.loading': 'Chargement…', + 'llmr.empty': 'Aucune requête trouvée.', + 'llmr.total': '{n} lignes', + 'llmr.filter.agent_id': 'ID de l\'agent', + 'llmr.filter.agent_ph': 'ex. main', + 'llmr.filter.source': 'Source', + 'llmr.filter.source_ph': 'ex. web, tic, cron', + 'llmr.filter.from': 'De', + 'llmr.filter.to': 'À', + 'llmr.filter.apply': 'Appliquer', + 'llmr.filter.reset': 'Réinitialiser', + 'llmr.table.agent': 'Agent', + 'llmr.table.source': 'Source', + 'llmr.table.model': 'Modèle', + 'llmr.table.date': 'Date', + 'llmr.table.in_tokens': 'Tokens en entrée', + 'llmr.table.out_tokens': 'Tokens en sortie', + 'llmr.table.cache_hit': 'Cache hit', + 'llmr.cache_read': 'lecture : {n} tk', + 'llmr.cache_write': 'écriture : {n} tk', + 'llmr.pagination': 'Page {cur} sur {pages} — {total} résultats', + + // ── LLM Request Detail ────────────────────────────────────────────────────── + 'llmr.detail.back': 'Retour', + 'llmr.detail.loading': 'Chargement…', + 'llmr.detail.request': 'Requête', + 'llmr.detail.no_agent': 'aucun agent', + 'llmr.detail.error_badge': 'erreur', + 'llmr.detail.purged': 'Charge utile non disponible — cette requête a été purgée par la politique de conservation.', + 'llmr.detail.reasoning_label': 'raisonnement', + 'llmr.detail.tool_params': 'Paramètres', + 'llmr.detail.tool_result': 'Résultat', + 'llmr.detail.system_role': 'système', + 'llmr.detail.cache_label': 'cache {pct}', + 'llmr.detail.stat_input': 'Tokens en entrée', + 'llmr.detail.stat_output': 'Tokens en sortie', + + 'llmr.detail.section_req_headers': 'En-têtes de la requête', + 'llmr.detail.section_resp_headers':'En-têtes de la réponse', + 'llmr.detail.section_params': 'Paramètres', + 'llmr.detail.section_system': 'Prompt système', + 'llmr.detail.section_conversation':'Conversation', + 'llmr.detail.section_tools': 'Outils définis', + 'llmr.detail.section_response': 'Réponse', + + // ── Config ────────────────────────────────────────────────────────────────── + 'config.title': 'Configuration', + 'config.loading': 'Chargement…', + 'config.developer': 'Développeur', + 'config.error_save':'Erreur lors de l\'enregistrement de "{name}" : {msg}', + + // ── Projects ──────────────────────────────────────────────────────────────── + 'projects.title': 'Projets', + 'projects.btn.new': 'Nouveau projet', + 'projects.empty': 'Aucun projet pour le moment. Créez-en un pour commencer.', + 'projects.action.edit': 'Modifier', + 'projects.action.delete': 'Supprimer', + 'projects.card.updated': 'Mis à jour', + 'projects.confirm.delete': 'Supprimer le projet "{name}" ?\nTous les tickets seront également supprimés.', + + 'projects.modal.title_edit': 'Modifier le projet', + 'projects.modal.title_new': 'Nouveau projet', + 'projects.modal.name': 'Nom', + 'projects.modal.name_ph': 'Mon projet', + 'projects.modal.path': 'Chemin', + 'projects.modal.path_ph': '/chemin/vers/le/projet', + 'projects.modal.desc': 'Description', + 'projects.modal.desc_ph': 'À propos de ce projet', + 'projects.modal.cancel': 'Annuler', + 'projects.modal.saving': 'Enregistrement…', + 'projects.modal.save': 'Enregistrer', + 'projects.modal.create': 'Créer', + + // ── Project board ─────────────────────────────────────────────────────────── + 'project_board.back': 'Projets', + 'project_board.open_chat': 'Ouvrir la discussion', + 'project_board.new_ticket': 'Nouveau ticket', + 'project_board.tab.tickets': 'Tickets', + 'project_board.section.running': 'En cours', + 'project_board.section.running_empty': 'Aucun ticket en cours', + 'project_board.section.todo': 'À faire', + 'project_board.section.todo_empty': 'Aucun ticket à faire', + 'project_board.section.completed': 'Terminé', + 'project_board.section.completed_empty': 'Aucun ticket terminé', + 'project_board.ticket.start': 'Démarrer', + 'project_board.ticket.running': 'En cours…', + 'project_board.ticket.reset': 'Réinitialiser', + 'project_board.ticket.result': 'Résultat', + 'project_board.ticket.error': 'Erreur', + 'project_board.ticket.no_output': '(aucune sortie)', + 'project_board.ticket.no_error': '(aucun message d\'erreur)', + 'project_board.modal.title': 'Nouveau ticket', + 'project_board.modal.title_label': 'Titre', + 'project_board.modal.title_ph': 'Ce qui doit être fait', + 'project_board.modal.desc_label': 'Description / Prompt', + 'project_board.modal.desc_ph': 'Instructions détaillées pour l\'agent…', + 'project_board.modal.agent': 'Agent', + 'project_board.modal.security_group':'Groupe de sécurité', + 'project_board.modal.inherit': '— hériter du projet —', + 'project_board.modal.cancel': 'Annuler', + 'project_board.modal.saving': 'Enregistrement…', + 'project_board.modal.create': 'Créer', + 'project_board.confirm.delete': 'Supprimer le ticket "{title}" ?', + + // ── Session detail ────────────────────────────────────────────────────────── + 'session.back': 'Retour', + 'session.loading': 'Chargement de la session…', + 'session.no_session': 'Aucune session chargée.', + 'session.no_session_hint': 'Naviguez vers #session/{id} pour voir une session.', + 'session.empty': 'Aucun message dans cette session.', + 'session.user_role': 'Utilisateur', + 'session.assistant_role': 'Assistant', + 'session.reasoning_label': 'Raisonnement', + 'session.thinking_role': 'Réflexion', + 'session.failed': 'échoué', + 'session.ephemeral': 'éphémère', + 'session.automated': 'automatisé', + 'session.live': 'en direct', + 'session.agent': 'agent :', + 'session.id': 'id :', + 'session.sub_agent': 'Sous-agent :', + 'session.end_of': 'fin de', + 'session.depth': 'profondeur {n}', + 'session.synthetic': 'synthétique', + 'session.tool_args': 'Arguments', + 'session.tool_result': 'Résultat', + 'session.tool_error': 'Erreur', + + // ── Inbox ────────────────────────────────────────────────────────────────── + 'inbox.empty': 'Aucune demande en attente', + 'inbox.reject_prompt': 'Motif du refus (facultatif) :', + + // ── Approvals ────────────────────────────────────────────────────────────── + 'approval.pending': 'En attente de votre accord', + 'approval.approved': 'Autorisé', + 'approval.rejected': 'Refusé', + 'approval.approve': 'Autoriser', + 'approval.reject': 'Refuser', + 'approval.confirm_reject': 'Confirmer le refus', + 'approval.reject_hint': 'Facultatif : dites pourquoi (l\'assistant le lira)', + 'approval.bypass_15': 'Autoriser et ignorer les demandes similaires pendant 15 minutes', + 'approval.bypass_all': 'Autoriser et ignorer toutes les demandes pour cette session', + + // ── Login ────────────────────────────────────────────────────────────────── + 'login.title': 'Bon retour', + 'login.subtitle': 'Connectez-vous à votre compte.', + 'login.username': 'Nom d\'utilisateur', + 'login.password': 'Mot de passe', + 'login.submit': 'Se connecter', + 'login.signing': 'Connexion en cours…', + 'login.missing': 'Entrez votre nom d\'utilisateur et votre mot de passe.', + 'login.error': 'Nom d\'utilisateur ou mot de passe invalide.', + 'login.network': 'Erreur réseau — veuillez réessayer.', + + // ── Profile ──────────────────────────────────────────────────────────────── + 'profile.title': 'Profil', + 'profile.account': 'Compte', + 'profile.username': 'Nom d\'utilisateur', + 'profile.role': 'Rôle', + 'profile.name': 'Nom d\'affichage', + 'profile.name.ph': 'Votre nom', + 'profile.language': 'Langue', + 'profile.language.default': 'Par défaut du groupe ({locale})', + 'profile.saved': 'Enregistré.', + 'profile.pw': 'Changer le mot de passe', + 'profile.pw.current': 'Mot de passe actuel', + 'profile.pw.new': 'Nouveau mot de passe', + 'profile.pw.confirm': 'Confirmer le nouveau mot de passe', + 'profile.pw.short': 'Le mot de passe doit contenir au moins 4 caractères.', + 'profile.pw.mismatch': 'Les mots de passe ne correspondent pas.', + 'profile.pw.changed': 'Mot de passe modifié.', + 'profile.pw.submit': 'Changer le mot de passe', + + // ── Settings (config page extras) ────────────────────────────────────────── + 'config.debug': 'Mode débogage', + 'config.debug.desc': 'Afficher les pages développeur (requêtes LLM, sessions TIC) dans la barre latérale.', + + // ── First-run setup ──────────────────────────────────────────────────────── + 'setup.title': 'Bienvenue sur Skald', + 'setup.subtitle': 'Créez le compte administrateur pour commencer.', + 'setup.username': 'Choisissez un nom d\'utilisateur.', + 'setup.pw.short': 'Le mot de passe doit contenir au moins 4 caractères.', + 'setup.pw.mismatch': 'Les deux mots de passe ne correspondent pas.', + 'setup.confirm': 'Confirmer le mot de passe', + 'setup.language': 'Langue de l\'interface', + 'setup.encrypt': 'Chiffrer mon historique de conversations', + 'setup.warn': 'votre mot de passe génère la clé de chiffrement. Si vous l\'oubliez, tout votre historique de conversations sera définitivement perdu — il n\'y a aucune récupération possible.', + 'setup.warn.strong': 'Attention :', + 'setup.submit': 'Créer le compte', + 'setup.creating': 'Création…', + 'setup.network': 'Erreur réseau — veuillez réessayer.', + + // ── Dashboard ─────────────────────────────────────────────────────────────── + 'dashboard.status.loading': 'Chargement…', + 'dashboard.status.no_models': 'Aucun modèle LLM', + 'dashboard.status.online': 'En ligne et prêt', + 'dashboard.status.degraded': 'Dégradé', + 'dashboard.status.offline': 'Tous les modèles hors ligne', + + 'dashboard.stats.loading': 'Chargement des statistiques…', + 'dashboard.stats.empty': 'Aucune requête LLM dans la plage sélectionnée.', + 'dashboard.stats.requests': 'Requêtes {per}', + 'dashboard.stats.tokens': 'Tokens {per}', + 'dashboard.stats.latency': 'Latence moyenne (ms)', + 'dashboard.stats.models': 'Modèles', + 'dashboard.stats.per_min': '/ min', + 'dashboard.stats.per_hour': '/ heure', + 'dashboard.stats.per_day': '/ jour', + + 'dashboard.stats.range.hour': '1h', + 'dashboard.stats.range.day': '24h', + 'dashboard.stats.range.week': '7j', + 'dashboard.stats.range.month': '30j', + + 'dashboard.stats.chart.input': 'Entrée', + 'dashboard.stats.chart.output': 'Sortie', + 'dashboard.stats.chart.cached': 'En cache', + 'dashboard.stats.chart.non_cached': 'Non en cache', + 'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%', + + 'dashboard.hero.subtitle': 'Votre centre de commande IA — recherche, code, planification et orchestration. Tout en un seul endroit.', + + 'dashboard.banner.no_models.title': 'Aucun modèle LLM configuré.', + 'dashboard.banner.no_models.desc': 'Commencez par ajouter un fournisseur (Anthropic, OpenAI, OpenRouter…), puis ajoutez au moins un modèle dans la section Modèles.', + 'dashboard.banner.no_models.action': 'Ajouter un fournisseur', + + 'dashboard.section.stats': 'Statistiques LLM', + 'dashboard.section.pending': 'En attente', + 'dashboard.section.guide': 'Guide rapide', + + 'dashboard.tip.honcho.title': 'Activer Honcho', + 'dashboard.tip.honcho.desc': 'Mémoire persistante à long terme — l\'agent apprend vos préférences au fil du temps. Demandez au Copilot de l\'activer.', + + 'dashboard.refresh': 'Actualiser', + + 'dashboard.guide.chat.title': 'Discussion', + 'dashboard.guide.chat.desc': 'Votre page d\'accueil est une conversation — elle sait tout : demandez-lui d\'exécuter des agents, d\'activer des plugins, d\'écrire du code ou de rechercher sur le web.', + 'dashboard.guide.inbox.title': 'Boîte de réception', + 'dashboard.guide.inbox.desc': 'Approbations en attente et questions des agents qui nécessitent votre intervention avant que les tâches en arrière-plan puissent continuer.', + 'dashboard.guide.agents.title': 'Agents', + 'dashboard.guide.agents.desc': 'Sous-agents spécialisés (ingénieur, architecte, QA…). Chacun a un prompt système ciblé, un ensemble d\'outils et une sélection de modèle.', + 'dashboard.guide.cron.title': 'Cron', + 'dashboard.guide.cron.desc': 'Tâches planifiées qui s\'exécutent automatiquement à intervalles réguliers, même lorsque le Copilot est inactif.', + 'dashboard.guide.models.title': 'Modèles', + 'dashboard.guide.models.desc': 'Gérez les modèles LLM, de transcription et de génération d\'images. Glissez-déposez pour réorganiser la priorité.', + 'dashboard.guide.providers.title': 'Fournisseurs', + 'dashboard.guide.providers.desc': 'Ajoutez des clés API pour les fournisseurs LLM (Anthropic, OpenAI, OpenRouter, Ollama…).', + 'dashboard.guide.security.title': 'Sécurité', + 'dashboard.guide.security.desc': 'Définissez des règles pour approuver ou refuser automatiquement les appels d\'outils — évitez les invites de confirmation répétitives.', + + // ── Models (hub + sections) ───────────────────────────────────────────────── + 'models.hub.title': 'Modèles', + 'models.hub.subtitle': 'Configurez les fournisseurs LLM, de transcription et de génération d\'images.', + 'models.hub.count.none': 'Aucun modèle', + 'models.hub.count.one': '1 modèle', + 'models.hub.count.many': '{n} modèles', + + 'models.hub.card.llm.title': 'LLM', + 'models.hub.card.llm.desc': 'Modèles de discussion et de complétion pour les agents et les outils', + 'models.hub.card.transcribe.title': 'Transcription', + 'models.hub.card.transcribe.desc': 'Modèles de synthèse vocale via le cloud ou un plugin local', + 'models.hub.card.image.title': 'Génération d\'images', + 'models.hub.card.image.desc': 'Modèles texte-vers-image via API cloud', + 'models.hub.card.tts.title': 'Synthèse vocale', + 'models.hub.card.tts.desc': 'Modèles de synthèse vocale via le cloud ou un plugin local', + + 'models.back': 'Retour aux modèles', + 'models.add': 'Ajouter', + 'models.add_model': 'Ajouter un modèle', + 'models.add_first': 'Ajouter votre premier modèle', + 'models.edit': 'Modifier', + 'models.delete': 'Supprimer', + 'models.saving': 'Enregistrement…', + 'models.save_changes': 'Enregistrer les modifications', + 'models.cancel': 'Annuler', + 'models.search': 'Rechercher des modèles…', + 'models.loading': 'Chargement des modèles…', + 'models.no_results': 'Aucun modèle trouvé', + 'models.enter_id': 'Entrer l\'ID du modèle manuellement', + 'models.managed_plugin': 'Géré par plugin', + 'models.readonly_plugin': 'Les modèles avec le badge Plugin sont en lecture seule — gérés automatiquement par le plugin qui les a enregistrés.', + 'models.readonly_plugin_full': 'Les modèles avec le badge Plugin sont en lecture seule — gérés automatiquement par le plugin qui les a enregistrés. Pour en ajouter, modifier ou supprimer, demandez directement à l\'agent : il dispose de toute la documentation nécessaire.', + 'models.default': 'défaut', + 'models.move_up': 'Monter', + 'models.move_down': 'Descendre', + 'models.strength.very_high': 'Très élevée', + 'models.strength.high': 'Élevée', + 'models.strength.average': 'Moyenne', + 'models.strength.low': 'Faible', + 'models.strength.very_low': 'Très faible', + 'models.strength.none': '— aucun —', + 'models.reasoning.off': '— désactivé —', + 'models.reasoning.label': 'Raisonnement', + 'models.reasoning.thinking': 'Raisonnement (réflexion)', + 'models.strength': 'Puissance', + 'models.priority': 'Priorité', + 'models.scope': 'Portée', + 'models.default_model': 'Modèle par défaut', + 'models.extra_params': 'Paramètres supplémentaires', + 'models.extra_params_hint': '(JSON, facultatif)', + 'models.model_id': 'ID du modèle', + 'models.model_id_hint': '(envoyé à l\'API)', + 'models.model_id_immutable': 'L\'ID du modèle ne peut pas être modifié après la création.', + 'models.name_alias': 'Nom / Alias', + 'models.name_alias_hint': '(facultatif)', + 'models.name_alias_ph': 'identique à l\'ID du modèle', + 'models.edit_title': 'Modifier {name}', + 'models.edit_info': 'L\'ID du modèle et le fournisseur ne peuvent pas être modifiés. Pour utiliser un modèle différent, ajoutez une nouvelle entrée.', + 'models.name_help': 'Utilisé pour référencer ce modèle (p. ex. dans le client d\'un agent). Doit être unique.', + 'models.no_providers_llm': 'Ajoutez d\'abord un fournisseur, puis revenez ici pour ajouter des modèles.', + 'models.no_providers_transcribe': 'Aucun fournisseur ne prend encore en charge la transcription. Ajoutez d\'abord un fournisseur OpenAI ou OpenRouter.', + 'models.no_providers_image': 'Aucun fournisseur ne prend encore en charge la génération d\'images. Ajoutez d\'abord un fournisseur OpenRouter.', + 'models.no_providers_tts': 'Aucun fournisseur ne prend encore en charge la TTS. Ajoutez d\'abord un fournisseur OpenAI.', + 'models.list_empty_llm': 'Aucun modèle configuré pour le moment.', + 'models.list_empty_transcribe': 'Aucun modèle de transcription configuré.', + 'models.list_empty_image': 'Aucun modèle de génération d\'images configuré.', + 'models.list_empty_tts': 'Aucun modèle TTS configuré.', + 'models.list_empty_add_hint': 'Cliquez sur Ajouter pour ajouter un modèle cloud.', + 'models.list_empty_whisper': 'Activez le plugin Whisper Local pour la transcription sur l\'appareil.', + 'models.source': 'Source', + 'models.source_plugin': 'Plugin', + 'models.source_cloud': 'Cloud', + 'models.name_col': 'Nom', + 'models.provider_col': 'Fournisseur', + 'models.model_id_col': 'ID du modèle', + 'models.language_col': 'Langue', + 'models.language_auto': 'auto', + 'models.choose_provider': 'Choisir un fournisseur', + 'models.add_model_title': 'Ajouter un modèle', + 'models.model_label': 'Modèle', + 'models.add_model_provider': 'Ajouter un modèle {type} — Choisir un fournisseur', + 'models.add_model_type': 'Ajouter un modèle {type}', + 'models.priority_hint': 'Nombre plus bas = essayé en premier. Défaut : 100.', + 'models.priority_hint_short': 'Nombre plus bas = utilisé en premier. Défaut : 100.', + 'models.status_healthy': 'En santé', + 'models.status_degraded': 'Dégradé', + 'models.status_down': 'Hors ligne', + + 'models.llm.title': 'Modèles LLM', + 'models.transcribe.title': 'Modèles de transcription', + 'models.image.title': 'Modèles de génération d\'images', + 'models.tts.title': 'Modèles de synthèse vocale', + + 'models.confirm_delete': 'Supprimer le modèle {type} "{name}" ?', + + 'models.error.save_order': 'Échec de l\'enregistrement de l\'ordre : {msg}', + 'models.error.load_models': 'Échec du chargement des modèles : {msg}', + 'models.error.invalid_json': 'Paramètres supplémentaires : JSON invalide', + 'models.error.select_model': 'Sélectionnez un modèle', + + 'models.label.sent_to_api': '(envoyé à l\'API)', + 'models.label.optional': '(facultatif)', + 'models.label.bcp47': '(BCP-47, facultatif)', + 'models.label.required_elevenlabs': '(facultatif — requis pour ElevenLabs)', + 'models.label.shown_to_llm': '(facultatif — montré au LLM)', + 'models.label.response_fmt': 'Format de réponse', + 'models.label.response_fmt_hint': '(facultatif)', + 'models.label.description': 'Description', + 'models.label.description_hint': '(facultatif)', + 'models.label.instructions': 'Instructions', + 'models.label.instructions_hint': '(facultatif — montré au LLM)', + 'models.label.voice_id': 'ID vocal', + 'models.label.voice_id_hint': '(facultatif — requis pour ElevenLabs)', + 'models.label.max_output': 'Max tokens de sortie', + 'models.label.max_output_hint': '(facultatif)', + + 'models.ph.model_id': 'ex. gpt-4o', + 'models.ph.model_id_transcribe': 'ex. openai/whisper-1', + 'models.ph.model_id_tts': 'ex. tts-1-hd', + 'models.ph.model_id_image': 'ex. x-ai/grok-2-vision', + 'models.ph.name_alias': 'identique à l\'ID du modèle', + 'models.ph.language': 'ex. fr, en — laissez vide pour détection automatique', + 'models.ph.voice_id': 'ex. alloy, Kore, 21m00Tcm4TlvDq8ikWAM', + 'models.ph.description': 'ex. Haute qualité, lent — idéal pour les longues réponses', + 'models.ph.instructions': 'ex. Parlez d\'un ton calme et neutre. Marquez une légère pause entre les phrases.', + 'models.ph.max_tokens': 'jusqu\'à {n}', + + 'models.form.model_lock': 'L\'ID du modèle ne peut pas être modifié après la création.', + 'models.form.voice_hint': 'Voix du locuteur. OpenAI : alloy/echo/nova… (défaut alloy si vide) ; Gemini : Kore/Puck/Zephyr… ; ElevenLabs : l\'ID vocal.', + 'models.form.instructions_hint': 'Guide vocal/tonal injecté dans le prompt système LLM lorsque ce modèle est actif.', + 'models.form.response_hint': 'Format audio demandé au fournisseur. Laissez vide sauf si le modèle en nécessite un spécifique — p. ex. Gemini TTS n\'accepte que le format pcm.', + 'models.form.response_default': 'Défaut du fournisseur (mp3)', + 'models.form.priority_img': 'Nombre plus bas = essayé en premier. Défaut : 100.', + 'models.form.name_as_provider': 'Nom / Alias (utilisé comme provider_id dans l\'outil LLM)', + + 'models.tts.cost_multiplier': 'Multiplicateur de coût par rapport au taux de base', + 'models.llm.price_tooltip': 'Entrée/Sortie par 1M de tokens', + + // ── Approval rules ───────────────────────────────────────────────────────── + 'approval.action.require': 'Exiger', + 'approval.action.allow': 'Autoriser', + 'approval.action.deny': 'Refuser', + 'approval.chip.unset': '—', + 'approval.chip.req': 'Req', + + 'approval.category.filesystem': 'Système de fichiers', + 'approval.category.shell': 'Shell', + 'approval.category.subagent': 'Agents', + 'approval.category.introspection': 'Introspection', + 'approval.category.config': 'Configuration', + 'approval.category.dynamic': 'Dynamique', + + 'approval.fs.allow_read': 'Autoriser la lecture', + 'approval.fs.allow_write': 'Autoriser l\'écriture', + 'approval.fs.deny': 'Refuser', + 'approval.fs.require': 'Exiger', + 'approval.fs.default': 'Exiger (défaut système)', + + 'approval.error.enter_path': 'Entrez un chemin de répertoire.', + 'approval.error.tool_required': 'Le motif d\'outil est requis.', + 'approval.error.override_prio': 'Les règles de dérogation doivent avoir une priorité < 0.', + 'approval.error.lowprio_range': 'Les règles de faible priorité doivent avoir une priorité entre 1 et {max}.', + + 'approval.tool.any': 'Tout outil', + 'approval.tool.any_mcp': 'Tout outil MCP', + 'approval.tool.search': 'Rechercher des outils…', + 'approval.tool.no_results': 'Aucun résultat', + 'approval.tool.group_builtin': 'Intégré', + 'approval.tool.group_glob': 'Glob', + 'approval.tool.group_mcp': 'MCP · {server}', + + 'approval.form.new_override': 'Nouvelle règle de dérogation', + 'approval.form.new_lowprio': 'Nouvelle règle de faible priorité', + 'approval.form.edit': 'Modifier la règle', + 'approval.form.tool_pattern': 'Motif d\'outil', + 'approval.form.tool_pattern_ph': 'ex. mcp__whatsapp__* ou execute_cmd', + 'approval.form.tool_pattern_hint': 'Utilisez * comme joker de fin, p. ex. mcp__whatsapp__*', + 'approval.form.select_tool': 'Sélectionner un outil', + 'approval.form.path_pattern': 'Motif de chemin', + 'approval.form.path_pattern_ph': 'ex. data/* ou data/notes/*', + 'approval.form.path_pattern_hint': 'Filtrer par chemin de fichier. Utilisez * comme joker.', + 'approval.form.action': 'Action', + 'approval.form.priority': 'Priorité', + 'approval.form.priority_override_hint': 'Doit être < 0 (p. ex. −10)', + 'approval.form.priority_lowprio_hint': 'Doit être 1 – {max}', + 'approval.form.source': 'Source', + 'approval.form.source_any': 'Toute', + 'approval.form.agent_id': 'ID de l\'agent', + 'approval.form.agent_id_ph': 'main (vide = toute)', + 'approval.form.note': 'Note', + 'approval.form.note_ph': 'Brève description…', + 'approval.form.cancel': 'Annuler', + 'approval.form.saving': 'Enregistrement…', + 'approval.form.save': 'Enregistrer', + + 'approval.card.priority': 'Priorité', + 'approval.card.edit': 'Modifier', + 'approval.card.delete': 'Supprimer', + 'approval.card.remove': 'Retirer', + + 'approval.matrix.title': 'Par outil', + 'approval.matrix.subtitle': 'priorité = 0 · nom d\'outil exact · aucun filtre de chemin/source', + 'approval.matrix.loading': 'Chargement des outils…', + + 'approval.fs.title': 'Système de fichiers', + 'approval.fs.subtitle': 'accès lecture/écriture par chemin', + 'approval.fs.empty': 'Aucune règle de chemin pour le moment — ajoutez-en une ci-dessous.', + 'approval.fs.add_ph': 'Ajouter un chemin de répertoire, p. ex. docs', + 'approval.fs.default_label': 'Défaut', + 'approval.fs.default_hint': 'chemins non correspondants', + + 'approval.sidebar.overrides': 'Dérogations', + 'approval.sidebar.overrides_sub': 'priorité < 0 · évaluées en premier', + 'approval.sidebar.lowprio': 'Faible priorité', + 'approval.sidebar.lowprio_sub': 'priorité 1–999998 · évaluées après le par-outil', + 'approval.sidebar.add': 'Ajouter', + 'approval.sidebar.empty': 'Aucune règle pour le moment.', + + 'approval.default_bar.title': 'Action par défaut', + 'approval.default_bar.hint': 'si aucune règle ne correspond', + 'approval.default_bar.unset': 'défaut système : autoriser', + + 'approval.header.default_badge': 'Défaut', + 'approval.header.rule_count': '{n} règle', + 'approval.header.rule_count_plural': '{n} règles', + + 'approval.confirm.delete_fs': 'Supprimer la règle de système de fichiers pour "{path}" ?', + 'approval.confirm.delete_rule': 'Supprimer la règle pour "{pattern}" ?', + 'approval.label.optional': '(facultatif)', + + // ── Security (groups) ─────────────────────────────────────────────────────── + 'security.title': 'Sécurité', + 'security.group_count': '{n} groupe', + 'security.group_count_plural': '{n} groupes', + 'security.new_group': 'Nouveau groupe', + 'security.rename_group': 'Renommer le groupe', + 'security.duplicate': 'Dupliquer', + 'security.duplicate_title': 'Dupliquer {name}', + 'security.duplicating': 'Duplication…', + 'security.create_first': 'Créer le premier groupe', + + 'security.form.id': 'ID', + 'security.form.id_ph': 'ex. cron_strict', + 'security.form.id_hint': 'Identifiant en minuscules, sans espaces. Ne peut pas être modifié ultérieurement.', + 'security.form.name': 'Nom', + 'security.form.name_ph': 'ex. Cron strict', + 'security.form.description': 'Description', + 'security.form.description_ph': 'Brève description…', + 'security.form.new_name': 'Nouveau nom', + 'security.form.new_id': 'Nouvel ID', + 'security.form.copy_info': 'Les {n} règle{s} de {name} seront copiées.', + 'security.form.cancel': 'Annuler', + 'security.form.saving': 'Enregistrement…', + 'security.form.save': 'Enregistrer', + + 'security.card.default_badge': 'Défaut', + 'security.card.rule_count': '{n} règle', + 'security.card.rule_count_plural': '{n} règles', + 'security.card.duplicate': 'Dupliquer', + 'security.card.rename': 'Renommer', + 'security.card.delete_disabled': 'Impossible de supprimer le groupe par défaut', + 'security.card.delete': 'Supprimer le groupe', + + 'security.confirm.delete': 'Supprimer le groupe "{name}" ?', + 'security.confirm.delete_with_rules': 'Supprimer le groupe "{name}" et ses {n} règle{s} ?', + + 'security.banner.text1': 'Les groupes de permissions sont des ensembles nommés de règles d\'approbation. Le profil d\'agent actif de la session détermine le groupe applicable — les règles de ce groupe sont évaluées en premier, avec le groupe Défaut comme solution de repli.', + 'security.banner.text2': 'Cliquez sur un groupe pour voir et gérer ses règles. Le groupe Défaut ne peut pas être supprimé, mais ses règles peuvent être modifiées librement.', + + 'security.empty.title': 'Aucun groupe pour le moment.', + + 'security.error.name_required': 'Le nom est requis.', + 'security.error.id_required': 'L\'ID est requis.', + 'security.error.group_name_required': 'Le nom du groupe est requis.', + 'security.error.group_id_required': 'L\'ID du groupe est requis.', + + // ── Roles ─────────────────────────────────────────────────────────────────── + 'roles.title': 'Rôles', + 'roles.count': '{n} rôle', + 'roles.count_plural': '{n} rôles', + 'roles.new_role': 'Nouveau rôle', + 'roles.loading': 'Chargement…', + 'roles.empty': 'Aucun rôle.', + + 'roles.col.id': 'ID', + 'roles.col.label': 'Libellé', + 'roles.col.group': 'Groupe de permissions', + 'roles.col.interface': 'Interface', + + 'roles.badge.simple': 'Simple', + 'roles.badge.full': 'Complet', + + 'roles.form.new': 'Nouveau rôle', + 'roles.form.edit': 'Modifier {name}', + 'roles.form.id': 'ID', + 'roles.form.id_hint': '(slug)', + 'roles.form.id_ph': 'ex. redacteur', + 'roles.form.id_desc': 'Minuscules, sans espaces. Ne peut pas être modifié ultérieurement.', + 'roles.form.label': 'Libellé', + 'roles.form.group': 'Groupe de permissions', + 'roles.form.interface': 'Interface', + 'roles.form.interface_full': 'Complet — toutes les pages', + 'roles.form.interface_simple': 'Simple — discussion uniquement', + 'roles.form.interface_hint': 'Les membres avec l\'interface simple voient seulement la discussion et la boîte de réception. Stocké comme ui_mode dans le JSON d\'attributs ci-dessous.', + 'roles.form.attrs': 'Attributs', + 'roles.form.attrs_hint': '(JSON, facultatif)', + 'roles.form.attrs_ph': '{}', + 'roles.form.cancel': 'Annuler', + 'roles.form.create': 'Créer', + 'roles.form.save': 'Enregistrer', + + 'roles.error.id_label': 'L\'ID et le libellé sont requis.', + 'roles.error.label': 'Le libellé est requis.', + + 'roles.tooltip.locked': 'Rôle intégré — verrouillé', + 'roles.tooltip.edit': 'Modifier', + 'roles.tooltip.delete': 'Supprimer', + + 'roles.confirm.delete': 'Supprimer le rôle "{name}" ?', + + // ── Agents ────────────────────────────────────────────────────────────────── + 'agents.title': 'Agents', + 'agents.loading': 'Chargement…', + 'agents.empty': 'Aucun agent trouvé.', + 'agents.back': 'Agents', + + 'agents.section.chat': 'Discussion', + 'agents.section.task': 'Exécuteurs de tâches', + 'agents.section.system': 'Système', + + 'agents.strength.very_high': 'Très élevée', + 'agents.strength.high': 'Élevée', + 'agents.strength.average': 'Moyenne', + 'agents.strength.low': 'Faible', + 'agents.strength.very_low': 'Très faible', + + 'agents.detail.meta': 'Métadonnées', + 'agents.detail.id': 'ID', + 'agents.detail.strength': 'Puissance', + 'agents.detail.scope': 'Portée', + 'agents.detail.pinned_model': 'Modèle épinglé', + 'agents.detail.memory_files': 'Fichiers mémoire', + 'agents.detail.model_order': 'Ordre de résolution des modèles', + 'agents.detail.model_order_desc': 'Modèles triés par leur adéquation avec les exigences de cet agent. Le système utilise le premier modèle disponible en partant du haut.', + 'agents.detail.no_models': 'Aucun modèle configuré.', + 'agents.detail.prompt': 'Prompt système', + 'agents.detail.default': 'défaut', + + 'agents.table.rank': '#', + 'agents.table.strength': 'Puissance', + 'agents.table.name': 'Nom', + 'agents.table.model_id': 'ID du modèle', + 'agents.table.scope': 'Portée', + + 'agents.banner.title': 'Vue en lecture seule. Les agents sont définis par des fichiers dans agents/ — pour ajouter, supprimer ou modifier un agent, modifiez le fichier AGENT.md correspondant dans ce répertoire.', + 'agents.banner.text': 'Vous pouvez aussi demander au Copilot (barre supérieure) de créer un nouvel agent pour vous — décrivez simplement ce qu\'il doit faire et il configurera tous les fichiers automatiquement.', + + // ── Connectors ────────────────────────────────────────────────────────────── + 'connectors.title': 'Connecteurs', + 'connectors.loading': 'Chargement…', + 'connectors.search': 'Rechercher des connecteurs…', + 'connectors.btn.signin_providers': 'Fournisseurs d\'authentification', + 'connectors.btn.catalog': 'Catalogue', + 'connectors.btn.marketplace': 'Marketplace', + 'connectors.empty.installed': 'Aucun connecteur installé pour le moment.', + 'connectors.empty.available': 'Rien de disponible pour vous pour le moment.', + 'connectors.empty.install_hint': 'Installez-en un depuis le Marketplace pour commencer.', + 'connectors.empty.ask_admin': 'Demandez à un administrateur d\'en rendre un disponible.', + 'connectors.empty.match': 'Aucun connecteur ne correspond à "{query}".', + + 'connectors.chip.global': 'global', + 'connectors.chip.per_user': 'par utilisateur', + 'connectors.chip.local_script': 'script local', + + 'connectors.status.active': 'actif', + 'connectors.status.needs_fix': 'nécessite une correction', + 'connectors.status.needs_signin': 'nécessite une connexion', + 'connectors.status.enabled': 'activé', + 'connectors.status.off': 'désactivé', + 'connectors.status.available': 'disponible', + + 'connectors.providers.title': 'Fournisseurs d\'authentification', + 'connectors.providers.desc': 'Applications OAuth par lesquelles les connecteurs par utilisateur se connectent. Une application (p. ex. Google) couvre tous ses services. Le secret client est stocké sur cette machine et ne sera plus jamais affiché.', + 'connectors.providers.empty': 'Aucun fournisseur d\'authentification pour le moment.', + 'connectors.providers.secret_set': 'secret défini', + 'connectors.providers.no_secret': 'pas de secret', + 'connectors.providers.no_client_id': '(pas d\'ID client)', + 'connectors.providers.add_google': 'Ajouter Google', + 'connectors.providers.add_other': 'Ajouter autre', + 'connectors.providers.save': 'Enregistrer', + 'connectors.providers.cancel': 'Annuler', + 'connectors.providers.delete_confirm': 'Supprimer le fournisseur d\'authentification "{name}" ?\n\nLes connecteurs qui l\'utilisent ne pourront plus se connecter.', + 'connectors.providers.error.name_client': 'Le nom et l\'ID client sont requis.', + 'connectors.providers.error.secret': 'Un secret client est requis pour un nouveau fournisseur.', + 'connectors.providers.field.name': 'ID du fournisseur', + 'connectors.providers.field.name_help': 'Le slug référencé par un connecteur (doit correspondre au auth.provider du manifeste).', + 'connectors.providers.field.display': 'Nom d\'affichage', + 'connectors.providers.field.client_id': 'ID client', + 'connectors.providers.field.client_secret': 'Secret client', + 'connectors.providers.field.secret_help_new': 'Requis.', + 'connectors.providers.field.secret_help_edit': 'Laissez vide pour conserver le secret stocké.', + 'connectors.providers.field.auth_url': 'URL d\'autorisation', + 'connectors.providers.field.token_url': 'URL du jeton', + 'connectors.providers.field.redirect': 'URI de redirection', + 'connectors.providers.field.redirect_help': 'La page de copier-coller. Doit être enregistrée comme redirection autorisée dans la console du fournisseur.', + 'connectors.providers.field.extra': 'Paramètres supplémentaires (JSON)', + 'connectors.providers.field.extra_help': 'Fusionné dans l\'URL de consentement. Google a besoin de ces deux éléments pour renvoyer un jeton d\'actualisation.', + + 'connectors.detail.back': 'Retour', + 'connectors.detail.global_note': 'S\'exécute une fois pour le groupe, sur l\'hôte. Personne n\'y accède avant d\'y être autorisé.', + 'connectors.detail.managed.title': 'Ce connecteur est géré pour vous.', + 'connectors.detail.managed.desc': 'Il est activé par un administrateur et vous est accordé — il n\'y a rien à configurer.', + 'connectors.detail.config.title_active': 'Configuration', + 'connectors.detail.config.title_setup': 'Configurer', + 'connectors.detail.config.already_global': 'Déjà activé. Une nouvelle soumission remplace les informations d\'identification stockées.', + 'connectors.detail.config.already_user': 'Déjà actif. Une nouvelle soumission remplace les informations d\'identification stockées.', + 'connectors.detail.config.api_key': 'Clé API', + 'connectors.detail.config.btn_test': 'Tester les informations', + 'connectors.detail.config.btn_testing': 'Test…', + 'connectors.detail.config.btn_enable_global': 'Activer globalement', + 'connectors.detail.config.btn_save_restart': 'Enregistrer et redémarrer', + 'connectors.detail.config.btn_disable': 'Désactiver', + 'connectors.detail.config.btn_activate': 'Activer', + 'connectors.detail.config.btn_deactivate': 'Désactiver', + 'connectors.detail.detail_scope_global': 'global', + 'connectors.detail.scope_local': 'exécute du code sur cette machine', + 'connectors.detail.status.active': 'actif', + 'connectors.detail.status.needs_fix': 'nécessite une correction', + 'connectors.detail.status.needs_signin': 'nécessite une connexion', + 'connectors.detail.confirm.deactivate': 'Désactiver "{name}" ?', + 'connectors.detail.confirm.disable_global': 'Désactiver "{name}" ?\n\nCela s\'arrête pour tous ceux qui peuvent l\'utiliser.', + + 'connectors.detail.oauth.title': 'Se connecter', + 'connectors.detail.oauth.desc': 'Se connecte avec {provider}. Vous approuvez l\'accès dans un onglet du navigateur, puis recollez le code que la page vous affiche — rien n\'est stocké sur cette machine tant que vous ne le faites pas.', + 'connectors.detail.oauth.scopes': 'Il demandera l\'accès à :', + 'connectors.detail.oauth.signed_in': 'Connecté et actif.', + 'connectors.detail.oauth.btn_signin': 'Se connecter avec {provider}', + 'connectors.detail.oauth.btn_signin_again':'Se connecter à nouveau', + 'connectors.detail.oauth.btn_finish': 'Terminer la connexion', + 'connectors.detail.oauth.btn_complete': 'Finaliser la connexion', + 'connectors.detail.oauth.step1': 'Un onglet s\'est ouvert pour {provider}. Approuvez l\'accès là-bas.', + 'connectors.detail.oauth.step1_link': 'Rouvrir la page de connexion', + 'connectors.detail.oauth.step2': 'Collez le code que la page vous a donné :', + 'connectors.detail.oauth.cancel': 'Annuler', + 'connectors.detail.oauth.deactivate': 'Désactiver', + + 'connectors.detail.test.running': 'Test des informations…', + 'connectors.detail.test.skipped': 'Aucune étape de vérification pour ce connecteur.', + 'connectors.detail.test.ok_label': 'OK', + 'connectors.detail.test.fail_label': 'Échec', + 'connectors.detail.test.error_saved': 'Enregistré, mais les informations n\'ont pas été vérifiées — corrigez-les et testez à nouveau.', + 'connectors.detail.test.error_verify': 'La vérification a échoué — le connecteur reste désactivé jusqu\'à ce que les informations soient corrigées.', + + 'connectors.detail.access.title': 'Qui peut l\'utiliser', + 'connectors.detail.access.desc': 'Cocher une case accorde les outils de ce connecteur à l\'agent de cette personne. L\'enregistrement remplace toute la liste.', + 'connectors.detail.access.empty': 'Aucun utilisateur.', + 'connectors.detail.access.save': 'Enregistrer les accès', + + 'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.', + + // ── Providers ─────────────────────────────────────────────────────────────── + 'providers.title': 'Fournisseurs', + 'providers.add': 'Ajouter', + 'providers.add_first': 'Ajouter votre premier fournisseur', + 'providers.empty': 'Aucun fournisseur configuré pour le moment.', + 'providers.count': '{n}', + + 'providers.card.models_title': 'Modèles utilisant ce fournisseur', + 'providers.card.api_key_configured': 'Clé API configurée', + 'providers.card.api_key_missing': 'Clé API manquante', + 'providers.card.edit': 'Modifier', + 'providers.card.delete': 'Supprimer', + 'providers.card.base_url': 'URL de base', + + 'providers.modal.add': 'Ajouter un fournisseur', + 'providers.modal.edit': 'Modifier le fournisseur', + 'providers.modal.name': 'Nom', + 'providers.modal.name_ph': 'ex. Mon Anthropic', + 'providers.modal.type': 'Type', + 'providers.modal.api_key': 'Clé API', + 'providers.modal.api_key_ph': 'Laissez vide pour conserver la clé existante', + 'providers.modal.base_url': 'URL de base', + 'providers.modal.base_url_ollama': 'http://localhost:11434', + 'providers.modal.base_url_oai': 'http://localhost:1234/v1', + 'providers.modal.description': 'Description', + 'providers.modal.description_optional': '(facultatif)', + 'providers.modal.cancel': 'Annuler', + 'providers.modal.saving': 'Enregistrement…', + 'providers.modal.save_changes': 'Enregistrer les modifications', + 'providers.modal.add_provider': 'Ajouter un fournisseur', + + 'providers.confirm.delete': 'Supprimer le fournisseur "{name}" ? Tous les modèles associés seront également supprimés.', + + // ── TIC Sessions ──────────────────────────────────────────────────────────── + 'tic.title': 'Sessions TIC', + 'tic.loading': 'Chargement…', + 'tic.empty': 'Aucune session TIC trouvée.', + 'tic.total': '{n} total', + 'tic.refresh': 'Actualiser', + + 'tic.table.agent': 'Agent', + 'tic.table.started': 'Démarrée', + 'tic.table.messages': 'Messages', + 'tic.table.last_activity':'Dernière activité', + + 'tic.pagination': 'Page {cur} sur {pages} — {total} sessions', + + // ── File viewer ───────────────────────────────────────────────────────────── + 'fv.back': 'Retour', + 'fv.download': 'Télécharger', + 'fv.mode_preview': 'Afficher l\'aperçu', + 'fv.mode_source': 'Afficher la source', + 'fv.binary_unavailable': 'Aperçu non disponible pour ce type de fichier.', + 'fv.latex_failed': 'Échec de la compilation LaTeX — affichage de la source à la place', + + // ── Marketplace ───────────────────────────────────────────────────────────── + 'marketplace.title': 'Marketplace', + 'marketplace.btn.catalog': 'Catalogue', + 'marketplace.action.refetch': 'Recharger le flux', + 'marketplace.not_admin': 'Le Marketplace est géré par l\'administrateur.', + 'marketplace.not_admin_link': 'Les connecteurs installés par l\'administrateur apparaissent sur la page Connecteurs.', + 'marketplace.desc': 'Connecteurs vérifiés que vous pouvez ajouter au catalogue de cette machine. Installer n\'active rien — cela rend un connecteur disponible.', + 'marketplace.feed_unreachable': 'Marketplace inaccessible — {error}', + 'marketplace.loading': 'Chargement du flux…', + + 'marketplace.filter.search': 'Rechercher des connecteurs…', + 'marketplace.filter.scope': 'Portée', + 'marketplace.filter.type': 'Type', + 'marketplace.filter.all': 'Tous', + 'marketplace.filter.global': 'Global', + 'marketplace.filter.per_user': 'Par utilisateur', + 'marketplace.filter.remote': 'Distant', + 'marketplace.filter.local': 'Local', + + 'marketplace.grid.empty_feed': 'Le flux est vide.', + 'marketplace.grid.no_match': 'Aucun connecteur ne correspond à ces filtres.', + + 'marketplace.card.installed': 'installé', + 'marketplace.card.scope_global': 'global', + 'marketplace.card.scope_per_user': 'par utilisateur', + 'marketplace.card.type_script': 'script local', + 'marketplace.card.type_remote': 'distant', + 'marketplace.card.files_one': '{n} fichier, vérifié SHA-256 à l\'installation', + 'marketplace.card.files_other': '{n} fichiers, vérifiés SHA-256 à l\'installation', + 'marketplace.card.oauth_scopes_one': 'Demande {n} portée OAuth', + 'marketplace.card.oauth_scopes_other': 'Demande {n} portées OAuth', + 'marketplace.card.installing': 'Installation…', + 'marketplace.card.reinstall': 'Réinstaller', + 'marketplace.card.install': 'Installer', + 'marketplace.card.homepage': 'Page d\'accueil', + + 'marketplace.confirm.install_warn': 'Ceci met du code sur cette machine :\n • {n} fichier(s), chacun vérifié avec SHA-256\n • installé dans ./connectors/{id}/', + 'marketplace.confirm.install_body':'Installer "{name}" dans le catalogue ?\n\nInstaller ne l\'active pas.', + + // ── Users ─────────────────────────────────────────────────────────────────── + 'users.title': 'Utilisateurs', + 'users.loading': 'Chargement…', + 'users.empty': 'Aucun utilisateur.', + 'users.count_one': '{n} utilisateur', + 'users.count_other': '{n} utilisateurs', + 'users.btn.new': 'Nouvel utilisateur', + + 'users.table.username': 'Nom d\'utilisateur', + 'users.table.display_name': 'Nom d\'affichage', + 'users.table.role': 'Rôle', + 'users.table.db': 'BD', + 'users.table.status': 'Statut', + + 'users.badge.encrypted': 'Chiffré', + 'users.badge.cleartext': 'En clair', + 'users.badge.active': 'Actif', + 'users.badge.inactive': 'Inactif', + + 'users.action.reset_pw': 'Réinitialiser le mot de passe', + 'users.action.edit': 'Modifier', + 'users.action.delete': 'Supprimer', + + 'users.modal.create_title': 'Nouvel utilisateur', + 'users.modal.edit_title': 'Modifier {username}', + 'users.modal.reset_title': 'Réinitialiser le mot de passe — {username}', + 'users.modal.username': 'Nom d\'utilisateur', + 'users.modal.display_name': 'Nom d\'affichage', + 'users.modal.optional': '(facultatif)', + 'users.modal.role': 'Rôle', + 'users.modal.password': 'Mot de passe', + 'users.modal.new_password': 'Nouveau mot de passe', + 'users.modal.encrypt': 'Chiffrer l\'historique des conversations', + 'users.modal.encrypt_warn': 'Attention : si le mot de passe est perdu, l\'historique des conversations est définitivement irrécupérable.', + 'users.modal.active': 'Actif', + 'users.modal.only_cleartext': 'Fonctionne uniquement pour les utilisateurs en clair (non chiffrés).', + 'users.modal.cancel': 'Annuler', + 'users.modal.create_btn': 'Créer', + 'users.modal.save_btn': 'Enregistrer', + 'users.modal.reset_btn': 'Réinitialiser', + + 'users.error.required_username_pw': 'Le nom d\'utilisateur et le mot de passe sont requis.', + 'users.error.required_username': 'Le nom d\'utilisateur est requis.', + 'users.error.password_empty': 'Le mot de passe ne peut pas être vide.', + + 'users.confirm.delete': 'Supprimer l\'utilisateur "{username}" ? Cela efface définitivement sa base de données et tout l\'historique des conversations.', + + // ── Catalog ───────────────────────────────────────────────────────────────── + 'catalog.title': 'Catalogue des connecteurs', + 'catalog.loading': 'Chargement…', + 'catalog.not_admin': 'Le catalogue est géré par l\'administrateur.', + 'catalog.not_admin_link': 'Ce que vous pouvez activer se trouve sur la page Connecteurs.', + 'catalog.desc': 'Ce que cette machine offre. Rien ici n\'est en cours d\'exécution — une entrée globale doit encore être activée, une entrée par utilisateur doit encore être activée par chaque utilisateur, les deux sur la page Connecteurs.', + 'catalog.empty.title': 'Le catalogue est vide.', + 'catalog.empty.hint': 'Ajoutez un connecteur depuis le Marketplace pour commencer.', + 'catalog.empty.action': 'Parcourir le Marketplace', + + 'catalog.btn.add': 'Ajouter un connecteur', + 'catalog.dropdown.marketplace': 'Depuis le Marketplace', + 'catalog.dropdown.marketplace_desc': 'Connecteurs vérifiés, fichiers vérifiés par SHA-256.', + 'catalog.dropdown.manual': 'Manuellement', + 'catalog.dropdown.manual_desc': 'Vous fournissez la configuration, et vous en portez la responsabilité.', + + 'catalog.table.connector': 'Connecteur', + 'catalog.table.scope': 'Portée', + 'catalog.table.type': 'Type', + 'catalog.table.auth': 'Auth', + + 'catalog.badge.global': 'global', + 'catalog.badge.per_user': 'par utilisateur', + 'catalog.badge.local_script':'script local', + 'catalog.badge.remote': 'distant', + + 'catalog.action.remove': 'Retirer du catalogue', + + 'catalog.modal.title': 'Ajouter un connecteur manuellement', + 'catalog.modal.script_warn': 'Un script local exécute du code sur cette machine. Rien ne le vérifie — contrairement au Marketplace, il n\'y a pas de condensé à contrôler.', + 'catalog.modal.name': 'Nom', + 'catalog.modal.name_hint': 'slug', + 'catalog.modal.scope': 'Portée', + 'catalog.modal.type': 'Type', + 'catalog.modal.transport': 'Transport', + 'catalog.modal.command': 'Commande', + 'catalog.modal.command_ph': 'python3', + 'catalog.modal.script_path': 'Chemin du script', + 'catalog.modal.script_path_hint': 'comme /, sous ./connectors', + 'catalog.modal.url': 'URL', + 'catalog.modal.args': 'Arguments', + 'catalog.modal.args_hint': 'un par ligne', + 'catalog.modal.config_schema': 'Clés secrètes/env requises', + 'catalog.modal.config_schema_hint': 'virgule/nouvelle ligne', + 'catalog.modal.auth': 'Auth', + 'catalog.modal.friendly': 'Nom convivial', + 'catalog.modal.desc': 'Description', + 'catalog.modal.desc_hint': 'le LLM lit ceci pour décider d\'activer le connecteur', + 'catalog.modal.cancel': 'Annuler', + 'catalog.modal.save': 'Ajouter au catalogue', + + 'catalog.error.name': 'Le nom est requis.', + 'catalog.confirm.delete': 'Retirer "{name}" du catalogue ?\n\nTout ce qui a déjà été activé continuera de fonctionner.', + + // ── Cron ──────────────────────────────────────────────────────────────────── + 'cron.title': 'Tâches Cron', + 'cron.count_one': '{n} tâche', + 'cron.count_other': '{n} tâches', + + 'cron.empty.title': 'Aucune tâche cron récurrente.', + 'cron.empty.hint': 'Demandez à l\'agent d\'en créer une avec execute_task.', + + 'cron.badge.running': 'en cours', + 'cron.badge.disabled': 'désactivée', + 'cron.badge.idle': 'inactive', + + 'cron.action.delete': 'Supprimer', + + 'cron.card.label_agent': 'Agent', + 'cron.card.label_last_run': 'Dernière exécution', + 'cron.card.label_next_run': 'Prochaine exécution', + 'cron.card.enabled': 'Activée', + 'cron.card.disabled': 'Désactivée', + + 'cron.confirm.delete': 'Supprimer la tâche "{title}" ?', + + // ── Common ───────────────────────────────────────────────────────────────── + 'common.save': 'Enregistrer', + 'common.saving': 'Enregistrement…', + 'common.cancel': 'Annuler', + 'common.loading': 'Chargement…', + + // ── Shared Folders (blueprint §6) ──────────────────────────────────────────── + 'nav.shared_folders': 'Dossiers partagés', + 'sf.title': 'Dossiers partagés', + 'sf.count': '{n} dossier', + 'sf.count_plural': '{n} dossiers', + 'sf.new': 'Nouveau dossier', + 'sf.loading': 'Chargement…', + 'sf.empty': 'Aucun dossier partagé pour le moment.', + 'sf.empty_hint': 'Créez-en un pour partager des fichiers avec d\'autres membres.', + 'sf.note.propagation': 'L\'ajout ou le retrait d\'un membre est appliqué immédiatement à son espace de travail.', + 'sf.members': 'Membres', + 'sf.no_members': 'Aucun membre pour le moment — seulement vous (admin) pouvez accéder à ce dossier.', + 'sf.no_desc': 'Pas encore de description — ajoutez-en une pour que l\'assistant sache quoi garder ici.', + 'sf.all_added': 'Tous les disponibles sont déjà membres.', + 'sf.choose_user': 'Choisir un membre…', + 'sf.add': 'Ajouter', + 'sf.remove': 'Retirer', + 'sf.edit_desc': 'Modifier la description', + 'sf.delete': 'Supprimer', + 'sf.access.label': 'Accès', + 'sf.access.read': 'Lecture', + 'sf.access.write': 'Écriture', + 'sf.access.readonly': 'Lecture seule', + 'sf.access.readwrite': 'Lecture et écriture', + 'sf.confirm.delete': 'Supprimer le dossier partagé "{name}" ?\n\nLes membres perdent l\'accès. Les fichiers sur le disque ne sont pas touchés.', + 'sf.confirm.remove_member': 'Retirer {name} de "{folder}" ?', + 'sf.error.name': 'Un nom de dossier est requis.', + 'sf.form.new': 'Nouveau dossier partagé', + 'sf.form.edit': 'Modifier "{name}"', + 'sf.form.name': 'Nom du dossier', + 'sf.form.name_hint': '(lettres, chiffres, tirets — pas de barres obliques)', + 'sf.form.name_ph': 'documents', + 'sf.form.name_desc': 'Devient shared/ dans l\'espace de travail de chaque membre. Ne peut pas être renommé ultérieurement.', + 'sf.form.desc': 'À quoi ça sert ?', + 'sf.form.desc_desc': 'L\'assistant lit ceci pour décider quoi stocker ici et quand chercher. Rédigez-le comme des instructions pour l\'assistant.', + 'sf.form.desc_ph': 'Factures, contrats et garanties. Enregistrez les PDFs et échéances ici ; vérifiez ici quand on vous interroge sur une facture ou une garantie.', + 'sf.form.create': 'Créer', + 'sf.form.save': 'Enregistrer', + 'sf.form.cancel': 'Annuler', +}; diff --git a/web/i18n/it.js b/web/i18n/it.js new file mode 100644 index 0000000..cd6d89e --- /dev/null +++ b/web/i18n/it.js @@ -0,0 +1,1078 @@ +export default { + // ── Navigazione ──────────────────────────────────────────────────────────── + 'nav.chat': 'Chat', + 'nav.inbox': 'Richieste', + 'nav.dashboard': 'Dashboard', + 'nav.projects': 'Progetti', + 'nav.tasks': 'Gestione attività', + 'nav.tasks.running': 'Attività in corso', + 'nav.tasks.cron': 'Attività ricorrenti', + 'nav.tasks.scheduled': 'Attività pianificate', + 'nav.tasks.history': 'Cronologia', + 'nav.models': 'Modelli', + 'nav.providers': 'Provider', + 'nav.security': 'Sicurezza', + 'nav.agents': 'Agenti', + 'nav.users': 'Utenti', + 'nav.roles': 'Ruoli', + 'nav.connectors': 'Connettori', + 'nav.catalog': 'Catalogo', + 'nav.config': 'Impostazioni', + 'nav.llm_requests': 'Richieste LLM', + 'nav.tic': 'Sessioni TIC', + + // ── Barra superiore ──────────────────────────────────────────────────────── + 'topbar.profile': 'Profilo', + 'topbar.logout': 'Esci', + 'topbar.account': 'Account', + 'topbar.open_chat': 'Apri la chat', + 'topbar.brand': 'Skald', + 'topbar.to_light': 'Passa al tema chiaro', + 'topbar.to_dark': 'Passa al tema scuro', + + // ── Chat ─────────────────────────────────────────────────────────────────── + 'chat.title': 'Skald', + 'chat.tab.general': 'Generale', + 'chat.hello': 'Ciao! Come posso aiutarti oggi?', + 'chat.greeting': 'Ciao!', + 'chat.greeting.named': 'Ciao, {name}!', + 'chat.greeting.sub': 'Cosa posso fare per te oggi?', + 'chat.placeholder': 'Scrivi un messaggio… (Invio per inviare, Maiusc+Invio per andare a capo)', + 'chat.mobile.placeholder': 'Scrivi un messaggio…', + 'chat.mobile.ask': 'Chiedimi qualsiasi cosa', + 'chat.mobile.back_general': 'Torna a Generale', + 'chat.mobile.project': 'Progetto', + 'chat.mobile.chat': 'Chat', + 'chat.mobile.record_voice': 'Registra voce', + 'chat.mobile.stop_record': 'Interrompi registrazione', + + 'mobile.coming_soon': 'Prossimamente', + 'mobile.nav.inbox': 'Richiesta', + 'mobile.nav.projects': 'Progetti', + 'mobile.nav.chat': 'Chat', + 'mobile.nav.alerts': 'Avvisi', + 'mobile.nav.settings': 'Impostazioni', + 'chat.send': 'Invia', + 'chat.stop': 'Ferma', + 'chat.thinking': 'Sto pensando…', + 'chat.attach': 'Allega file', + 'chat.new_session': 'Nuova conversazione', + 'chat.collapse': 'Nascondi la chat', + 'chat.close_tab': 'Chiudi scheda', + 'chat.privacy': 'Privata', + 'chat.privacy.hint': 'Solo tu puoi vedere questa conversazione. Condividere qualcosa con il gruppo richiede sempre prima un\'approvazione.', + 'chat.suggest.1': 'Cosa sai fare?', + 'chat.suggest.2': 'Aiutami a pianificare la giornata', + 'chat.suggest.3': 'Raccontami una storia', + 'chat.suggest.4': 'Cosa ricordi di me?', + 'chat.rejected': 'Negata.', + 'chat.rejected_by_user': 'Negata dall\'utente.', + 'chat.truncated': 'Risposta troncata dal limite di token (↓{tokens} tok).', + + // ── Copilot render ───────────────────────────────────────────────────────── + 'copilot.open_in_viewer': 'Apri nel visualizzatore', + 'copilot.unchanged_lines': '⋯ {n} righe invariate ⋯', + 'copilot.cancel': 'Annulla', + 'copilot.clarification_ph': 'Scrivi la risposta…', + 'copilot.send': 'Invia', + 'copilot.result': 'risultato', + 'copilot.error_label': 'errore', + 'copilot.not_sent_to_llm': 'Questo messaggio non viene inviato all\'LLM', + 'copilot.remove': 'Rimuovi', + 'copilot.result_json': 'risultato · json', + 'copilot.agent_done': 'completato', + 'copilot.agent_running': 'in esecuzione…', + 'copilot.agent_finished': 'finito', + 'copilot.status_awaiting': 'In attesa di approvazione', + 'copilot.status_cancelled': 'Annullato dall\'utente', + 'copilot.status_denied': 'Negato dalle regole', + 'copilot.bypass_session': 'Sessione', + 'copilot.bypass_15min': '15 min', + + // ── Copilot comandi slash ────────────────────────────────────────────────── + 'copilot.cmd.help': 'Mostra comandi disponibili', + 'copilot.cmd.clear': 'Avvia una nuova conversazione', + 'copilot.cmd.new': 'Alias per /clear', + 'copilot.cmd.models': 'Elenca i modelli LLM disponibili', + 'copilot.cmd.model': 'Seleziona il modello per questa chat', + 'copilot.cmd.context': 'Utilizzo token dell\'ultimo turno', + 'copilot.cmd.cost': 'Spesa sessione (USD)', + 'copilot.cmd.compact': 'Forza compattazione contesto', + 'copilot.cmd.resettools': 'Rimuovi gruppi di strumenti attivati', + 'copilot.cmd.sethome': 'Imposta web come home notifiche', + + // ── LLM Requests ─────────────────────────────────────────────────────────── + 'llmr.title': 'Richieste LLM', + 'llmr.loading': 'Caricamento…', + 'llmr.empty': 'Nessuna richiesta trovata.', + 'llmr.total': '{n} righe', + 'llmr.filter.agent_id': 'ID Agente', + 'llmr.filter.agent_ph': 'es. main', + 'llmr.filter.source': 'Sorgente', + 'llmr.filter.source_ph': 'es. web, tic, cron', + 'llmr.filter.from': 'Da', + 'llmr.filter.to': 'A', + 'llmr.filter.apply': 'Applica', + 'llmr.filter.reset': 'Reimposta', + 'llmr.table.agent': 'Agente', + 'llmr.table.source': 'Sorgente', + 'llmr.table.model': 'Modello', + 'llmr.table.date': 'Data', + 'llmr.table.in_tokens': 'Token in', + 'llmr.table.out_tokens': 'Token out', + 'llmr.table.cache_hit': 'Cache hit', + 'llmr.cache_read': 'lettura: {n} tk', + 'llmr.cache_write': 'scrittura: {n} tk', + 'llmr.pagination': 'Pagina {cur} di {pages} — {total} risultati', + + // ── LLM Request Detail ────────────────────────────────────────────────────── + 'llmr.detail.back': 'Indietro', + 'llmr.detail.loading': 'Caricamento…', + 'llmr.detail.request': 'Richiesta', + 'llmr.detail.no_agent': 'nessun agente', + 'llmr.detail.error_badge': 'errore', + 'llmr.detail.purged': 'Payload non disponibile — questa richiesta è stata eliminata dalla politica di conservazione.', + 'llmr.detail.reasoning_label': 'ragionamento', + 'llmr.detail.tool_params': 'Parametri', + 'llmr.detail.tool_result': 'Risultato', + 'llmr.detail.system_role': 'sistema', + 'llmr.detail.cache_label': 'cache {pct}', + 'llmr.detail.stat_input': 'Token in input', + 'llmr.detail.stat_output': 'Token in output', + + 'llmr.detail.section_req_headers': 'Intestazioni richiesta', + 'llmr.detail.section_resp_headers':'Intestazioni risposta', + 'llmr.detail.section_params': 'Parametri', + 'llmr.detail.section_system': 'Prompt di sistema', + 'llmr.detail.section_conversation':'Conversazione', + 'llmr.detail.section_tools': 'Strumenti definiti', + 'llmr.detail.section_response': 'Risposta', + + // ── Session detail ────────────────────────────────────────────────────────── + 'session.back': 'Indietro', + 'session.loading': 'Caricamento sessione…', + 'session.no_session': 'Nessuna sessione caricata.', + 'session.no_session_hint': 'Naviga su #session/{id} per vedere una sessione.', + 'session.empty': 'Nessun messaggio in questa sessione.', + 'session.user_role': 'Utente', + 'session.assistant_role': 'Assistente', + 'session.reasoning_label': 'Ragionamento', + 'session.thinking_role': 'Pensiero', + 'session.failed': 'fallito', + 'session.ephemeral': 'effimero', + 'session.automated': 'automatico', + 'session.live': 'dal vivo', + 'session.agent': 'agente:', + 'session.id': 'id:', + 'session.sub_agent': 'Sotto-agente:', + 'session.end_of': 'fine di', + 'session.depth': 'profondità {n}', + 'session.synthetic': 'sintetico', + 'session.tool_args': 'Argomenti', + 'session.tool_result': 'Risultato', + 'session.tool_error': 'Errore', + + // ── Config ────────────────────────────────────────────────────────────────── + 'config.title': 'Config', + 'config.loading': 'Caricamento…', + 'config.developer': 'Sviluppatore', + 'config.error_save':'Errore durante il salvataggio di "{name}": {msg}', + + // ── Projects ──────────────────────────────────────────────────────────────── + 'projects.title': 'Progetti', + 'projects.btn.new': 'Nuovo progetto', + 'projects.empty': 'Nessun progetto. Creane uno per iniziare.', + 'projects.action.edit': 'Modifica', + 'projects.action.delete': 'Elimina', + 'projects.card.updated': 'Aggiornato', + 'projects.confirm.delete': 'Eliminare il progetto "{name}"?\nTutti i ticket verranno eliminati.', + + 'projects.modal.title_edit': 'Modifica progetto', + 'projects.modal.title_new': 'Nuovo progetto', + 'projects.modal.name': 'Nome', + 'projects.modal.name_ph': 'Mio progetto', + 'projects.modal.path': 'Percorso', + 'projects.modal.path_ph': '/percorso/del/progetto', + 'projects.modal.desc': 'Descrizione', + 'projects.modal.desc_ph': 'Di cosa tratta questo progetto', + 'projects.modal.cancel': 'Annulla', + 'projects.modal.saving': 'Salvataggio…', + 'projects.modal.save': 'Salva', + 'projects.modal.create': 'Crea', + + // ── Project board ─────────────────────────────────────────────────────────── + 'project_board.back': 'Progetti', + 'project_board.open_chat': 'Apri chat', + 'project_board.new_ticket': 'Nuovo ticket', + 'project_board.tab.tickets': 'Ticket', + 'project_board.section.running': 'In esecuzione', + 'project_board.section.running_empty': 'Nessun ticket in esecuzione', + 'project_board.section.todo': 'Da fare', + 'project_board.section.todo_empty': 'Nessun ticket da fare', + 'project_board.section.completed': 'Completati', + 'project_board.section.completed_empty': 'Nessun ticket completato', + 'project_board.ticket.start': 'Avvia', + 'project_board.ticket.running': 'In esecuzione…', + 'project_board.ticket.reset': 'Reimposta', + 'project_board.ticket.result': 'Risultato', + 'project_board.ticket.error': 'Errore', + 'project_board.ticket.no_output': '(nessun output)', + 'project_board.ticket.no_error': '(nessun errore)', + 'project_board.modal.title': 'Nuovo ticket', + 'project_board.modal.title_label': 'Titolo', + 'project_board.modal.title_ph': 'Cosa bisogna fare', + 'project_board.modal.desc_label': 'Descrizione / Prompt', + 'project_board.modal.desc_ph': 'Istruzioni dettagliate per l\'agente…', + 'project_board.modal.agent': 'Agente', + 'project_board.modal.security_group':'Gruppo di sicurezza', + 'project_board.modal.inherit': '— eredita dal progetto —', + 'project_board.modal.cancel': 'Annulla', + 'project_board.modal.saving': 'Salvataggio…', + 'project_board.modal.create': 'Crea', + 'project_board.confirm.delete': 'Eliminare il ticket "{title}"?', + + // ── Richieste ────────────────────────────────────────────────────────────── + 'inbox.empty': 'Nessuna richiesta in attesa', + 'inbox.reject_prompt': 'Motivo del rifiuto (facoltativo):', + + // ── Approvazioni ─────────────────────────────────────────────────────────── + 'approval.pending': 'In attesa del tuo OK', + 'approval.approved': 'Consentito', + 'approval.rejected': 'Negato', + 'approval.approve': 'Consenti', + 'approval.reject': 'Nega', + 'approval.confirm_reject': 'Conferma il rifiuto', + 'approval.reject_hint': 'Facoltativo: spiega perché (lo leggerà l\'assistente)', + 'approval.bypass_15': 'Consenti e salta richieste simili per 15 minuti', + 'approval.bypass_all': 'Consenti e salta tutte le richieste di questa sessione', + + // ── Accesso ──────────────────────────────────────────────────────────────── + 'login.title': 'Bentornato', + 'login.subtitle': 'Accedi al tuo account.', + 'login.username': 'Nome utente', + 'login.password': 'Password', + 'login.submit': 'Accedi', + 'login.signing': 'Accesso in corso…', + 'login.missing': 'Inserisci nome utente e password.', + 'login.error': 'Nome utente o password non validi.', + 'login.network': 'Errore di rete — riprova.', + + // ── Profilo ──────────────────────────────────────────────────────────────── + 'profile.title': 'Profilo', + 'profile.account': 'Account', + 'profile.username': 'Nome utente', + 'profile.role': 'Ruolo', + 'profile.name': 'Nome visualizzato', + 'profile.name.ph': 'Il tuo nome', + 'profile.language': 'Lingua', + 'profile.language.default': 'Predefinita del gruppo ({locale})', + 'profile.saved': 'Salvato.', + 'profile.pw': 'Cambia password', + 'profile.pw.current': 'Password attuale', + 'profile.pw.new': 'Nuova password', + 'profile.pw.confirm': 'Conferma nuova password', + 'profile.pw.short': 'La password deve avere almeno 4 caratteri.', + 'profile.pw.mismatch': 'Le password non coincidono.', + 'profile.pw.changed': 'Password aggiornata.', + 'profile.pw.submit': 'Cambia password', + + // ── Impostazioni (extra pagina config) ───────────────────────────────────── + 'config.debug': 'Modalità debug', + 'config.debug.desc': 'Mostra le pagine per sviluppatori (richieste LLM, sessioni TIC) nella barra laterale.', + + // ── Configurazione iniziale ──────────────────────────────────────────────── + 'setup.title': 'Benvenuto in Skald', + 'setup.subtitle': 'Crea l\'account amministratore per iniziare.', + 'setup.username': 'Scegli un nome utente.', + 'setup.pw.short': 'La password deve avere almeno 4 caratteri.', + 'setup.pw.mismatch': 'Le due password non coincidono.', + 'setup.confirm': 'Conferma password', + 'setup.language': 'Lingua dell\'interfaccia', + 'setup.encrypt': 'Cifra la cronologia delle mie conversazioni', + 'setup.warn': 'la tua password genera la chiave di cifratura. Se la dimentichi, l\'intera cronologia delle conversazioni andrà persa per sempre — non esiste alcun recupero.', + 'setup.warn.strong': 'Attenzione:', + 'setup.submit': 'Crea account', + 'setup.creating': 'Creazione…', + 'setup.network': 'Errore di rete — riprova.', + + // ── Dashboard ─────────────────────────────────────────────────────────────── + 'dashboard.status.loading': 'Caricamento…', + 'dashboard.status.no_models': 'Nessun modello LLM', + 'dashboard.status.online': 'Online e funzionante', + 'dashboard.status.degraded': 'Degradato', + 'dashboard.status.offline': 'Tutti i modelli offline', + + 'dashboard.stats.loading': 'Caricamento statistiche…', + 'dashboard.stats.empty': 'Nessuna richiesta LLM nell\'intervallo selezionato.', + 'dashboard.stats.requests': 'Richieste {per}', + 'dashboard.stats.tokens': 'Token {per}', + 'dashboard.stats.latency': 'Latenza media (ms)', + 'dashboard.stats.models': 'Modelli', + 'dashboard.stats.per_min': '/ min', + 'dashboard.stats.per_hour': '/ h', + 'dashboard.stats.per_day': '/ giorno', + + 'dashboard.stats.range.hour': '1h', + 'dashboard.stats.range.day': '24h', + 'dashboard.stats.range.week': '7g', + 'dashboard.stats.range.month': '30g', + + 'dashboard.stats.chart.input': 'Input', + 'dashboard.stats.chart.output': 'Output', + 'dashboard.stats.chart.cached': 'In cache', + 'dashboard.stats.chart.non_cached': 'Non in cache', + 'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', + + 'dashboard.hero.subtitle': 'Il tuo centro di comando AI — ricerca, codice, pianificazione e orchestrazione. Tutto in un unico posto.', + + 'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.', + 'dashboard.banner.no_models.desc': 'Inizia aggiungendo un provider (Anthropic, OpenAI, OpenRouter…), poi aggiungi almeno un modello nella sezione Modelli.', + 'dashboard.banner.no_models.action': 'Aggiungi un provider', + + 'dashboard.section.stats': 'Statistiche LLM', + 'dashboard.section.pending': 'In sospeso', + 'dashboard.section.guide': 'Guida rapida', + + 'dashboard.tip.honcho.title': 'Abilita Honcho', + 'dashboard.tip.honcho.desc': 'Memoria persistente a lungo termine — l\'agente impara le tue preferenze col tempo. Chiedi al Copilot di attivarla.', + + 'dashboard.refresh': 'Aggiorna', + + 'dashboard.guide.chat.title': 'Chat', + 'dashboard.guide.chat.desc': 'La tua home page è una conversazione — sa tutto: chiedile di eseguire agenti, attivare plugin, scrivere codice o cercare sul web.', + 'dashboard.guide.inbox.title': 'Richieste', + 'dashboard.guide.inbox.desc': 'Approvazioni in sospeso e domande degli agenti che richiedono il tuo contributo prima che le attività in background possano continuare.', + 'dashboard.guide.agents.title': 'Agenti', + 'dashboard.guide.agents.desc': 'Sub-agenti specializzati (ingegnere, architetto, QA…). Ciascuno ha un prompt di sistema mirato, un set di strumenti e un modello specifico.', + 'dashboard.guide.cron.title': 'Cron', + 'dashboard.guide.cron.desc': 'Attività pianificate che vengono eseguite automaticamente a intervalli regolari, anche quando il Copilot è inattivo.', + 'dashboard.guide.models.title': 'Modelli', + 'dashboard.guide.models.desc': 'Gestisci modelli LLM, di trascrizione e generazione immagini. Trascina per riordinare le priorità.', + 'dashboard.guide.providers.title': 'Provider', + 'dashboard.guide.providers.desc': 'Aggiungi chiavi API per provider LLM (Anthropic, OpenAI, OpenRouter, Ollama…).', + 'dashboard.guide.security.title': 'Sicurezza', + 'dashboard.guide.security.desc': 'Definisci regole per auto-approvare o auto-rifiutare chiamate strumentali — salta le richieste di conferma ripetitive.', + + // ── Modelli (hub + sezioni) ────────────────────────────────────────────────── + 'models.hub.title': 'Modelli', + 'models.hub.subtitle': 'Configura provider LLM, trascrizione e generazione immagini.', + 'models.hub.count.none': 'Nessun modello', + 'models.hub.count.one': '1 modello', + 'models.hub.count.many': '{n} modelli', + + 'models.hub.card.llm.title': 'LLM', + 'models.hub.card.llm.desc': 'Modelli di chat e completamento per agenti e strumenti', + 'models.hub.card.transcribe.title': 'Trascrizione', + 'models.hub.card.transcribe.desc': 'Modelli di riconoscimento vocale tramite cloud o plugin locale', + 'models.hub.card.image.title': 'Generazione immagini', + 'models.hub.card.image.desc': 'Modelli text-to-image tramite API cloud', + 'models.hub.card.tts.title': 'Sintesi vocale', + 'models.hub.card.tts.desc': 'Modelli di sintesi vocale tramite cloud o plugin locale', + + 'models.back': 'Torna ai modelli', + 'models.add': 'Aggiungi', + 'models.add_model': 'Aggiungi modello', + 'models.add_first': 'Aggiungi il primo modello', + 'models.edit': 'Modifica', + 'models.delete': 'Elimina', + 'models.saving': 'Salvataggio…', + 'models.save_changes': 'Salva modifiche', + 'models.cancel': 'Annulla', + 'models.search': 'Cerca modelli…', + 'models.loading': 'Caricamento modelli…', + 'models.no_results': 'Nessun modello trovato', + 'models.enter_id': 'Inserisci manualmente l\'ID del modello', + 'models.managed_plugin': 'Gestito dal plugin', + 'models.readonly_plugin': 'I modelli con badge Plugin sono in sola lettura — gestiti automaticamente dal plugin che li ha registrati.', + 'models.readonly_plugin_full': 'I modelli con badge Plugin sono in sola lettura — gestiti automaticamente dal plugin che li ha registrati. Per aggiungerli, modificarli o rimuoverli, chiedi direttamente all\'agente: ha tutta la documentazione necessaria.', + 'models.default': 'predefinito', + 'models.move_up': 'Sposta su', + 'models.move_down': 'Sposta giù', + 'models.strength.very_high': 'Molto alta', + 'models.strength.high': 'Alta', + 'models.strength.average': 'Media', + 'models.strength.low': 'Bassa', + 'models.strength.very_low': 'Molto bassa', + 'models.strength.none': '— nessuna —', + 'models.reasoning.off': '— disattivato —', + 'models.reasoning.label': 'Ragionamento', + 'models.reasoning.thinking': 'Ragionamento (thinking)', + 'models.strength': 'Forza', + 'models.priority': 'Priorità', + 'models.scope': 'Ambito', + 'models.default_model': 'Modello predefinito', + 'models.extra_params': 'Parametri extra', + 'models.extra_params_hint': '(JSON, opzionale)', + 'models.model_id': 'ID modello', + 'models.model_id_hint': '(inviato all\'API)', + 'models.model_id_immutable': 'L\'ID modello non può essere modificato dopo la creazione.', + 'models.name_alias': 'Nome / Alias', + 'models.name_alias_hint': '(opzionale)', + 'models.name_alias_ph': 'uguale all\'ID modello', + 'models.edit_title': 'Modifica {name}', + 'models.edit_info': 'ID modello e provider non possono essere modificati. Per usare un modello diverso, aggiungi una nuova voce.', + 'models.name_help': 'Usato per riferirsi a questo modello (es. nel client di un agente). Deve essere unico.', + 'models.no_providers_llm': 'Aggiungi prima un Provider, poi torna qui per aggiungere modelli.', + 'models.no_providers_transcribe': 'Nessun provider supporta ancora la trascrizione. Aggiungi prima un provider OpenAI o OpenRouter.', + 'models.no_providers_image': 'Nessun provider supporta ancora la generazione immagini. Aggiungi prima un provider OpenRouter.', + 'models.no_providers_tts': 'Nessun provider supporta ancora la sintesi vocale. Aggiungi prima un provider OpenAI.', + 'models.list_empty_llm': 'Nessun modello configurato.', + 'models.list_empty_transcribe': 'Nessun modello di trascrizione configurato.', + 'models.list_empty_image': 'Nessun modello di generazione immagini configurato.', + 'models.list_empty_tts': 'Nessun modello TTS configurato.', + 'models.list_empty_add_hint': 'Clicca Aggiungi per aggiungere un modello cloud.', + 'models.list_empty_whisper': 'Attiva il plugin Whisper Local per la trascrizione su dispositivo.', + 'models.source': 'Origine', + 'models.source_plugin': 'Plugin', + 'models.source_cloud': 'Cloud', + 'models.name_col': 'Nome', + 'models.provider_col': 'Provider', + 'models.model_id_col': 'ID modello', + 'models.language_col': 'Lingua', + 'models.language_auto': 'auto', + 'models.choose_provider': 'Scegli provider', + 'models.add_model_title': 'Aggiungi modello', + 'models.model_label': 'Modello', + 'models.add_model_provider': 'Aggiungi modello {type} — Scegli provider', + 'models.add_model_type': 'Aggiungi modello {type}', + 'models.priority_hint': 'Numero più basso = provato per primo. Predefinito: 100.', + 'models.priority_hint_short': 'Numero più basso = usato per primo. Predefinito: 100.', + 'models.status_healthy': 'Funzionante', + 'models.status_degraded': 'Degradato', + 'models.status_down': 'Non disponibile', + + 'models.llm.title': 'Modelli LLM', + 'models.transcribe.title': 'Modelli di trascrizione', + 'models.image.title': 'Modelli di generazione immagini', + 'models.tts.title': 'Modelli di sintesi vocale', + + 'models.confirm_delete': 'Eliminare il modello {type} "{name}"?', + + 'models.error.save_order': 'Errore durante il salvataggio dell\'ordine: {msg}', + 'models.error.load_models': 'Errore durante il caricamento dei modelli: {msg}', + 'models.error.invalid_json': 'Parametri extra: JSON non valido', + 'models.error.select_model': 'Seleziona un modello', + + 'models.label.sent_to_api': '(inviato all\'API)', + 'models.label.optional': '(opzionale)', + 'models.label.bcp47': '(BCP-47, opzionale)', + 'models.label.required_elevenlabs': '(opzionale — richiesto per ElevenLabs)', + 'models.label.shown_to_llm': '(opzionale — mostrato all\'LLM)', + 'models.label.response_fmt': 'Formato risposta', + 'models.label.response_fmt_hint': '(opzionale)', + 'models.label.description': 'Descrizione', + 'models.label.description_hint': '(opzionale)', + 'models.label.instructions': 'Istruzioni', + 'models.label.instructions_hint': '(opzionale — mostrato all\'LLM)', + 'models.label.voice_id': 'ID voce', + 'models.label.voice_id_hint': '(opzionale — richiesto per ElevenLabs)', + 'models.label.max_output': 'Token massimi di output', + 'models.label.max_output_hint': '(opzionale)', + + 'models.ph.model_id': 'es. gpt-4o', + 'models.ph.model_id_transcribe': 'es. openai/whisper-1', + 'models.ph.model_id_tts': 'es. tts-1-hd', + 'models.ph.model_id_image': 'es. x-ai/grok-2-vision', + 'models.ph.name_alias': 'uguale all\'ID modello', + 'models.ph.language': 'es. it, en — lascia vuoto per rilevamento automatico', + 'models.ph.voice_id': 'es. alloy, Kore, 21m00Tcm4TlvDq8ikWAM', + 'models.ph.description': 'es. Alta qualità, lento — ideale per risposte lunghe', + 'models.ph.instructions': 'es. Parla con tono calmo e neutro. Fai una breve pausa tra le frasi.', + 'models.ph.max_tokens': 'fino a {n}', + + 'models.form.model_lock': 'L\'ID modello non può essere modificato dopo la creazione.', + 'models.form.voice_hint': 'Voce del parlante. OpenAI: alloy/echo/nova… (predefinito alloy se vuoto); Gemini: Kore/Puck/Zephyr…; ElevenLabs: l\'ID voce.', + 'models.form.instructions_hint': 'Guida per voce/tono iniettata nel prompt di sistema LLM quando questo modello è attivo.', + 'models.form.response_hint': 'Formato audio richiesto al provider. Lascia vuoto a meno che il modello non ne richieda uno specifico — es. Gemini TTS accetta solo pcm.', + 'models.form.response_default': 'Predefinito del provider (mp3)', + 'models.form.priority_img': 'Numero più basso = provato per primo. Predefinito: 100.', + 'models.form.name_as_provider': 'Nome / Alias (usato come provider_id nello strumento LLM)', + + 'models.tts.cost_multiplier': 'Moltiplicatore di costo rispetto alla tariffa base', + 'models.llm.price_tooltip': 'Input/Output per 1M token', + + // ── Regole di approvazione ────────────────────────────────────────────────── + 'approval.action.require': 'Richiedi', + 'approval.action.allow': 'Consenti', + 'approval.action.deny': 'Nega', + 'approval.chip.unset': '—', + 'approval.chip.req': 'Rich', + + 'approval.category.filesystem': 'File System', + 'approval.category.shell': 'Shell', + 'approval.category.subagent': 'Agenti', + 'approval.category.introspection': 'Introspezione', + 'approval.category.config': 'Config', + 'approval.category.dynamic': 'Dinamico', + + 'approval.fs.allow_read': 'Consenti lettura', + 'approval.fs.allow_write': 'Consenti scrittura', + 'approval.fs.deny': 'Nega', + 'approval.fs.require': 'Richiedi', + 'approval.fs.default': 'Richiedi (predefinito di sistema)', + + 'approval.error.enter_path': 'Inserisci un percorso di directory.', + 'approval.error.tool_required': 'Il pattern dello strumento è obbligatorio.', + 'approval.error.override_prio': 'Le regole di override devono avere priorità < 0.', + 'approval.error.lowprio_range': 'Le regole a bassa priorità devono avere priorità tra 1 e {max}.', + + 'approval.tool.any': 'Qualsiasi strumento', + 'approval.tool.any_mcp': 'Qualsiasi strumento MCP', + 'approval.tool.search': 'Cerca strumenti…', + 'approval.tool.no_results': 'Nessun risultato', + 'approval.tool.group_builtin': 'Integrati', + 'approval.tool.group_glob': 'Glob', + 'approval.tool.group_mcp': 'MCP · {server}', + + 'approval.form.new_override': 'Nuova regola di override', + 'approval.form.new_lowprio': 'Nuova regola a bassa priorità', + 'approval.form.edit': 'Modifica regola', + 'approval.form.tool_pattern': 'Pattern strumento', + 'approval.form.tool_pattern_ph': 'es. mcp__whatsapp__* o execute_cmd', + 'approval.form.tool_pattern_hint': 'Usa * come wildcard finale, es. mcp__whatsapp__*', + 'approval.form.select_tool': 'Seleziona strumento', + 'approval.form.path_pattern': 'Pattern percorso', + 'approval.form.path_pattern_ph': 'es. data/* o data/notes/*', + 'approval.form.path_pattern_hint': 'Filtra per percorso file. Usa * come wildcard.', + 'approval.form.action': 'Azione', + 'approval.form.priority': 'Priorità', + 'approval.form.priority_override_hint': 'Deve essere < 0 (es. −10)', + 'approval.form.priority_lowprio_hint': 'Deve essere 1 – {max}', + 'approval.form.source': 'Origine', + 'approval.form.source_any': 'Qualsiasi', + 'approval.form.agent_id': 'ID agente', + 'approval.form.agent_id_ph': 'main (vuoto = qualsiasi)', + 'approval.form.note': 'Nota', + 'approval.form.note_ph': 'Breve descrizione…', + 'approval.form.cancel': 'Annulla', + 'approval.form.saving': 'Salvataggio…', + 'approval.form.save': 'Salva', + + 'approval.card.priority': 'Priorità', + 'approval.card.edit': 'Modifica', + 'approval.card.delete': 'Elimina', + 'approval.card.remove': 'Rimuovi', + + 'approval.matrix.title': 'Per strumento', + 'approval.matrix.subtitle': 'priorità = 0 · nome esatto · nessun filtro percorso/origine', + 'approval.matrix.loading': 'Caricamento strumenti…', + + 'approval.fs.title': 'File System', + 'approval.fs.subtitle': 'accesso in lettura/scrittura per percorso', + 'approval.fs.empty': 'Nessuna regola di percorso — aggiungine una qui sotto.', + 'approval.fs.add_ph': 'Aggiungi percorso directory, es. docs', + 'approval.fs.default_label': 'Predefinito', + 'approval.fs.default_hint': 'percorsi non configurati', + + 'approval.sidebar.overrides': 'Override', + 'approval.sidebar.overrides_sub': 'priorità < 0 · valutate per prime', + 'approval.sidebar.lowprio': 'Bassa priorità', + 'approval.sidebar.lowprio_sub': 'priorità 1–999998 · valutate dopo per-strumento', + 'approval.sidebar.add': 'Aggiungi', + 'approval.sidebar.empty': 'Nessuna regola.', + + 'approval.default_bar.title': 'Azione predefinita', + 'approval.default_bar.hint': 'se nessuna regola corrisponde', + 'approval.default_bar.unset': 'predefinito di sistema: consenti', + + 'approval.header.default_badge': 'Predefinito', + 'approval.header.rule_count': '{n} regola', + 'approval.header.rule_count_plural': '{n} regole', + + 'approval.confirm.delete_fs': 'Rimuovere la regola File System per "{path}"?', + 'approval.confirm.delete_rule': 'Eliminare la regola per "{pattern}"?', + 'approval.label.optional': '(opzionale)', + + // ── Sicurezza (gruppi) ────────────────────────────────────────────────────── + 'security.title': 'Sicurezza', + 'security.group_count': '{n} gruppo', + 'security.group_count_plural': '{n} gruppi', + 'security.new_group': 'Nuovo gruppo', + 'security.rename_group': 'Rinomina gruppo', + 'security.duplicate': 'Duplica', + 'security.duplicate_title': 'Duplica {name}', + 'security.duplicating': 'Duplicazione…', + 'security.create_first': 'Crea il primo gruppo', + + 'security.form.id': 'ID', + 'security.form.id_ph': 'es. cron_strict', + 'security.form.id_hint': 'Slug minuscolo, senza spazi. Non può essere modificato in seguito.', + 'security.form.name': 'Nome', + 'security.form.name_ph': 'es. Cron strict', + 'security.form.description': 'Descrizione', + 'security.form.description_ph': 'Breve descrizione…', + 'security.form.new_name': 'Nuovo nome', + 'security.form.new_id': 'Nuovo ID', + 'security.form.copy_info': 'Tutte le {n} regola{s} da {name} verranno copiate.', + 'security.form.cancel': 'Annulla', + 'security.form.saving': 'Salvataggio…', + 'security.form.save': 'Salva', + + 'security.card.default_badge': 'Predefinito', + 'security.card.rule_count': '{n} regola', + 'security.card.rule_count_plural': '{n} regole', + 'security.card.duplicate': 'Duplica', + 'security.card.rename': 'Rinomina', + 'security.card.delete_disabled': 'Non puoi eliminare il gruppo predefinito', + 'security.card.delete': 'Elimina gruppo', + + 'security.confirm.delete': 'Eliminare il gruppo "{name}"?', + 'security.confirm.delete_with_rules': 'Eliminare il gruppo "{name}" e le sue {n} regola{s}?', + + 'security.banner.text1': 'I gruppi di permessi sono insiemi nominati di regole di approvazione. Il Profilo agente attivo di una sessione determina quale gruppo si applica — le regole di quel gruppo vengono valutate per prime, con il gruppo Predefinito come fallback.', + 'security.banner.text2': 'Clicca un gruppo per visualizzare e gestire le sue regole. Il gruppo Predefinito non può essere eliminato, ma le sue regole possono essere modificate liberamente.', + + 'security.empty.title': 'Nessun gruppo.', + + 'security.error.name_required': 'Il nome è obbligatorio.', + 'security.error.id_required': 'L\'ID è obbligatorio.', + 'security.error.group_name_required': 'Il nome del gruppo è obbligatorio.', + 'security.error.group_id_required': 'L\'ID del gruppo è obbligatorio.', + + // ── Ruoli ─────────────────────────────────────────────────────────────────── + 'roles.title': 'Ruoli', + 'roles.count': '{n} ruolo', + 'roles.count_plural': '{n} ruoli', + 'roles.new_role': 'Nuovo ruolo', + 'roles.loading': 'Caricamento…', + 'roles.empty': 'Nessun ruolo.', + + 'roles.col.id': 'ID', + 'roles.col.label': 'Etichetta', + 'roles.col.group': 'Gruppo di permessi', + 'roles.col.interface': 'Interfaccia', + + 'roles.badge.simple': 'Semplice', + 'roles.badge.full': 'Completa', + + 'roles.form.new': 'Nuovo ruolo', + 'roles.form.edit': 'Modifica {name}', + 'roles.form.id': 'ID', + 'roles.form.id_hint': '(slug)', + 'roles.form.id_ph': 'es. editor', + 'roles.form.id_desc': 'Minuscolo, senza spazi. Non può essere modificato in seguito.', + 'roles.form.label': 'Etichetta', + 'roles.form.group': 'Gruppo di permessi', + 'roles.form.interface': 'Interfaccia', + 'roles.form.interface_full': 'Completa — tutte le pagine', + 'roles.form.interface_simple': 'Semplice — solo chat', + 'roles.form.interface_hint': 'I membri con interfaccia semplice vedono solo chat e richieste. Memorizzato come ui_mode nell\'attrs JSON qui sotto.', + 'roles.form.attrs': 'Attrs', + 'roles.form.attrs_hint': '(JSON, opzionale)', + 'roles.form.attrs_ph': '{}', + 'roles.form.cancel': 'Annulla', + 'roles.form.create': 'Crea', + 'roles.form.save': 'Salva', + + 'roles.error.id_label': 'ID ed etichetta sono obbligatori.', + 'roles.error.label': 'L\'etichetta è obbligatoria.', + + 'roles.tooltip.locked': 'Ruolo integrato — bloccato', + 'roles.tooltip.edit': 'Modifica', + 'roles.tooltip.delete': 'Elimina', + + 'roles.confirm.delete': 'Eliminare il ruolo "{name}"?', + + // ── Agenti ────────────────────────────────────────────────────────────────── + 'agents.title': 'Agenti', + 'agents.loading': 'Caricamento…', + 'agents.empty': 'Nessun agente trovato.', + 'agents.back': 'Agenti', + + 'agents.section.chat': 'Chat', + 'agents.section.task': 'Esecutori attività', + 'agents.section.system': 'Sistema', + + 'agents.strength.very_high': 'Molto alta', + 'agents.strength.high': 'Alta', + 'agents.strength.average': 'Media', + 'agents.strength.low': 'Bassa', + 'agents.strength.very_low': 'Molto bassa', + + 'agents.detail.meta': 'Metadati', + 'agents.detail.id': 'ID', + 'agents.detail.strength': 'Forza', + 'agents.detail.scope': 'Ambito', + 'agents.detail.pinned_model': 'Modello fissato', + 'agents.detail.memory_files': 'File di memoria', + 'agents.detail.model_order': 'Ordine di risoluzione modelli', + 'agents.detail.model_order_desc': 'Modelli ordinati per quanto corrispondono ai requisiti di questo agente. Il sistema usa il primo modello disponibile partendo dall\'alto.', + 'agents.detail.no_models': 'Nessun modello configurato.', + 'agents.detail.prompt': 'Prompt di sistema', + 'agents.detail.default': 'predefinito', + + 'agents.table.rank': '#', + 'agents.table.strength': 'Forza', + 'agents.table.name': 'Nome', + 'agents.table.model_id': 'ID modello', + 'agents.table.scope': 'Ambito', + + 'agents.banner.title': 'Vista sola lettura. Gli agenti sono definiti da file in agents/ — per aggiungere, rimuovere o modificare un agente, modifica il corrispondente file AGENT.md in quella directory.', + 'agents.banner.text': 'Puoi anche chiedere a Copilot (barra superiore) di creare un nuovo agente per te — descrivi cosa dovrebbe fare e configurerà automaticamente tutti i file necessari.', + + // ── Connettori ────────────────────────────────────────────────────────────── + 'connectors.title': 'Connettori', + 'connectors.loading': 'Caricamento…', + 'connectors.search': 'Cerca connettori…', + 'connectors.btn.signin_providers': 'Provider di accesso', + 'connectors.btn.catalog': 'Catalogo', + 'connectors.btn.marketplace': 'Marketplace', + 'connectors.empty.installed': 'Nessun connettore installato.', + 'connectors.empty.available': 'Niente di disponibile per te.', + 'connectors.empty.install_hint': 'Installane uno dal Marketplace per iniziare.', + 'connectors.empty.ask_admin': 'Chiedi a un amministratore di renderne disponibile uno.', + 'connectors.empty.match': 'Nessun connettore corrisponde a "{query}".', + + 'connectors.chip.global': 'globale', + 'connectors.chip.per_user': 'per utente', + 'connectors.chip.local_script': 'script locale', + + 'connectors.status.active': 'attivo', + 'connectors.status.needs_fix': 'da sistemare', + 'connectors.status.needs_signin': 'richiede accesso', + 'connectors.status.enabled': 'abilitato', + 'connectors.status.off': 'spento', + 'connectors.status.available': 'disponibile', + + 'connectors.providers.title': 'Provider di accesso', + 'connectors.providers.desc': 'App OAuth attraverso cui i connettori per utente si autenticano. Un\'app (es. Google) copre tutti i suoi servizi. Il client secret è memorizzato su questo computer e non verrà mai più mostrato.', + 'connectors.providers.empty': 'Nessun provider di accesso.', + 'connectors.providers.secret_set': 'secret impostato', + 'connectors.providers.no_secret': 'nessun secret', + 'connectors.providers.no_client_id': '(nessun client id)', + 'connectors.providers.add_google': 'Aggiungi Google', + 'connectors.providers.add_other': 'Aggiungi altro', + 'connectors.providers.save': 'Salva', + 'connectors.providers.cancel': 'Annulla', + 'connectors.providers.delete_confirm': 'Eliminare il provider di accesso "{name}"?\n\nI connettori che lo usano non potranno più accedere.', + 'connectors.providers.error.name_client': 'Nome e client id sono obbligatori.', + 'connectors.providers.error.secret': 'Il client secret è obbligatorio per un nuovo provider.', + 'connectors.providers.field.name': 'ID provider', + 'connectors.providers.field.name_help': 'Lo slug a cui i connettori fanno riferimento (deve corrispondere a auth.provider del manifest).', + 'connectors.providers.field.display': 'Nome visualizzato', + 'connectors.providers.field.client_id': 'Client id', + 'connectors.providers.field.client_secret': 'Client secret', + 'connectors.providers.field.secret_help_new': 'Obbligatorio.', + 'connectors.providers.field.secret_help_edit': 'Lascia vuoto per mantenere il secret memorizzato.', + 'connectors.providers.field.auth_url': 'URL di autorizzazione', + 'connectors.providers.field.token_url': 'URL del token', + 'connectors.providers.field.redirect': 'URI di reindirizzamento', + 'connectors.providers.field.redirect_help': 'La pagina copia-e-incolla. Deve essere registrata come reindirizzamento autorizzato nella console del provider.', + 'connectors.providers.field.extra': 'Parametri extra (JSON)', + 'connectors.providers.field.extra_help': 'Uniti all\'URL di consenso. Google necessita di questi due per restituire un refresh token.', + + 'connectors.detail.back': 'Indietro', + 'connectors.detail.global_note': 'Eseguito una volta per il gruppo, sull\'host. Nessuno può raggiungerlo finché non gli viene concesso l\'accesso.', + 'connectors.detail.managed.title': 'Questo connettore è gestito per te.', + 'connectors.detail.managed.desc': 'È abilitato da un amministratore e ti è stato concesso — non c\'è niente da configurare.', + 'connectors.detail.config.title_active': 'Configurazione', + 'connectors.detail.config.title_setup': 'Imposta', + 'connectors.detail.config.already_global': 'Già abilitato. Re-inviando si sostituiscono le credenziali memorizzate.', + 'connectors.detail.config.already_user': 'Già attivo. Re-inviando si sostituiscono le credenziali memorizzate.', + 'connectors.detail.config.api_key': 'Chiave API', + 'connectors.detail.config.btn_test': 'Verifica credenziali', + 'connectors.detail.config.btn_testing': 'Verifica…', + 'connectors.detail.config.btn_enable_global': 'Abilita globalmente', + 'connectors.detail.config.btn_save_restart': 'Salva e riavvia', + 'connectors.detail.config.btn_disable': 'Disabilita', + 'connectors.detail.config.btn_activate': 'Attiva', + 'connectors.detail.config.btn_deactivate': 'Disattiva', + 'connectors.detail.detail_scope_global': 'globale', + 'connectors.detail.scope_local': 'esegue codice su questo computer', + 'connectors.detail.status.active': 'attivo', + 'connectors.detail.status.needs_fix': 'da sistemare', + 'connectors.detail.status.needs_signin': 'richiede accesso', + 'connectors.detail.confirm.deactivate': 'Disattivare "{name}"?', + 'connectors.detail.confirm.disable_global': 'Disabilitare "{name}"?\n\nSi ferma per tutti coloro che possono usarlo.', + + 'connectors.detail.oauth.title': 'Accesso', + 'connectors.detail.oauth.desc': 'Accede con {provider}. Approvi l\'accesso in una scheda del browser, poi incolli il codice che la pagina ti mostra — niente viene memorizzato su questo computer finché non lo fai.', + 'connectors.detail.oauth.scopes': 'Richiederà accesso a:', + 'connectors.detail.oauth.signed_in': 'Accesso effettuato e attivo.', + 'connectors.detail.oauth.btn_signin': 'Accedi con {provider}', + 'connectors.detail.oauth.btn_signin_again':'Accedi di nuovo', + 'connectors.detail.oauth.btn_finish': 'Completa accesso', + 'connectors.detail.oauth.btn_complete': 'Completa accesso', + 'connectors.detail.oauth.step1': 'Una scheda si è aperta per {provider}. Approva l\'accesso lì.', + 'connectors.detail.oauth.step1_link': 'Riapri la pagina di accesso', + 'connectors.detail.oauth.step2': 'Incolla il codice che la pagina ti ha mostrato:', + 'connectors.detail.oauth.cancel': 'Annulla', + 'connectors.detail.oauth.deactivate': 'Disattiva', + + 'connectors.detail.test.running': 'Verifica credenziali…', + 'connectors.detail.test.skipped': 'Nessun passaggio di verifica per questo connettore.', + 'connectors.detail.test.ok_label': 'OK', + 'connectors.detail.test.fail_label': 'Fallito', + 'connectors.detail.test.error_saved': 'Salvato, ma le credenziali non hanno superato la verifica — correggile e prova di nuovo.', + 'connectors.detail.test.error_verify': 'Verifica fallita — il connettore rimane disabilitato finché le credenziali non vengono corrette.', + + 'connectors.detail.access.title': 'Chi può usarlo', + 'connectors.detail.access.desc': 'Selezionando un utente gli vengono concessi gli strumenti di questo connettore. Salvando si sostituisce l\'intera lista.', + 'connectors.detail.access.empty': 'Nessun utente.', + 'connectors.detail.access.save': 'Salva accesso', + + 'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.', + + // ── Provider ──────────────────────────────────────────────────────────────── + 'providers.title': 'Provider', + 'providers.add': 'Aggiungi', + 'providers.add_first': 'Aggiungi il primo provider', + 'providers.empty': 'Nessun provider configurato.', + 'providers.count': '{n}', + + 'providers.card.models_title': 'Modelli che usano questo provider', + 'providers.card.api_key_configured': 'Chiave API configurata', + 'providers.card.api_key_missing': 'Chiave API mancante', + 'providers.card.edit': 'Modifica', + 'providers.card.delete': 'Elimina', + 'providers.card.base_url': 'URL base', + + 'providers.modal.add': 'Aggiungi provider', + 'providers.modal.edit': 'Modifica provider', + 'providers.modal.name': 'Nome', + 'providers.modal.name_ph': 'es. Il mio Anthropic', + 'providers.modal.type': 'Tipo', + 'providers.modal.api_key': 'Chiave API', + 'providers.modal.api_key_ph': 'Lascia vuoto per mantenere la chiave esistente', + 'providers.modal.base_url': 'URL base', + 'providers.modal.base_url_ollama': 'http://localhost:11434', + 'providers.modal.base_url_oai': 'http://localhost:1234/v1', + 'providers.modal.description': 'Descrizione', + 'providers.modal.description_optional': '(opzionale)', + 'providers.modal.cancel': 'Annulla', + 'providers.modal.saving': 'Salvataggio…', + 'providers.modal.save_changes': 'Salva modifiche', + 'providers.modal.add_provider': 'Aggiungi provider', + + 'providers.confirm.delete': 'Eliminare il provider "{name}"? Tutti i modelli associati verranno eliminati.', + + // ── TIC Sessions ──────────────────────────────────────────────────────────── + 'tic.title': 'Sessioni TIC', + 'tic.loading': 'Caricamento…', + 'tic.empty': 'Nessuna sessione TIC trovata.', + 'tic.total': '{n} totale', + 'tic.refresh': 'Aggiorna', + + 'tic.table.agent': 'Agente', + 'tic.table.started': 'Iniziata', + 'tic.table.messages': 'Messaggi', + 'tic.table.last_activity':'Ultima attività', + + 'tic.pagination': 'Pagina {cur} di {pages} — {total} sessioni', + + // ── File viewer ────────────────────────────────────────────────────────────── + 'fv.back': 'Indietro', + 'fv.download': 'Scarica', + 'fv.mode_preview': 'Mostra anteprima', + 'fv.mode_source': 'Mostra sorgente', + 'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.', + 'fv.latex_failed': 'Compilazione LaTeX fallita — mostra il sorgente', + + // ── Marketplace ────────────────────────────────────────────────────────────── + 'marketplace.title': 'Marketplace', + 'marketplace.btn.catalog': 'Catalogo', + 'marketplace.action.refetch': 'Ricarica il feed', + 'marketplace.not_admin': 'Il marketplace è gestito dall\'amministratore.', + 'marketplace.not_admin_link': 'I connettori installati dall\'amministratore appaiono nella pagina Connettori.', + 'marketplace.desc': 'Connettori verificati che puoi aggiungere al catalogo di questo computer. Installare non attiva nulla — rende un connettore disponibile.', + 'marketplace.feed_unreachable': 'Marketplace irraggiungibile — {error}', + 'marketplace.loading': 'Caricamento feed…', + + 'marketplace.filter.search': 'Cerca connettori…', + 'marketplace.filter.scope': 'Ambito', + 'marketplace.filter.type': 'Tipo', + 'marketplace.filter.all': 'Tutti', + 'marketplace.filter.global': 'Globale', + 'marketplace.filter.per_user': 'Per utente', + 'marketplace.filter.remote': 'Remoto', + 'marketplace.filter.local': 'Locale', + + 'marketplace.grid.empty_feed': 'Il feed è vuoto.', + 'marketplace.grid.no_match': 'Nessun connettore corrisponde a questi filtri.', + + 'marketplace.card.installed': 'installato', + 'marketplace.card.scope_global': 'globale', + 'marketplace.card.scope_per_user': 'per utente', + 'marketplace.card.type_script': 'script locale', + 'marketplace.card.type_remote': 'remoto', + 'marketplace.card.files_one': '{n} file, verificato SHA-256 all\'installazione', + 'marketplace.card.files_other': '{n} file, verificati SHA-256 all\'installazione', + 'marketplace.card.oauth_scopes_one': 'Richiede {n} ambito OAuth', + 'marketplace.card.oauth_scopes_other': 'Richiede {n} ambiti OAuth', + 'marketplace.card.installing': 'Installazione…', + 'marketplace.card.reinstall': 'Reinstalla', + 'marketplace.card.install': 'Installa', + 'marketplace.card.homepage': 'Homepage', + + 'marketplace.confirm.install_warn': 'Questo mette codice sul computer:\n • {n} file, ciascuno verificato con SHA-256\n • installato in ./connectors/{id}/', + 'marketplace.confirm.install_body':'Installare "{name}" nel catalogo?\n\nInstallare non lo attiva.', + + // ── Utenti ────────────────────────────────────────────────────────────────── + 'users.title': 'Utenti', + 'users.loading': 'Caricamento…', + 'users.empty': 'Nessun utente.', + 'users.count_one': '{n} utente', + 'users.count_other': '{n} utenti', + 'users.btn.new': 'Nuovo utente', + + 'users.table.username': 'Nome utente', + 'users.table.display_name': 'Nome visualizzato', + 'users.table.role': 'Ruolo', + 'users.table.db': 'DB', + 'users.table.status': 'Stato', + + 'users.badge.encrypted': 'Cifrato', + 'users.badge.cleartext': 'In chiaro', + 'users.badge.active': 'Attivo', + 'users.badge.inactive': 'Inattivo', + + 'users.action.reset_pw': 'Reimposta password', + 'users.action.edit': 'Modifica', + 'users.action.delete': 'Elimina', + + 'users.modal.create_title': 'Nuovo utente', + 'users.modal.edit_title': 'Modifica {username}', + 'users.modal.reset_title': 'Reimposta password — {username}', + 'users.modal.username': 'Nome utente', + 'users.modal.display_name': 'Nome visualizzato', + 'users.modal.optional': '(opzionale)', + 'users.modal.role': 'Ruolo', + 'users.modal.password': 'Password', + 'users.modal.new_password': 'Nuova password', + 'users.modal.encrypt': 'Cifra cronologia conversazioni', + 'users.modal.encrypt_warn': 'Attenzione: se la password viene persa, la cronologia delle conversazioni non sarà mai più recuperabile.', + 'users.modal.active': 'Attivo', + 'users.modal.only_cleartext': 'Funziona solo per utenti in chiaro (non cifrati).', + 'users.modal.cancel': 'Annulla', + 'users.modal.create_btn': 'Crea', + 'users.modal.save_btn': 'Salva', + 'users.modal.reset_btn': 'Reimposta', + + 'users.error.required_username_pw': 'Nome utente e password sono obbligatori.', + 'users.error.required_username': 'Il nome utente è obbligatorio.', + 'users.error.password_empty': 'La password non può essere vuota.', + + 'users.confirm.delete': 'Eliminare l\'utente "{username}"? Questo cancella definitivamente il database e tutta la cronologia delle conversazioni.', + + // ── Catalogo ──────────────────────────────────────────────────────────────── + 'catalog.title': 'Catalogo connettori', + 'catalog.loading': 'Caricamento…', + 'catalog.not_admin': 'Il catalogo è gestito dall\'amministratore.', + 'catalog.not_admin_link': 'Quello che puoi attivare è nella pagina Connettori.', + 'catalog.desc': 'Cosa offre questo computer. Niente qui è in esecuzione — una voce globale necessita comunque di essere abilitata, una per utente necessita che ogni utente la attivi, entrambe nella pagina Connettori.', + 'catalog.empty.title': 'Il catalogo è vuoto.', + 'catalog.empty.hint': 'Aggiungi un connettore dal marketplace per iniziare.', + 'catalog.empty.action': 'Sfoglia il marketplace', + + 'catalog.btn.add': 'Aggiungi connettore', + 'catalog.dropdown.marketplace': 'Dal marketplace', + 'catalog.dropdown.marketplace_desc': 'Connettori verificati, file controllati tramite SHA-256.', + 'catalog.dropdown.manual': 'Manualmente', + 'catalog.dropdown.manual_desc': 'Fornisci tu la configurazione e te ne assumi la responsabilità.', + + 'catalog.table.connector': 'Connettore', + 'catalog.table.scope': 'Ambito', + 'catalog.table.type': 'Tipo', + 'catalog.table.auth': 'Auth', + + 'catalog.badge.global': 'globale', + 'catalog.badge.per_user': 'per utente', + 'catalog.badge.local_script':'script locale', + 'catalog.badge.remote': 'remoto', + + 'catalog.action.remove': 'Rimuovi dal catalogo', + + 'catalog.modal.title': 'Aggiungi connettore manualmente', + 'catalog.modal.script_warn': 'Uno script locale esegue codice su questo computer. Niente lo verifica — a differenza del marketplace, non c\'è un digest da controllare.', + 'catalog.modal.name': 'Nome', + 'catalog.modal.name_hint': 'slug', + 'catalog.modal.scope': 'Ambito', + 'catalog.modal.type': 'Tipo', + 'catalog.modal.transport': 'Trasporto', + 'catalog.modal.command': 'Comando', + 'catalog.modal.command_ph': 'python3', + 'catalog.modal.script_path': 'Percorso script', + 'catalog.modal.script_path_hint': 'come /, sotto ./connectors', + 'catalog.modal.url': 'URL', + 'catalog.modal.args': 'Argomenti', + 'catalog.modal.args_hint': 'uno per riga', + 'catalog.modal.config_schema': 'Chiavi segrete/env richieste', + 'catalog.modal.config_schema_hint': 'virgola/nuova riga', + 'catalog.modal.auth': 'Auth', + 'catalog.modal.friendly': 'Nome visualizzato', + 'catalog.modal.desc': 'Descrizione', + 'catalog.modal.desc_hint': 'l\'LLM legge questo quando decide se attivare il connettore', + 'catalog.modal.cancel': 'Annulla', + 'catalog.modal.save': 'Aggiungi al catalogo', + + 'catalog.error.name': 'Il nome è obbligatorio.', + 'catalog.confirm.delete': 'Rimuovere "{name}" dal catalogo?\n\nTutto ciò che è già stato attivato continuerà a funzionare.', + + // ── Cron ──────────────────────────────────────────────────────────────────── + 'cron.title': 'Processi pianificati (Cron)', + 'cron.count_one': '{n} processo', + 'cron.count_other': '{n} processi', + + 'cron.empty.title': 'Nessun processo cron ricorrente.', + 'cron.empty.hint': 'Chiedi all\'agente di crearne uno con execute_task.', + + 'cron.badge.running': 'in esecuzione', + 'cron.badge.disabled': 'disabilitato', + 'cron.badge.idle': 'inattivo', + + 'cron.action.delete': 'Elimina', + + 'cron.card.label_agent': 'Agente', + 'cron.card.label_last_run': 'Ultima esecuzione', + 'cron.card.label_next_run': 'Prossima esecuzione', + 'cron.card.enabled': 'Abilitato', + 'cron.card.disabled': 'Disabilitato', + + 'cron.confirm.delete': 'Eliminare il processo "{title}"?', + + // ── Comuni ───────────────────────────────────────────────────────────────── + 'common.save': 'Salva', + 'common.saving': 'Salvataggio…', + 'common.cancel': 'Annulla', + 'common.loading': 'Caricamento…', + + // ── Cartelle condivise (blueprint §6) ──────────────────────────────────────── + 'nav.shared_folders': 'Cartelle condivise', + 'sf.title': 'Cartelle condivise', + 'sf.count': '{n} cartella', + 'sf.count_plural': '{n} cartelle', + 'sf.new': 'Nuova cartella', + 'sf.loading': 'Caricamento…', + 'sf.empty': 'Ancora nessuna cartella condivisa.', + 'sf.empty_hint': 'Creane una per condividere file con gli altri membri.', + 'sf.note.propagation': 'Aggiungere o rimuovere un membro viene applicato subito al suo ambiente.', + 'sf.members': 'Membri', + 'sf.no_members': 'Ancora nessun membro — solo tu (admin) puoi accedere a questa cartella.', + 'sf.no_desc': 'Ancora nessuna descrizione — aggiungine una così l’assistente sa cosa tenerci.', + 'sf.all_added': 'Tutti i membri disponibili sono già stati aggiunti.', + 'sf.choose_user': 'Scegli un membro…', + 'sf.add': 'Aggiungi', + 'sf.remove': 'Rimuovi', + 'sf.edit_desc': 'Modifica descrizione', + 'sf.delete': 'Elimina', + 'sf.access.label': 'Accesso', + 'sf.access.read': 'Lettura', + 'sf.access.write': 'Scrittura', + 'sf.access.readonly': 'Sola lettura', + 'sf.access.readwrite': 'Lettura e scrittura', + 'sf.confirm.delete': 'Eliminare la cartella condivisa “{name}”?\n\nI membri perderanno l’accesso. I file su disco restano intatti.', + 'sf.confirm.remove_member': 'Rimuovere {name} da “{folder}”?', + 'sf.error.name': 'Il nome della cartella è obbligatorio.', + 'sf.form.new': 'Nuova cartella condivisa', + 'sf.form.edit': 'Modifica “{name}”', + 'sf.form.name': 'Nome cartella', + 'sf.form.name_hint': '(lettere, numeri, trattini — niente slash)', + 'sf.form.name_ph': 'documenti', + 'sf.form.name_desc': 'Diventa shared/ nello spazio di ogni membro. Non potrà essere rinominata.', + 'sf.form.desc': 'A cosa serve?', + 'sf.form.desc_desc': 'L’assistente legge questo testo per decidere cosa salvarci e quando consultarla. Scrivilo come istruzioni per l’assistente.', + 'sf.form.desc_ph': 'Bollette, contratti e garanzie di casa. Salva qui i PDF delle utenze e le scadenze; consulta qui quando ti chiedono di una fattura o una garanzia.', + 'sf.form.create': 'Crea', + 'sf.form.save': 'Salva', + 'sf.form.cancel': 'Annulla', +}; diff --git a/web/index.html b/web/index.html index 2cda2fb..8e4bb94 100644 --- a/web/index.html +++ b/web/index.html @@ -93,6 +93,7 @@ + @@ -104,7 +105,7 @@ - + diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index 6b331f0..4bbe3f4 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -1,4 +1,5 @@ import { LightElement } from './base.js'; +import { t } from './i18n.js'; // Slash commands handled entirely server-side: they reply with a `Done` and never // echo back as a `user_message`, so they are the only commands rendered @@ -270,7 +271,7 @@ export class ChatSession extends LightElement { if (approved) { this._updateTool(tool_call_id, { status: 'running', request_id: null }); } else { - this._updateTool(tool_call_id, { status: 'rejected', error: 'Rifiutato.' }); + this._updateTool(tool_call_id, { status: 'rejected', error: t('chat.rejected') }); } const expanded = new Set(this._expanded); expanded.delete(tool_call_id); @@ -320,7 +321,7 @@ export class ChatSession extends LightElement { } case 'truncated': - this._pushError(`Risposta troncata dal limite di token (↓${msg.output_tokens?.toLocaleString() ?? '?'} tok).`); + this._pushError(`${t('chat.truncated', { tokens: msg.output_tokens?.toLocaleString() ?? '?' })}`); break; case 'error': @@ -592,7 +593,7 @@ export class ChatSession extends LightElement { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify({ type: 'reject_tool', request_id: msg.request_id, note: this._rejectNote })); } - this._updateTool(msg.tool_call_id, { status: 'rejected', error: "Rifiutato dall'utente." }); + this._updateTool(msg.tool_call_id, { status: 'rejected', error: t('chat.rejected_by_user') }); this._rejectingId = null; } diff --git a/web/lib/i18n.js b/web/lib/i18n.js new file mode 100644 index 0000000..8bd3a5e --- /dev/null +++ b/web/lib/i18n.js @@ -0,0 +1,70 @@ +import en from '../i18n/en.js'; +import it from '../i18n/it.js'; +import fr from '../i18n/fr.js'; + +const DICTS = { en, it, fr }; + +export const LOCALES = [ + { id: 'en', label: 'English' }, + { id: 'it', label: 'Italiano' }, + { id: 'fr', label: 'Français' }, +]; + +// Pre-auth the last choice is cached in localStorage (the login page can be +// localized before any session exists); after login the server is the source +// of truth: the user's own `locale` wins over the instance default. +let _locale = localStorage.getItem('locale') || 'en'; + +export function t(key, params) { + let s = DICTS[_locale]?.[key] ?? DICTS.en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; +} + +export function getLocale() { return _locale; } + +export function setLocale(locale, { persist = false } = {}) { + if (!DICTS[locale]) locale = 'en'; + const changed = locale !== _locale; + _locale = locale; + localStorage.setItem('locale', locale); + document.documentElement.lang = locale; + if (changed) window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); + if (persist) { + fetch('/api/auth/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locale }), + }).catch(() => {}); + } +} + +/** + * Resolves the effective locale once the session is known: + * user preference (users.locale) → instance default (config `ui_locale`) → + * cached/browser default. Pre-auth (login/setup) keeps the cached locale. + */ +export async function initI18n() { + try { + const res = await fetch('/api/auth/me'); + if (!res.ok) return; + const me = await res.json(); + const eff = me.locale || me.default_locale; + if (eff) setLocale(eff); + } catch { /* keep cached locale */ } +} + +/** Re-renders the host component whenever the locale changes. */ +export const I18nMixin = (Base) => class extends Base { + connectedCallback() { + super.connectedCallback?.(); + this.__onLocaleChanged = () => this.requestUpdate(); + window.addEventListener('locale-changed', this.__onLocaleChanged); + } + disconnectedCallback() { + window.removeEventListener('locale-changed', this.__onLocaleChanged); + super.disconnectedCallback?.(); + } +}; diff --git a/web/lib/inbox-mixin.js b/web/lib/inbox-mixin.js index 4452e78..1cfdc01 100644 --- a/web/lib/inbox-mixin.js +++ b/web/lib/inbox-mixin.js @@ -1,10 +1,11 @@ import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdown } from './base.js'; +import { t } from './i18n.js'; /** * InboxMixin — shared fetch, action, and render logic for the agent inbox. - * Used by AgentInboxPage (full page) and HomePage (embedded section). + * Used by AgentInboxPage (full page) and DashboardPage (embedded section). */ export const InboxMixin = (Base) => class extends Base { @@ -68,7 +69,7 @@ export const InboxMixin = (Base) => class extends Base { } _rejectWithNote(requestId, toolCallId = null) { - const note = prompt('Rejection reason (optional):') ?? ''; + const note = prompt(t('inbox.reject_prompt')) ?? ''; this._resolveApproval(requestId, 'reject', note, null, null, toolCallId); } @@ -208,11 +209,11 @@ export const InboxMixin = (Base) => class extends Base {