Major rebranding, i18n, dashboard, shared folders, role capabilities

- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
This commit is contained in:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+14 -6
View File
@@ -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<dyn Plugin>` from `core-api` | | `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<dyn Plugin>` 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` | | `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 | | `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: 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. `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) ## 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. 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.** `<app-copilot>` 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 | | File | Element | Notes |
| ---- | ------- | ----- | | ---- | ------- | ----- |
| `copilot.js` | `<app-copilot>` | Desktop copilot (`_wsSource='web'`); composer input with model pill, auto-resize textarea | | `copilot.js` | `<app-copilot>` | 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` | `<chat-page>` | Mobile chat (`_wsSource='mobile'`) | | `shared/chat-page.js` | `<chat-page>` | Mobile chat (`_wsSource='mobile'`) |
| `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page | | `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page |
| `sidebar.js` | `<app-sidebar>` | Nav sidebar; polls `/api/inbox` every 10 s for badge | | `sidebar.js` | `<app-sidebar>` | Nav sidebar; role-driven (`ui_mode`); polls `/api/inbox` every 10 s for badge |
| `topbar.js` | `<app-topbar>` | Top nav bar | | `topbar.js` | `<app-topbar>` | Top nav bar; per-user avatar color hashed from the username |
| `home-page.js` | `<home-page>` | Landing / dashboard | | `dashboard-page.js` | `<dashboard-page>` | `#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 | | `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` | `<file-viewer-page>` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)``#file_viewer?path=...` | | `file-viewer-page.js` | `<file-viewer-page>` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)``#file_viewer?path=...` |
| `shared/file-viewer-mobile.js` | `<mobile-file-viewer-page>` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button | | `shared/file-viewer-mobile.js` | `<mobile-file-viewer-page>` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button |
Generated
+1
View File
@@ -5644,6 +5644,7 @@ dependencies = [
"anyhow", "anyhow",
"rpassword", "rpassword",
"skald-core", "skald-core",
"sqlx",
"tokio", "tokio",
] ]
+2 -2
View File
@@ -2,9 +2,9 @@
> ⚠️ **Active development** — expect breaking changes. Things move fast. > ⚠️ **Active development** — expect breaking changes. Things move fast.
<table><tr><td width="220"><img src="assets/images/skaldkonur.png" alt="Skáldkonur — the digital skald" width="200"></td><td> <table><tr><td width="220"><img src="assets/images/skaldkonur.png" alt="Skald Circle — app icon" width="200"></td><td>
**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. 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.
+31
View File
@@ -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
+25 -32
View File
@@ -4,48 +4,41 @@ Each agent in the `agents/` directory can have an icon/avatar declared in the `"
## Visual style ## 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 - **Style**: painterly vector — bold shapes fused with expressive brushstrokes
- **Technique**: bold brushstrokes, rich colours, depth, video game concept art quality (Overwatch / Arcane / Hades) - **Technique**: vivid colours, emotion, motion, warm lighting
- **Format**: portrait (vertical rectangle) - **Format**: square (1024×1024), rendered as a character portrait
- **Background**: medium-bright, not dark, no neon - **Background**: warm, cozy, medium-bright (no dark/no neon)
- **Subject**: a character / living being representing the agent's role, with contextual elements (tools, holograms, symbols) - **Subject**: a warm animal character representing the agent's role, with contextual elements (tools, symbols, objects)
- **Palette**: varies per agent, generally warm with one dominant colour - **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}". 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}.
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.
``` ```
## Per-agent reference ## Per-agent reference
| Agent | Subject | Palette | | Agent | Animal | Role | Elements | Palette |
|-------|---------|---------| |-------|--------|------|----------|---------|
| **Architect** | Visionary with floating architectural blueprints and geometry | Blue & teal | | **Main Assistant** 🦊 | Fox | General assistant | Glowing threads connecting a heart, star, house | Terracotta, amber, gold |
| **Engineer** | Technician/cyborg with holographic tools, gears, circuits | Amber & steel blue | | **Project Coordinator** 🦡 | Badger | Family coordinator | Floating threads linking heart, star, house, smiling face; cozy kitchen table | Terracotta, amber, gold, coral |
| **Explorer** | Curious analyst with magnifying glass, floating code and data trails | Deep blue & gold | | **Researcher** 🐿️ | Squirrel | Curious researcher | Glowing book, magnifying glass, compass, scrolls, stars | Terracotta, amber, soft teal, coral |
| **Researcher** | Scientist with smart glasses, floating documents, magnifier | Purple & teal | | **Generalist** 🦫 | Beaver | Handy executor | Glowing multitool, wrench, paintbrush, trowel, cooking pot | Terracotta, orange, amber, timber |
| **Main Assistant** | Central charismatic leader with luminous geometric shapes | Purple & gold | | **Code Explorer** 🕵️ | Meerkat | Curious analyst | Magnifying glass, data trails, sparkling code symbols | Terracotta, amber, deep blue, gold |
| **TIC** | Mysterious figure with multiple eyes, radar, data nodes | Dark purple & cyan | | **Software Architect** 🏗️ | Heron | Thoughtful planner | Floating blueprints, geometric shapes, building blocks | Terracotta, soft teal, amber, pale gold |
| **Tinker** | Clever craftsperson with multitool, gears, repair tools | Orange & steel grey | | **Software Engineer** 🔧 | Bear | Focused builder | Glowing wrench, gears, circuit board, hammer, sparks | Terracotta, orange, amber, steel grey |
| **Worker** | Practical person with futuristic toolbelt and mechanical elements | Orange & steel grey | | **Spec Writer** 📝 | Owl | Wise scribe | Glowing quill, scrolls, open books, words floating mid-air | Deep indigo, burnished gold, amber, cream |
| **Blueprint** | Scholarly figure with floating scrolls and glowing quills writing words in mid-air, luminous documents orbiting | Deep indigo & burnished gold | | **Tech Lead** 👑 | Stag | Confident strategist | Holographic kanban board, task cards, sub-agent symbols | Warm amber, deep teal, gold, coral |
| **Tech Lead** | Confident strategist at a holographic kanban board, task cards floating mid-air, sub-agents visible in the background | Warm amber & deep teal | | **TIC** 👁️ | Cat | Watchful guardian | Sensor nodes, radar arcs, notification symbols (bell, letter, calendar) | Dark purple, amber, soft cyan, warm grey |
| **Project Coordinator** | Central orchestrator with glowing connected nodes, satellite sub-agents orbiting, holographic project maps and branching task flows | Teal & warm gold | | **Business Analyst** 💼 | Magpie | Thoughtful evaluator | Glowing clipboard, floating documents, abacus, data points | Deep indigo, gold, soft teal, amber |
## Adding a new agent icon ## 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` 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 4. No code changes needed — the backend serves whatever file path is declared in the manifest
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+2 -1
View File
@@ -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.", "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", "type": "task",
"scope": "reasoning", "scope": "reasoning",
"strength": "high" "strength": "high",
"icon": "icon.png"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+35
View File
@@ -18,6 +18,7 @@
//! fs-tools *before* reaching here; `UserFs` only ever sees physical paths. //! fs-tools *before* reaching here; `UserFs` only ever sees physical paths.
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, RwLock};
/// One shared folder mounted into a user's container. /// One shared folder mounted into a user's container.
#[derive(Debug, Clone)] #[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<UserFs>`. 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<RwLock<Arc<UserFs>>>);
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<UserFs> {
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. /// Pure lexical normalization (resolve `.`/`..`), no filesystem access.
fn normalize(p: &Path) -> PathBuf { fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new(); let mut out = PathBuf::new();
+36
View File
@@ -18,6 +18,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -39,6 +40,9 @@ pub const HOMES_DIR: &str = "homes";
pub const SHARED_DIR: &str = "shared"; pub const SHARED_DIR: &str = "shared";
/// Home mount point inside the container. /// Home mount point inside the container.
pub const CONTAINER_HOME: &str = "/root"; 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, /// The deterministic container name for a user — derivable without any manager,
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly. /// 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; let _ = docker(&["rm", "-f", &name]).await;
Ok(()) 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 ──────────────────────────────────────────────────────── // ── docker CLI helpers ────────────────────────────────────────────────────────
+8
View File
@@ -400,6 +400,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
database_password BLOB, database_password BLOB,
password_hash BLOB, password_hash BLOB,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
locale TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK ( CHECK (
@@ -410,6 +411,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
) )
.execute(pool) .execute(pool)
.await?; .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 // Shared on-disk folders (blueprint §6/§0.1): a named directory
// `{WD}/shared/{folder_name}` bind-mounted into the container of each member. // `{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 ( "CREATE TABLE IF NOT EXISTS shared_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_name TEXT NOT NULL UNIQUE, folder_name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
)", )",
) )
.execute(pool) .execute(pool)
.await?; .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( sqlx::query(
"CREATE TABLE IF NOT EXISTS shared_folder_members ( "CREATE TABLE IF NOT EXISTS shared_folder_members (
@@ -25,6 +25,12 @@ pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script";
/// Curate the connector catalog (admin only). /// Curate the connector catalog (admin only).
pub const MANAGE_CATALOG: &str = "mcp.manage_catalog"; 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. /// The default capabilities of an ordinary (non-admin) user role.
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG]; pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
+48 -8
View File
@@ -15,6 +15,9 @@ use sqlx::SqlitePool;
pub struct SharedFolder { pub struct SharedFolder {
pub id: i64, pub id: i64,
pub folder_name: String, 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, pub created_at: String,
} }
@@ -59,25 +62,50 @@ pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Share
} }
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> { pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> {
let rows = sqlx::query_as::<_, (i64, String, String)>( let rows = sqlx::query_as::<_, (i64, String, String, String)>(
"SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name", "SELECT id, folder_name, description, created_at FROM shared_folders ORDER BY folder_name",
) )
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(rows Ok(rows
.into_iter() .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()) .collect())
} }
pub async fn get(pool: &SqlitePool, folder_id: i64) -> Result<Option<SharedFolder>> {
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<Option<SharedFolder>> { pub async fn get_by_name(pool: &SqlitePool, folder_name: &str) -> Result<Option<SharedFolder>> {
let row = sqlx::query_as::<_, (i64, String, String)>( let row = sqlx::query_as::<_, (i64, String, String, String)>(
"SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?", "SELECT id, folder_name, description, created_at FROM shared_folders WHERE folder_name = ?",
) )
.bind(folder_name) .bind(folder_name)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .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. /// 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<Vec<FolderMemb
/// Creates a folder, returning its id. `folder_name` must already be validated as /// Creates a folder, returning its id. `folder_name` must already be validated as
/// a safe path component (see [`is_valid_folder_name`]). /// a safe path component (see [`is_valid_folder_name`]).
pub async fn create(pool: &SqlitePool, folder_name: &str) -> Result<i64> { pub async fn create(pool: &SqlitePool, folder_name: &str, description: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)") let id = sqlx::query("INSERT INTO shared_folders (folder_name, description) VALUES (?, ?)")
.bind(folder_name) .bind(folder_name)
.bind(description)
.execute(pool) .execute(pool)
.await? .await?
.last_insert_rowid(); .last_insert_rowid();
Ok(id) 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. /// Adds (or updates the capability of) a member. Idempotent on the PK.
pub async fn add_member( pub async fn add_member(
pool: &SqlitePool, pool: &SqlitePool,
+24 -1
View File
@@ -65,6 +65,8 @@ pub struct User {
pub role_id: String, pub role_id: String,
pub credentials: Credentials, pub credentials: Credentials,
pub active: bool, pub active: bool,
/// UI locale override (NULL = follow the instance default).
pub locale: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -78,6 +80,7 @@ pub struct UserSummary {
pub role_id: String, pub role_id: String,
pub encrypted: bool, pub encrypted: bool,
pub active: bool, pub active: bool,
pub locale: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -95,6 +98,7 @@ impl User {
role_id: self.role_id.clone(), role_id: self.role_id.clone(),
encrypted: self.is_encrypted(), encrypted: self.is_encrypted(),
active: self.active, active: self.active,
locale: self.locale.clone(),
created_at: self.created_at.clone(), created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(), updated_at: self.updated_at.clone(),
} }
@@ -140,6 +144,7 @@ struct Row {
database_password: Option<Vec<u8>>, database_password: Option<Vec<u8>>,
password_hash: Option<Vec<u8>>, password_hash: Option<Vec<u8>>,
active: bool, active: bool,
locale: Option<String>,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@@ -150,7 +155,7 @@ macro_rules! select {
($tail:literal) => { ($tail:literal) => {
concat!( concat!(
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ", "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 $tail
) )
}; };
@@ -185,6 +190,7 @@ impl TryFrom<Row> for User {
role_id: r.role_id, role_id: r.role_id,
credentials, credentials,
active: r.active, active: r.active,
locale: r.locale,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
}) })
@@ -370,6 +376,22 @@ pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: O
Ok(()) 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`: /// Removes the directory row only. The caller still owns `database/{id}.db`:
/// erasing a user means deleting that file too. /// erasing a user means deleting that file too.
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -556,6 +578,7 @@ mod tests {
role_id: "admin".into(), role_id: "admin".into(),
credentials: encrypted(), credentials: encrypted(),
active: true, active: true,
locale: None,
created_at: "now".into(), created_at: "now".into(),
updated_at: "now".into(), updated_at: "now".into(),
}; };
+55
View File
@@ -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()),
},
],
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod cron;
pub mod db; pub mod db;
pub mod events; pub mod events;
pub mod image_generate; pub mod image_generate;
pub mod i18n;
pub mod inbox; pub mod inbox;
pub mod latex; pub mod latex;
pub mod llm; pub mod llm;
+11
View File
@@ -268,6 +268,17 @@ impl McpManager {
self.descriptions.write().unwrap().remove(name); 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<McpTool> { pub fn tools(&self) -> Vec<McpTool> {
self.servers.read().unwrap().values() self.servers.read().unwrap().values()
.flat_map(|s| s.tools().iter().cloned()) .flat_map(|s| s.tools().iter().cloned())
@@ -431,7 +431,9 @@ impl ChatSessionHandler {
let ctx = ToolContext { let ctx = ToolContext {
session_id: self.session_id, session_id: self.session_id,
pool: Arc::clone(&self.db), 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) self.tools.run(name, &ctx, args)
} }
+7 -4
View File
@@ -20,7 +20,7 @@ use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_sessions_stack}; use crate::db::{chat_history, chat_sessions_stack};
use crate::events::ServerEvent; use crate::events::ServerEvent;
use core_api::message_meta::MessageMetadata; use core_api::message_meta::MessageMetadata;
use core_api::user_fs::UserFs; use core_api::user_fs::SharedFs;
use crate::llm::LlmManager; use crate::llm::LlmManager;
use crate::mcp::McpProvider; use crate::mcp::McpProvider;
use crate::image_generate::ImageGeneratorManager; use crate::image_generate::ImageGeneratorManager;
@@ -272,8 +272,11 @@ pub struct ChatSessionHandler {
pub(super) user_id: String, pub(super) user_id: String,
/// The owner's filesystem view (home + shared folders + container), threaded /// The owner's filesystem view (home + shared folders + container), threaded
/// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and /// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and
/// `execute_cmd` execs into the owner's container (blueprint §6). /// `execute_cmd` execs into the owner's container (blueprint §6). A **shared
pub(super) fs: Arc<UserFs>, /// 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<LlmManager>, pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize, pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize, pub(super) max_tool_rounds: usize,
@@ -343,7 +346,7 @@ impl ChatSessionHandler {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>, shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
fs: Arc<UserFs>, fs: SharedFs,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
+14 -5
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use core_api::user_fs::UserFs; use core_api::user_fs::{SharedFs, UserFs};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -29,8 +29,9 @@ pub struct ChatSessionManager {
shared_pool: Arc<SqlitePool>, shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
/// The owner's filesystem view, threaded to each handler and on into every /// The owner's filesystem view, threaded to each handler and on into every
/// `ToolContext` (blueprint §6). /// `ToolContext` (blueprint §6). A shared swappable cell so a shared-folder
user_fs: Arc<UserFs>, /// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions.
user_fs: SharedFs,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -60,7 +61,7 @@ impl ChatSessionManager {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>, shared_pool: Arc<SqlitePool>,
user_id: String, user_id: String,
user_fs: Arc<UserFs>, user_fs: SharedFs,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -171,7 +172,7 @@ impl ChatSessionManager {
self.db.clone(), self.db.clone(),
self.shared_pool.clone(), self.shared_pool.clone(),
self.user_id.clone(), self.user_id.clone(),
Arc::clone(&self.user_fs), self.user_fs.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
self.max_tool_rounds, self.max_tool_rounds,
@@ -197,4 +198,12 @@ impl ChatSessionManager {
self.active.lock().await.insert(session_id, handler.clone()); self.active.lock().await.insert(session_id, handler.clone());
Ok(handler) 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);
}
} }
+49
View File
@@ -64,6 +64,55 @@ impl Skald {
} }
fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts } 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<Arc<super::UserContext>> {
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<crate::auth::SessionStore> { &self.rt.sessions } pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config } pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
+1 -1
View File
@@ -346,7 +346,7 @@ impl Conversation {
// The ownerless manager is inert (no loops, no consumers — see §19): it takes // 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. // 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(), String::new(),
std::path::PathBuf::from("homes"), std::path::PathBuf::from("homes"),
"skald-ownerless", "skald-ownerless",
+1 -1
View File
@@ -63,7 +63,7 @@ impl Runtime {
users, users,
sessions, sessions,
config, config,
config_properties: vec![crate::tic::config_set()], config_properties: vec![crate::i18n::config_set(), crate::tic::config_set()],
system_bus, system_bus,
event_bus, event_bus,
global_tx, global_tx,
+16 -6
View File
@@ -35,7 +35,7 @@ use core_api::events::GlobalEvent;
use core_api::inbox::InboxApi; use core_api::inbox::InboxApi;
use core_api::system_bus::SystemEventBus; use core_api::system_bus::SystemEventBus;
use core_api::user_channel::UserChannelHandle; use core_api::user_channel::UserChannelHandle;
use core_api::user_fs::UserFs; use core_api::user_fs::SharedFs;
use crate::approval::ApprovalManager; use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus; use crate::chat_event_bus::ChatEventBus;
@@ -66,8 +66,10 @@ pub struct UserContext {
pub user_id: String, pub user_id: String,
pub pool: Arc<SqlitePool>, pub pool: Arc<SqlitePool>,
/// The owner's filesystem view (home + shared folders + container, §6), /// The owner's filesystem view (home + shared folders + container, §6),
/// threaded into every `ToolContext` this user's sessions produce. /// threaded into every `ToolContext` this user's sessions produce. A shared
pub fs: Arc<UserFs>, /// 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<ChatEventBus>, pub event_bus: Arc<ChatEventBus>,
pub sessions: Arc<ChatSessionManager>, pub sessions: Arc<ChatSessionManager>,
pub chat_hub: Arc<ChatHub>, pub chat_hub: Arc<ChatHub>,
@@ -151,8 +153,9 @@ impl UserContextFactory {
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> { async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let pool = Arc::new(pool); let pool = Arc::new(pool);
// The owner's filesystem view: private home + shared folders + container. // The owner's filesystem view: private home + shared folders + container.
// Snapshotted at login; a membership change takes effect on next login (v1). // A shared swappable cell — a shared-folder membership change is applied in
let fs = Arc::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?); // 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 event_bus = Arc::new(ChatEventBus::new());
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512); let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
@@ -238,7 +241,7 @@ impl UserContextFactory {
Arc::clone(&pool), Arc::clone(&pool),
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
user_id.to_string(), user_id.to_string(),
Arc::clone(&fs), fs.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
self.max_tool_rounds, self.max_tool_rounds,
@@ -340,6 +343,13 @@ impl UserContextRegistry {
guard.insert(user_id.to_string(), Arc::clone(&ctx)); guard.insert(user_id.to_string(), Arc::clone(&ctx));
Ok(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<Arc<UserContext>> {
self.contexts.lock().await.get(user_id).cloned()
}
} }
// ── UserChannelHandle impl ──────────────────────────────────────────────────── // ── UserChannelHandle impl ────────────────────────────────────────────────────
+1
View File
@@ -17,5 +17,6 @@ path = "src/main.rs"
skald-core = { path = "../skald-core" } skald-core = { path = "../skald-core" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1" anyhow = "1"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
# Reads a password without echoing it to the terminal. # Reads a password without echoing it to the terminal.
rpassword = "7" rpassword = "7"
+42 -5
View File
@@ -94,10 +94,12 @@ fn usage() -> String {
async fn run(mode: Mode) -> Result<std::process::ExitCode> { async fn run(mode: Mode) -> Result<std::process::ExitCode> {
// Opening the pool creates `database/system.db` and its schema if absent — // 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. // the same call the server makes, so setup and server agree on the layout.
let pool = db::init_system_pool(SYSTEM_DB_PATH) let pool = std::sync::Arc::new(
db::init_system_pool(SYSTEM_DB_PATH)
.await .await
.context("opening the system database")?; .context("opening the system database")?,
let users = UserManager::new(std::sync::Arc::new(pool)); );
let users = UserManager::new(std::sync::Arc::clone(&pool));
let has_admin = users.count().await.context("counting users")? > 0; let has_admin = users.count().await.context("counting users")? > 0;
@@ -113,13 +115,13 @@ async fn run(mode: Mode) -> Result<std::process::ExitCode> {
// Each is idempotent: it decides for itself whether there is work to do. // 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 // Today there is one. Provider and model setup will be added here as further
// steps, in order, each skipping itself when already configured. // 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) Ok(std::process::ExitCode::SUCCESS)
} }
/// Create the first admin, or do nothing if one already exists. /// 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 { if has_admin {
// Idempotent re-run, or a second binary got there first. // Idempotent re-run, or a second binary got there first.
return Ok(()); 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.trim();
let display_name = (!display_name.is_empty()).then_some(display_name); let display_name = (!display_name.is_empty()).then_some(display_name);
let locale = prompt_locale()?;
let encrypt = prompt_encrypt()?; let encrypt = prompt_encrypt()?;
let password = prompt_new_password()?; let password = prompt_new_password()?;
@@ -150,6 +153,12 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
.await .await
.context("creating the admin user")?; .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})."); println!("\n✓ Admin user '{username}' created (id {id}).");
if encrypt { if encrypt {
println!(" Their private database is encrypted. There is no recovery if the password is lost."); println!(" Their private database is encrypted. There is no recovery if the password is lost.");
@@ -177,6 +186,34 @@ fn prompt_username() -> Result<String> {
} }
} }
/// 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<String> {
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::<usize>() {
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 — /// 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 /// 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. /// the other users (§2/§4); and it has no recovery. The prompt says so.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 94 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

After

Width:  |  Height:  |  Size: 7.7 KiB

+62 -3
View File
@@ -61,6 +61,16 @@ pub struct MeResponse {
pub username: String, pub username: String,
pub display_name: Option<String>, pub display_name: Option<String>,
pub role_id: String, 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<String>,
/// 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. /// Returns the authenticated user's profile, or 401 if no valid session.
@@ -84,14 +94,44 @@ pub async fn me(
.await? .await?
.ok_or_else(|| ApiError::not_found("user not found"))?; .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 { Ok(Json(MeResponse {
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
role_id: user.role_id, role_id: user.role_id,
ui_mode,
locale: user.locale,
encrypted: user.encrypted,
default_locale,
}) })
.into_response()) .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::<serde_json::Value>(&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 ──────────────────────────────────────────────────── // ── POST /api/auth/logout ────────────────────────────────────────────────────
pub async fn logout( pub async fn logout(
@@ -127,11 +167,16 @@ fn extract_session_token(headers: &HeaderMap) -> Option<String> {
None 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<Option<T>>` with `#[serde(default)]`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateProfileBody { pub struct UpdateProfileBody {
pub display_name: Option<String>, #[serde(default)]
pub display_name: Option<Option<String>>,
#[serde(default)]
pub locale: Option<Option<String>>,
} }
pub async fn update_profile( pub async fn update_profile(
@@ -145,13 +190,27 @@ pub async fn update_profile(
.await? .await?
.ok_or_else(|| ApiError::not_found("user not found"))?; .ok_or_else(|| ApiError::not_found("user not found"))?;
if let Some(display_name) = body.display_name {
skald_core::db::users::rename( skald_core::db::users::rename(
skald.db(), skald.db(),
&auth.user_id, &auth.user_id,
&user.username, &user.username,
body.display_name.as_deref(), display_name.as_deref().filter(|s| !s.trim().is_empty()),
) )
.await?; .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 }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
+7
View File
@@ -22,6 +22,7 @@ pub mod roles;
pub mod run_context; pub mod run_context;
pub mod sessions; pub mod sessions;
pub mod setup; pub mod setup;
pub mod shared_folders;
pub mod transcribe_audio; pub mod transcribe_audio;
pub mod transcribe_models; pub mod transcribe_models;
pub mod tts_models; pub mod tts_models;
@@ -176,6 +177,12 @@ pub fn router() -> Router<Arc<Skald>> {
.route("/users", get(users_mgmt::list).post(users_mgmt::create)) .route("/users", get(users_mgmt::list).post(users_mgmt::create))
.route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete))
.route("/users/{id}/password", post(users_mgmt::reset_password)) .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) // Images (generated by image_generate tool)
.route("/images/{task_id}", get(images::get_image)) .route("/images/{task_id}", get(images::get_image))
// MCP tool-result media (images/audio/files returned by MCP servers) // MCP tool-result media (images/audio/files returned by MCP servers)
+15
View File
@@ -30,6 +30,9 @@ pub struct CreateUserBody {
pub password: String, pub password: String,
#[serde(default)] #[serde(default)]
pub encrypted: bool, pub encrypted: bool,
/// Chosen interface language — becomes the instance default (`ui_locale`).
#[serde(default)]
pub locale: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -54,11 +57,23 @@ pub async fn create_user(
if body.password.is_empty() { if body.password.is_empty() {
return Err(ApiError::bad_request("password must not be 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 let id = skald
.users() .users()
.register_user(username, None, "admin", Some(&body.password), body.encrypted) .register_user(username, None, "admin", Some(&body.password), body.encrypted)
.await?; .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 })) Ok(Json(CreateUserResult { user_id: id }))
} }
+233
View File
@@ -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<MemberView>,
}
async fn folder_view(skald: &Skald, f: shared_folders::SharedFolder) -> Result<FolderView, ApiError> {
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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<FolderView>>, 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<CreateBody>,
) -> Result<Json<FolderView>, 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<DescriptionBody>,
) -> Result<Json<serde_json::Value>, 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<MemberBody>,
) -> Result<Json<serde_json::Value>, 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path((id, user_id)): Path<(i64, String)>,
) -> Result<Json<serde_json::Value>, 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 })))
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { TasksPage } from './components/tasks/index.js';
import { AgentsPage } from './components/agents.js'; import { AgentsPage } from './components/agents.js';
import { UsersPage } from './components/users-page.js'; import { UsersPage } from './components/users-page.js';
import { RolesPage } from './components/roles-page.js'; import { RolesPage } from './components/roles-page.js';
import { SharedFoldersPage } from './components/shared-folders.js';
import { ConnectorsPage } from './components/connectors.js'; import { ConnectorsPage } from './components/connectors.js';
import { ConnectorDetailPage } from './components/connector-detail.js'; import { ConnectorDetailPage } from './components/connector-detail.js';
import { MarketplacePage } from './components/marketplace.js'; import { MarketplacePage } from './components/marketplace.js';
@@ -19,8 +20,8 @@ import { ProfilePage } from './components/profile-page.js';
import { ApprovalGroupsPage } from './components/approval-groups.js'; import { ApprovalGroupsPage } from './components/approval-groups.js';
import { ApprovalRulesPage } from './components/approval-rules.js'; import { ApprovalRulesPage } from './components/approval-rules.js';
import { ConfigPage } from './components/config-page.js'; import { ConfigPage } from './components/config-page.js';
import { DashboardPage } from './components/dashboard-page.js';
import { AgentInboxPage } from './components/agent-inbox.js'; import { AgentInboxPage } from './components/agent-inbox.js';
import { HomePage } from './components/home-page.js';
import { LlmRequestsPage } from './components/llm-requests.js'; import { LlmRequestsPage } from './components/llm-requests.js';
import { LlmRequestDetail } from './components/llm-request-detail.js'; import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-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). // Register the global `openFile(path)` helper (window.openFile → location.hash).
import './lib/open-file.js'; import './lib/open-file.js';
import { initI18n } from './lib/i18n.js';
customElements.define('app-topbar', AppTopbar); customElements.define('app-topbar', AppTopbar);
customElements.define('app-sidebar', AppSidebar); customElements.define('app-sidebar', AppSidebar);
@@ -46,6 +48,7 @@ customElements.define('tasks-page', TasksPage);
customElements.define('agents-page', AgentsPage); customElements.define('agents-page', AgentsPage);
customElements.define('users-page', UsersPage); customElements.define('users-page', UsersPage);
customElements.define('roles-page', RolesPage); customElements.define('roles-page', RolesPage);
customElements.define('shared-folders-page', SharedFoldersPage);
customElements.define('connectors-page', ConnectorsPage); customElements.define('connectors-page', ConnectorsPage);
customElements.define('connector-detail-page', ConnectorDetailPage); customElements.define('connector-detail-page', ConnectorDetailPage);
customElements.define('marketplace-page', MarketplacePage); customElements.define('marketplace-page', MarketplacePage);
@@ -54,8 +57,8 @@ customElements.define('profile-page', ProfilePage);
customElements.define('approval-groups-page', ApprovalGroupsPage); customElements.define('approval-groups-page', ApprovalGroupsPage);
customElements.define('approval-rules-page', ApprovalRulesPage); customElements.define('approval-rules-page', ApprovalRulesPage);
customElements.define('config-page', ConfigPage); customElements.define('config-page', ConfigPage);
customElements.define('dashboard-page', DashboardPage);
customElements.define('agent-inbox-page', AgentInboxPage); customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('home-page', HomePage);
customElements.define('llm-requests-page', LlmRequestsPage); customElements.define('llm-requests-page', LlmRequestsPage);
customElements.define('llm-request-detail', LlmRequestDetail); customElements.define('llm-request-detail', LlmRequestDetail);
customElements.define('session-detail-page', SessionDetailPage); customElements.define('session-detail-page', SessionDetailPage);
@@ -98,5 +101,7 @@ window.addEventListener('llm-page-change', (e) => {
if (login) login.style.display = ''; if (login) login.style.display = '';
return; return;
} }
// Logged in: resolve the effective locale (user pref → instance default).
initI18n();
} catch { /* show app by default */ } } catch { /* show app by default */ }
})(); })();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+3 -2
View File
@@ -1,8 +1,9 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { InboxMixin } from '../lib/inbox-mixin.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() { static get properties() {
return { return {
@@ -58,7 +59,7 @@ export class AgentInboxPage extends InboxMixin(LightElement) {
<div class="page-panel"> <div class="page-panel">
<div class="page-panel-header"> <div class="page-panel-header">
<h5 class="mb-0"> <h5 class="mb-0">
Agent Inbox ${t('nav.inbox')}
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing} ${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
</h5> </h5>
<button class="inbox-refresh-btn" title="Refresh" @click=${() => this._loadInbox()}> <button class="inbox-refresh-btn" title="Refresh" @click=${() => this._loadInbox()}>
+42 -40
View File
@@ -1,6 +1,7 @@
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../lib/base.js'; import { LightElement, renderMarkdown } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const STRENGTH_COLORS = { const STRENGTH_COLORS = {
very_high: '#ef4444', very_high: '#ef4444',
@@ -38,6 +39,8 @@ export class AgentsPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'agents'; this._open = e.detail.page === 'agents';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
@@ -46,6 +49,11 @@ export class AgentsPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _loadList() { async _loadList() {
this._loading = true; this._loading = true;
this._error = null; this._error = null;
@@ -81,12 +89,16 @@ export class AgentsPage extends LightElement {
// ── Render helpers ──────────────────────────────────────────────────────── // ── Render helpers ────────────────────────────────────────────────────────
_strengthLabel(strength) {
return { very_high: t('agents.strength.very_high'), high: t('agents.strength.high'), average: t('agents.strength.average'), low: t('agents.strength.low'), very_low: t('agents.strength.very_low') }[strength] ?? strength;
}
_strengthDot(strength, size = '0.62rem') { _strengthDot(strength, size = '0.62rem') {
if (!strength) return html`<span style="opacity:0.3;font-size:${size}"></span>`; if (!strength) return html`<span style="opacity:0.3;font-size:${size}">${'—'}</span>`;
return html` return html`
<span class="agent-strength-dot" <span class="agent-strength-dot"
style="background:${STRENGTH_COLORS[strength] ?? '#888'}" style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
title=${STRENGTH_LABELS[strength] ?? strength}></span> title=${this._strengthLabel(strength)}></span>
`; `;
} }
@@ -113,7 +125,7 @@ export class AgentsPage extends LightElement {
${agent.strength ? html` ${agent.strength ? html`
<span class="agent-meta-item"> <span class="agent-meta-item">
${this._strengthDot(agent.strength)} ${this._strengthDot(agent.strength)}
<span>${STRENGTH_LABELS[agent.strength] ?? agent.strength}</span> <span>${this._strengthLabel(agent.strength)}</span>
</span> </span>
` : ''} ` : ''}
${agent.scope ? html`${this._scopePill(agent.scope)}` : ''} ${agent.scope ? html`${this._scopePill(agent.scope)}` : ''}
@@ -142,18 +154,16 @@ export class AgentsPage extends LightElement {
} }
_renderList() { _renderList() {
if (this._loading) return html`<div class="text-muted py-4 text-center">Loading…</div>`; if (this._loading) return html`<div class="text-muted py-4 text-center">${t('agents.loading')}</div>`;
if (this._error) return html`<div class="alert alert-danger py-2" style="font-size:0.85rem">${this._error}</div>`; if (this._error) return html`<div class="alert alert-danger py-2" style="font-size:0.85rem">${this._error}</div>`;
if (this._agents.length === 0) return html`<p class="text-muted">No agents found.</p>`; if (this._agents.length === 0) return html`<p class="text-muted">${t('agents.empty')}</p>`;
// Group by role: chat entry-points, dispatchable task executors, and
// runtime-internal system agents (e.g. tic).
const chat = this._agents.filter(a => a.type === 'chat'); const chat = this._agents.filter(a => a.type === 'chat');
const task = this._agents.filter(a => a.type === 'task'); const task = this._agents.filter(a => a.type === 'task');
const system = this._agents.filter(a => a.type === 'system'); const system = this._agents.filter(a => a.type === 'system');
return html` return html`
${this._renderSection('Chat', chat)} ${this._renderSection(t('agents.section.chat'), chat)}
${this._renderSection('Task Executors', task)} ${this._renderSection(t('agents.section.task'), task)}
${this._renderSection('System', system)} ${this._renderSection(t('agents.section.system'), system)}
`; `;
} }
@@ -167,7 +177,7 @@ export class AgentsPage extends LightElement {
<td>${this._strengthDot(m.strength)}</td> <td>${this._strengthDot(m.strength)}</td>
<td> <td>
<span class="fw-semibold">${m.name}</span> <span class="fw-semibold">${m.name}</span>
${m.is_default ? html`<span class="badge bg-primary ms-1" style="font-size:0.6rem">default</span>` : ''} ${m.is_default ? html`<span class="badge bg-primary ms-1" style="font-size:0.6rem">${t('agents.detail.default')}</span>` : ''}
</td> </td>
<td class="text-muted agent-model-id">${m.model_id}</td> <td class="text-muted agent-model-id">${m.model_id}</td>
<td> <td>
@@ -178,17 +188,16 @@ export class AgentsPage extends LightElement {
} }
_renderDetail() { _renderDetail() {
if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">Loading…</div>`; if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">${t('agents.loading')}</div>`;
if (!this._detail) return ''; if (!this._detail) return '';
const { meta, prompt, models } = this._detail; const { meta, prompt, models } = this._detail;
return html` return html`
<div class="agent-detail"> <div class="agent-detail">
<!-- Header -->
<div class="agent-detail-header"> <div class="agent-detail-header">
<button class="btn btn-sm btn-link px-0" @click=${() => this._back()}> <button class="btn btn-sm btn-link px-0" @click=${() => this._back()}>
<i class="bi bi-arrow-left me-1"></i>Agents <i class="bi bi-arrow-left me-1"></i>${t('agents.back')}
</button> </button>
<div class="agent-detail-title-row"> <div class="agent-detail-title-row">
${meta.icon ? html` ${meta.icon ? html`
@@ -204,28 +213,27 @@ export class AgentsPage extends LightElement {
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="agent-detail-body"> <div class="agent-detail-body">
<!-- Meta -->
<section class="agent-section"> <section class="agent-section">
<h3 class="agent-section-title">Metadata</h3> <h3 class="agent-section-title">${t('agents.detail.meta')}</h3>
<table class="agent-meta-table"> <table class="agent-meta-table">
<tbody> <tbody>
<tr><td class="agent-meta-key">ID</td><td><code>${meta.id}</code></td></tr> <tr><td class="agent-meta-key">${t('agents.detail.id')}</td><td><code>${meta.id}</code></td></tr>
${meta.strength ? html` ${meta.strength ? html`
<tr><td class="agent-meta-key">Strength</td> <tr><td class="agent-meta-key">${t('agents.detail.strength')}</td>
<td class="d-flex align-items-center gap-2"> <td class="d-flex align-items-center gap-2">
${this._strengthDot(meta.strength)} ${this._strengthDot(meta.strength)}
${STRENGTH_LABELS[meta.strength] ?? meta.strength} ${this._strengthLabel(meta.strength)}
</td> </td>
</tr> </tr>
` : ''} ` : ''}
${meta.scope ? html` ${meta.scope ? html`
<tr><td class="agent-meta-key">Scope</td><td>${this._scopePill(meta.scope)}</td></tr> <tr><td class="agent-meta-key">${t('agents.detail.scope')}</td><td>${this._scopePill(meta.scope)}</td></tr>
` : ''} ` : ''}
${meta.client ? html` ${meta.client ? html`
<tr><td class="agent-meta-key">Pinned model</td><td><code>${meta.client}</code></td></tr> <tr><td class="agent-meta-key">${t('agents.detail.pinned_model')}</td><td><code>${meta.client}</code></td></tr>
` : ''} ` : ''}
${meta.inject_memory?.length ? html` ${meta.inject_memory?.length ? html`
<tr><td class="agent-meta-key">Memory files</td> <tr><td class="agent-meta-key">${t('agents.detail.memory_files')}</td>
<td>${meta.inject_memory.map(f => html`<div style="font-size:0.8rem"><code>${f}</code></div>`)}</td> <td>${meta.inject_memory.map(f => html`<div style="font-size:0.8rem"><code>${f}</code></div>`)}</td>
</tr> </tr>
` : ''} ` : ''}
@@ -233,25 +241,23 @@ export class AgentsPage extends LightElement {
</table> </table>
</section> </section>
<!-- Model resolution order -->
<section class="agent-section"> <section class="agent-section">
<h3 class="agent-section-title">Model resolution order</h3> <h3 class="agent-section-title">${t('agents.detail.model_order')}</h3>
<p class="text-muted mb-2" style="font-size:0.8rem"> <p class="text-muted mb-2" style="font-size:0.8rem">
Models sorted by how well they match this agent's requirements. ${t('agents.detail.model_order_desc')}
The system uses the first available model from the top.
</p> </p>
${models.length === 0 ${models.length === 0
? html`<p class="text-muted" style="font-size:0.85rem">No models configured.</p>` ? html`<p class="text-muted" style="font-size:0.85rem">${t('agents.detail.no_models')}</p>`
: html` : html`
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm agent-model-table mb-0"> <table class="table table-sm agent-model-table mb-0">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>${t('agents.table.rank')}</th>
<th>Strength</th> <th>${t('agents.table.strength')}</th>
<th>Name</th> <th>${t('agents.table.name')}</th>
<th>Model ID</th> <th>${t('agents.table.model_id')}</th>
<th>Scope</th> <th>${t('agents.table.scope')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -263,9 +269,8 @@ export class AgentsPage extends LightElement {
} }
</section> </section>
<!-- System prompt -->
<section class="agent-section"> <section class="agent-section">
<h3 class="agent-section-title">System prompt</h3> <h3 class="agent-section-title">${t('agents.detail.prompt')}</h3>
<div class="agent-prompt-body markdown-body"> <div class="agent-prompt-body markdown-body">
${unsafeHTML(renderMarkdown(prompt))} ${unsafeHTML(renderMarkdown(prompt))}
</div> </div>
@@ -284,17 +289,14 @@ export class AgentsPage extends LightElement {
? this._renderDetail() ? this._renderDetail()
: html` : html`
<div class="agents-page-header"> <div class="agents-page-header">
<h2 class="llm-page-title">Agents</h2> <h2 class="llm-page-title">${t('agents.title')}</h2>
</div> </div>
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-1"><strong>Read-only view.</strong> Agents are defined by files in <code>agents/</code> <p class="mb-1">${unsafeHTML(t('agents.banner.title'))}</p>
— to add, remove, or modify an agent, edit the corresponding <code>AGENT.md</code> file in that <p class="mb-0">${unsafeHTML(t('agents.banner.text'))}</p>
directory.</p>
<p class="mb-0">You can also ask <strong>Copilot</strong> (top bar) to create a new agent for you
— just describe what it should do and it will set up all the files automatically.</p>
</div> </div>
</div> </div>
+49 -47
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class ApprovalGroupsPage extends LightElement { export class ApprovalGroupsPage extends LightElement {
static properties = { static properties = {
@@ -31,6 +33,8 @@ export class ApprovalGroupsPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', async (e) => { window.addEventListener('llm-page-change', async (e) => {
this._open = e.detail.page === 'approval'; this._open = e.detail.page === 'approval';
this.style.display = this._open ? 'flex' : 'none'; 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() { async _load() {
this._error = null; this._error = null;
try { try {
@@ -65,8 +74,8 @@ export class ApprovalGroupsPage extends LightElement {
fetch('/api/tool-permission-groups'), fetch('/api/tool-permission-groups'),
fetch('/api/approval/rules'), fetch('/api/approval/rules'),
]); ]);
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
const groups = await gRes.json(); const groups = await gRes.json();
this._groups = groups.sort((a, b) => { this._groups = groups.sort((a, b) => {
if (a.id === 'default') return -1; if (a.id === 'default') return -1;
@@ -114,8 +123,8 @@ export class ApprovalGroupsPage extends LightElement {
async _saveGroup() { async _saveGroup() {
const isNew = this._groupEditId === 'new'; const isNew = this._groupEditId === 'new';
if (!this._groupForm.name.trim()) { this._error = 'Group name 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 = 'Group ID is required.'; return; } if (isNew && !this._groupForm.id.trim()) { this._error = t('security.error.group_id_required'); return; }
this._groupSaving = true; this._groupSaving = true;
this._error = null; this._error = null;
try { try {
@@ -141,8 +150,8 @@ export class ApprovalGroupsPage extends LightElement {
async _deleteGroup(group) { async _deleteGroup(group) {
const count = this._rulesForGroup(group.id).length; const count = this._rulesForGroup(group.id).length;
const msg = count > 0 const msg = count > 0
? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?` ? t('security.confirm.delete_with_rules', { name: group.name, n: count, s: count === 1 ? '' : 's' })
: `Delete group "${group.name}"?`; : t('security.confirm.delete', { name: group.name });
if (!confirm(msg)) return; if (!confirm(msg)) return;
try { try {
const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' }); 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._duplicateOf = group;
this._dupForm = { this._dupForm = {
id: `${group.id}_copy`, id: `${group.id}_copy`,
name: `Copy of ${group.name}`, name: `${t('security.duplicate')} ${group.name}`,
}; };
this._groupEditId = null; // close any open create/rename form this._groupEditId = null; // close any open create/rename form
} }
@@ -167,8 +176,8 @@ export class ApprovalGroupsPage extends LightElement {
_cancelDuplicate() { this._duplicateOf = null; } _cancelDuplicate() { this._duplicateOf = null; }
async _saveDuplicate() { async _saveDuplicate() {
if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; } if (!this._dupForm.name.trim()) { this._error = t('security.error.name_required'); return; }
if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; } if (!this._dupForm.id.trim()) { this._error = t('security.error.id_required'); return; }
this._dupSaving = true; this._dupSaving = true;
this._error = null; this._error = null;
try { try {
@@ -196,7 +205,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form"> <div class="apr-form">
<div class="apr-form-header"> <div class="apr-form-header">
<i class="bi bi-collection"></i> <i class="bi bi-collection"></i>
<span>${isNew ? 'New group' : 'Rename group'}</span> <span>${isNew ? t('security.new_group') : t('security.rename_group')}</span>
<button class="apr-form-close" @click=${() => this._cancelGroupEdit()}> <button class="apr-form-close" @click=${() => this._cancelGroupEdit()}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
@@ -205,41 +214,41 @@ export class ApprovalGroupsPage extends LightElement {
<div class="row g-3"> <div class="row g-3">
${isNew ? html` ${isNew ? html`
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">ID <span class="text-danger">*</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.id')} <span class="text-danger">*</span></label>
<input <input
class="form-control form-control-sm font-monospace" class="form-control form-control-sm font-monospace"
placeholder="e.g. cron_strict" placeholder=${t('security.form.id_ph')}
.value=${f.id} .value=${f.id}
@input=${(e) => this._patchGroup('id', e.target.value)} @input=${(e) => this._patchGroup('id', e.target.value)}
/> />
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div> <div class="form-text" style="font-size:0.75rem">${t('security.form.id_hint')}</div>
</div> </div>
` : nothing} ` : nothing}
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name <span class="text-danger">*</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.name')} <span class="text-danger">*</span></label>
<input <input
class="form-control form-control-sm" class="form-control form-control-sm"
placeholder="e.g. Cron strict" placeholder=${t('security.form.name_ph')}
.value=${f.name} .value=${f.name}
@input=${(e) => this._patchGroup('name', e.target.value)} @input=${(e) => this._patchGroup('name', e.target.value)}
/> />
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.description')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input <input
class="form-control form-control-sm" class="form-control form-control-sm"
placeholder="Short description…" placeholder=${t('security.form.description_ph')}
.value=${f.description} .value=${f.description}
@input=${(e) => this._patchGroup('description', e.target.value)} @input=${(e) => this._patchGroup('description', e.target.value)}
/> />
</div> </div>
</div> </div>
<div class="apr-form-actions"> <div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelGroupEdit()}>Cancel</button> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelGroupEdit()}>${t('security.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveGroup()} ?disabled=${this._groupSaving}> <button class="btn btn-sm btn-primary" @click=${() => this._saveGroup()} ?disabled=${this._groupSaving}>
${this._groupSaving ${this._groupSaving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('security.form.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>Save`} : html`<i class="bi bi-check-lg me-1"></i>${t('security.form.save')}`}
</button> </button>
</div> </div>
</div> </div>
@@ -256,7 +265,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form"> <div class="apr-form">
<div class="apr-form-header"> <div class="apr-form-header">
<i class="bi bi-copy"></i> <i class="bi bi-copy"></i>
<span>Duplicate <strong>${src.name}</strong></span> <span>${t('security.duplicate_title', { name: src.name })}</span>
<button class="apr-form-close" @click=${() => this._cancelDuplicate()}> <button class="apr-form-close" @click=${() => this._cancelDuplicate()}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
@@ -264,7 +273,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form-body"> <div class="apr-form-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">New name <span class="text-danger">*</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.new_name')} <span class="text-danger">*</span></label>
<input <input
class="form-control form-control-sm" class="form-control form-control-sm"
.value=${f.name} .value=${f.name}
@@ -272,27 +281,27 @@ export class ApprovalGroupsPage extends LightElement {
/> />
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">New ID <span class="text-danger">*</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.new_id')} <span class="text-danger">*</span></label>
<input <input
class="form-control form-control-sm font-monospace" class="form-control form-control-sm font-monospace"
.value=${f.id} .value=${f.id}
@input=${(e) => { this._dupForm = { ...this._dupForm, id: e.target.value }; }} @input=${(e) => { this._dupForm = { ...this._dupForm, id: e.target.value }; }}
/> />
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div> <div class="form-text" style="font-size:0.75rem">${t('security.form.id_hint')}</div>
</div> </div>
</div> </div>
<div class="apr-form-body" style="padding:0;margin-top:0.5rem"> <div class="apr-form-body" style="padding:0;margin-top:0.5rem">
<div class="alert alert-info py-2 mb-0" style="font-size:0.8rem"> <div class="alert alert-info py-2 mb-0" style="font-size:0.8rem">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
All <strong>${this._rulesForGroup(src.id).length}</strong> rule${this._rulesForGroup(src.id).length === 1 ? '' : 's'} from <em>${src.name}</em> 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 }))}
</div> </div>
</div> </div>
<div class="apr-form-actions"> <div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>Cancel</button> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>${t('security.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveDuplicate()} ?disabled=${this._dupSaving}> <button class="btn btn-sm btn-primary" @click=${() => this._saveDuplicate()} ?disabled=${this._dupSaving}>
${this._dupSaving ${this._dupSaving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Duplicating` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('security.duplicating')}`
: html`<i class="bi bi-copy me-1"></i>Duplicate`} : html`<i class="bi bi-copy me-1"></i>${t('security.duplicate')}`}
</button> </button>
</div> </div>
</div> </div>
@@ -308,24 +317,24 @@ export class ApprovalGroupsPage extends LightElement {
return html` return html`
<div class="apr-card apr-group-card" @click=${() => this._navigateTo(group)}> <div class="apr-card apr-group-card" @click=${() => this._navigateTo(group)}>
<div class="apr-card-row1"> <div class="apr-card-row1">
${isDefault ? html`<span class="apr-group-default-badge">Default</span>` : nothing} ${isDefault ? html`<span class="apr-group-default-badge">${t('security.card.default_badge')}</span>` : nothing}
<span class="apr-group-name">${group.name}</span> <span class="apr-group-name">${group.name}</span>
<span class="apr-priority-badge ms-auto" title="${count} rule${count === 1 ? '' : 's'}"> <span class="apr-priority-badge ms-auto" title="${count === 1 ? t('security.card.rule_count', { n: count }) : t('security.card.rule_count_plural', { n: count })}">
<i class="bi bi-list-ul"></i> <i class="bi bi-list-ul"></i>
${count} ${count}
</span> </span>
<div class="apr-card-actions" @click=${(e) => e.stopPropagation()}> <div class="apr-card-actions" @click=${(e) => e.stopPropagation()}>
<button class="apr-btn-icon" title="Duplicate" <button class="apr-btn-icon" title=${t('security.card.duplicate')}
@click=${(e) => { e.stopPropagation(); this._startDuplicate(group); }}> @click=${(e) => { e.stopPropagation(); this._startDuplicate(group); }}>
<i class="bi bi-copy"></i> <i class="bi bi-copy"></i>
</button> </button>
<button class="apr-btn-icon apr-btn-edit" title="Rename" <button class="apr-btn-icon apr-btn-edit" title=${t('security.card.rename')}
@click=${(e) => { e.stopPropagation(); this._startEditGroup(group); }}> @click=${(e) => { e.stopPropagation(); this._startEditGroup(group); }}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button <button
class="apr-btn-icon apr-btn-delete" class="apr-btn-icon apr-btn-delete"
title=${isDefault ? 'Cannot delete the default group' : 'Delete group'} title=${isDefault ? t('security.card.delete_disabled') : t('security.card.delete')}
?disabled=${isDefault} ?disabled=${isDefault}
@click=${(e) => { e.stopPropagation(); if (!isDefault) this._deleteGroup(group); }} @click=${(e) => { e.stopPropagation(); if (!isDefault) this._deleteGroup(group); }}
> >
@@ -349,12 +358,12 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-page"> <div class="apr-page">
<div class="apr-header"> <div class="apr-header">
<h2 class="apr-title"> <h2 class="apr-title">
<i class="bi bi-shield-check me-2"></i>Security <i class="bi bi-shield-check me-2"></i>${t('security.title')}
</h2> </h2>
<div class="apr-header-right"> <div class="apr-header-right">
<span class="apr-header-count">${this._groups.length} group${this._groups.length === 1 ? '' : 's'}</span> <span class="apr-header-count">${this._groups.length === 1 ? t('security.group_count', { n: this._groups.length }) : t('security.group_count_plural', { n: this._groups.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}> <button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
<i class="bi bi-plus-lg me-1"></i>New group <i class="bi bi-plus-lg me-1"></i>${t('security.new_group')}
</button> </button>
</div> </div>
</div> </div>
@@ -362,15 +371,8 @@ export class ApprovalGroupsPage extends LightElement {
<div class="agent-info-banner" style="margin: 14px 20px 0"> <div class="agent-info-banner" style="margin: 14px 20px 0">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-1"> <p class="mb-1">${unsafeHTML(t('security.banner.text1'))}</p>
<strong>Permission groups</strong> are named sets of approval rules. <p class="mb-0">${unsafeHTML(t('security.banner.text2'))}</p>
A session's active <strong>Agent Profile</strong> determines which group applies —
that group's rules are evaluated first, with the <strong>Default</strong> group as fallback.
</p>
<p class="mb-0">
Click a group to view and manage its rules.
The <strong>Default</strong> group cannot be deleted, but its rules can be edited freely.
</p>
</div> </div>
</div> </div>
@@ -385,9 +387,9 @@ export class ApprovalGroupsPage extends LightElement {
${this._groups.length === 0 ? html` ${this._groups.length === 0 ? html`
<div class="apr-empty"> <div class="apr-empty">
<i class="bi bi-collection"></i> <i class="bi bi-collection"></i>
<p>No groups yet.</p> <p>${t('security.empty.title')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}> <button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
<i class="bi bi-plus-lg me-1"></i>Create first group <i class="bi bi-plus-lg me-1"></i>${t('security.create_first')}
</button> </button>
</div> </div>
` : this._groups.map(g => this._renderGroupCard(g))} ` : this._groups.map(g => this._renderGroupCard(g))}
+120 -98
View File
@@ -1,40 +1,29 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const DEFAULT_PRIORITY = 999999; const DEFAULT_PRIORITY = 999999;
const ACTIONS = ['require', 'allow', 'deny']; const ACTIONS = ['require', 'allow', 'deny'];
const ACTION_STYLE = { const ACTION_STYLE = {
require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' }, require: { icon: 'bi-person-check', 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' }, allow: { icon: 'bi-check-circle', 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' }, deny: { icon: 'bi-slash-circle', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
}; };
const CATEGORY_LABELS = { const CATEGORY_ORDER = ['filesystem', 'shell', 'subagent', 'introspection', 'config', 'dynamic'];
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',
];
// File System permission model. Each path row maps to exactly one approval rule via a // 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 // 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 // selector collapses the (access-class × action) axes into the mental model from the
// mockup: Allow read / Allow write / Deny / Require. // mockup: Allow read / Allow write / Deny / Require.
const FS_ACCESS = { const FS_ACCESS = {
allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' }, allow_read: { tool_pattern: '@fs_read', action: 'allow' },
allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' }, allow_write: { tool_pattern: '@fs_any', action: 'allow' },
deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' }, deny: { tool_pattern: '@fs_any', action: 'deny' },
require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' }, require: { tool_pattern: '@fs_any', action: 'require' },
}; };
// Priority band for the settable "Default" row (below specific fs path rules, above the // Priority band for the settable "Default" row (below specific fs path rules, above the
// global `*` catch-all at 999999). // global `*` catch-all at 999999).
@@ -91,6 +80,8 @@ export class ApprovalRulesPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
if (e.detail.page !== 'approval') { if (e.detail.page !== 'approval') {
this._open = false; this._open = false;
@@ -118,6 +109,11 @@ export class ApprovalRulesPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
this._error = null; this._error = null;
try { try {
@@ -125,8 +121,8 @@ export class ApprovalRulesPage extends LightElement {
fetch('/api/approval/rules'), fetch('/api/approval/rules'),
fetch('/api/approval/tools'), fetch('/api/approval/tools'),
]); ]);
if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`); if (!rulesRes.ok) throw new Error(`HTTP ${rulesRes.status}`);
if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`); if (!toolsRes.ok) throw new Error(`HTTP ${toolsRes.status}`);
this._rules = await rulesRes.json(); this._rules = await rulesRes.json();
this._tools = await toolsRes.json(); this._tools = await toolsRes.json();
} catch (e) { } catch (e) {
@@ -331,7 +327,7 @@ export class ApprovalRulesPage extends LightElement {
async _addFsRule() { async _addFsRule() {
const clean = this._normalizeFsPath(this._fsNewPath); 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; const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read;
this._fsSaving = new Set([...this._fsSaving, 'new']); this._fsSaving = new Set([...this._fsSaving, 'new']);
this._error = null; this._error = null;
@@ -386,7 +382,7 @@ export class ApprovalRulesPage extends LightElement {
} }
async _deleteFsRule(rule) { 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._fsSaving = new Set([...this._fsSaving, rule.id]);
this._error = null; this._error = null;
try { try {
@@ -443,15 +439,25 @@ export class ApprovalRulesPage extends LightElement {
// ── Tool grouping ───────────────────────────────────────────────────────────── // ── 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() { _groupedTools() {
if (!this._tools) return []; if (!this._tools) return [];
const map = new Map(); const map = new Map();
const metaMap = new Map(); // category key → { description } const metaMap = new Map();
for (const t of this._tools.built_in) { 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; 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, []); if (!map.has(cat)) map.set(cat, []);
map.get(cat).push(t); map.get(cat).push(t);
} }
@@ -459,7 +465,7 @@ export class ApprovalRulesPage extends LightElement {
for (const t of this._tools.mcp) { for (const t of this._tools.mcp) {
const serverId = t.server ?? t.name; const serverId = t.server ?? t.name;
const meta = servers[serverId] ?? {}; const meta = servers[serverId] ?? {};
const key = `MCP · ${meta.friendly_name ?? serverId}`; const key = `mcp:${serverId}`;
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, []); map.set(key, []);
if (meta.description) metaMap.set(key, meta.description); 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]); if (map.has(cat)) result.push([cat, map.get(cat), null]);
} }
for (const [key, tools] of map.entries()) { 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; return result;
} }
@@ -513,14 +519,14 @@ export class ApprovalRulesPage extends LightElement {
_selectTool(name) { this._form = { ...this._form, tool_pattern: name }; } _selectTool(name) { this._form = { ...this._form, tool_pattern: name }; }
async _save() { 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); const p = Number(this._form.priority);
if (this._formMode === 'override' && p >= 0) { 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)) { 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; this._saving = true;
@@ -555,7 +561,7 @@ export class ApprovalRulesPage extends LightElement {
} }
async _delete(rule) { 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 { try {
const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' }); const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); 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 current = this._form.tool_pattern;
const allTools = [ const allTools = [
{ name: '*', description: 'Any tool', source: 'glob', server: null }, { name: '*', description: t('approval.tool.any'), source: 'glob', server: null },
{ name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null }, { name: 'mcp__*', description: t('approval.tool.any_mcp'), source: 'glob', server: null },
...this._tools.built_in, ...this._tools.built_in,
...this._tools.mcp, ...this._tools.mcp,
]; ];
@@ -596,17 +602,21 @@ export class ApprovalRulesPage extends LightElement {
); );
const groups = {}; const groups = {};
for (const t of filtered) { for (const tool of filtered) {
const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob'; 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] = []; if (!groups[key]) groups[key] = [];
groups[key].push(t); groups[key].push(tool);
} }
return html` return html`
<div class="apr-tool-picker"> <div class="apr-tool-picker">
<input <input
class="form-control form-control-sm mb-2" class="form-control form-control-sm mb-2"
placeholder="Search tools…" placeholder=${t('approval.tool.search')}
.value=${this._toolFilter} .value=${this._toolFilter}
@input=${(e) => { this._toolFilter = e.target.value; }} @input=${(e) => { this._toolFilter = e.target.value; }}
/> />
@@ -624,7 +634,7 @@ export class ApprovalRulesPage extends LightElement {
</button> </button>
`)} `)}
`)} `)}
${filtered.length === 0 ? html`<div class="text-muted p-2">No results</div>` : nothing} ${filtered.length === 0 ? html`<div class="text-muted p-2">${t('approval.tool.no_results')}</div>` : nothing}
</div> </div>
</div> </div>
`; `;
@@ -640,8 +650,8 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-header"> <div class="apr-form-header">
<i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i> <i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i>
<span>${this._editingId === 'new' <span>${this._editingId === 'new'
? (isOverride ? 'New override rule' : 'New low priority rule') ? (isOverride ? t('approval.form.new_override') : t('approval.form.new_lowprio'))
: 'Edit rule'}</span> : t('approval.form.edit')}</span>
<button class="apr-form-close" @click=${() => this._cancelEdit()}> <button class="apr-form-close" @click=${() => this._cancelEdit()}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
@@ -649,31 +659,31 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-body"> <div class="apr-form-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Tool pattern <span class="text-danger">*</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.tool_pattern')} <span class="text-danger">*</span></label>
<input <input
class="form-control form-control-sm font-monospace" class="form-control form-control-sm font-monospace"
placeholder="e.g. mcp__whatsapp__* or execute_cmd" placeholder=${t('approval.form.tool_pattern_ph')}
.value=${f.tool_pattern} .value=${f.tool_pattern}
@input=${(e) => this._patch('tool_pattern', e.target.value)} @input=${(e) => this._patch('tool_pattern', e.target.value)}
/> />
<div class="form-text" style="font-size:0.75rem">Use <code>*</code> as a trailing wildcard, e.g. <code>mcp__whatsapp__*</code></div> <div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.tool_pattern_hint'))}</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Select tool</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.select_tool')}</label>
${this._renderToolPicker()} ${this._renderToolPicker()}
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path pattern <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.path_pattern')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input <input
class="form-control form-control-sm font-monospace" class="form-control form-control-sm font-monospace"
placeholder="e.g. data/* or data/notes/*" placeholder=${t('approval.form.path_pattern_ph')}
.value=${f.path_pattern} .value=${f.path_pattern}
@input=${(e) => this._patch('path_pattern', e.target.value)} @input=${(e) => this._patch('path_pattern', e.target.value)}
/> />
<div class="form-text" style="font-size:0.75rem">Filter by file path. Use <code>*</code> as a wildcard.</div> <div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.path_pattern_hint'))}</div>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Action</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.action')}</label>
<select <select
class="form-select form-select-sm" class="form-select form-select-sm"
.value=${f.action} .value=${f.action}
@@ -683,7 +693,7 @@ export class ApprovalRulesPage extends LightElement {
</select> </select>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.priority')}</label>
<input <input
type="number" type="number"
class="form-control form-control-sm" class="form-control form-control-sm"
@@ -692,47 +702,47 @@ export class ApprovalRulesPage extends LightElement {
/> />
<div class="form-text" style="font-size:0.75rem"> <div class="form-text" style="font-size:0.75rem">
${isOverride ${isOverride
? html`Must be <strong>&lt; 0</strong> (e.g. 10)` ? unsafeHTML(t('approval.form.priority_override_hint'))
: html`Must be <strong>1 ${DEFAULT_PRIORITY - 1}</strong>`} : unsafeHTML(t('approval.form.priority_lowprio_hint', { max: DEFAULT_PRIORITY - 1 }))}
</div> </div>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Source <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.source')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<select <select
class="form-select form-select-sm" class="form-select form-select-sm"
@change=${(e) => this._patch('source', e.target.value)} @change=${(e) => this._patch('source', e.target.value)}
> >
<option value="" ?selected=${!f.source}>Any</option> <option value="" ?selected=${!f.source}>${t('approval.form.source_any')}</option>
${['web', 'telegram', 'cron'].map(s => html` ${['web', 'telegram', 'cron'].map(s => html`
<option value=${s} ?selected=${f.source === s}>${s}</option> <option value=${s} ?selected=${f.source === s}>${s}</option>
`)} `)}
</select> </select>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent ID <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.agent_id')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input <input
class="form-control form-control-sm font-monospace" class="form-control form-control-sm font-monospace"
placeholder="main (empty = any)" placeholder=${t('approval.form.agent_id_ph')}
.value=${f.agent_id} .value=${f.agent_id}
@input=${(e) => this._patch('agent_id', e.target.value)} @input=${(e) => this._patch('agent_id', e.target.value)}
/> />
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Note <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.note')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input <input
class="form-control form-control-sm" class="form-control form-control-sm"
placeholder="Short description…" placeholder=${t('approval.form.note_ph')}
.value=${f.note} .value=${f.note}
@input=${(e) => this._patch('note', e.target.value)} @input=${(e) => this._patch('note', e.target.value)}
/> />
</div> </div>
</div> </div>
<div class="apr-form-actions"> <div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>Cancel</button> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>${t('approval.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()} ?disabled=${this._saving}> <button class="btn btn-sm btn-primary" @click=${() => this._save()} ?disabled=${this._saving}>
${this._saving ${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('approval.form.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>Save`} : html`<i class="bi bi-check-lg me-1"></i>${t('approval.form.save')}`}
</button> </button>
</div> </div>
</div> </div>
@@ -750,18 +760,18 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-card-row1"> <div class="apr-card-row1">
<span class="apr-action-badge"> <span class="apr-action-badge">
<i class="bi ${s.icon}"></i> <i class="bi ${s.icon}"></i>
${s.label} ${{ require: t('approval.action.require'), allow: t('approval.action.allow'), deny: t('approval.action.deny') }[rule.action] ?? rule.action}
</span> </span>
<code class="apr-pattern">${rule.tool_pattern}</code> <code class="apr-pattern">${rule.tool_pattern}</code>
<span class="apr-priority-badge" title="Priority"> <span class="apr-priority-badge" title=${t('approval.card.priority')}>
<i class="bi bi-list-ol"></i> <i class="bi bi-list-ol"></i>
${rule.priority} ${rule.priority}
</span> </span>
<div class="apr-card-actions"> <div class="apr-card-actions">
<button class="apr-btn-icon apr-btn-edit" title="Edit" @click=${() => this._startEdit(rule)}> <button class="apr-btn-icon apr-btn-edit" title=${t('approval.card.edit')} @click=${() => this._startEdit(rule)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="apr-btn-icon apr-btn-delete" title="Delete" @click=${() => this._delete(rule)}> <button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.delete')} @click=${() => this._delete(rule)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -784,10 +794,10 @@ export class ApprovalRulesPage extends LightElement {
_renderChipGroup(currentAction, onChange) { _renderChipGroup(currentAction, onChange) {
const chips = [ const chips = [
{ action: null, label: '—' }, { action: null, label: t('approval.chip.unset') },
{ action: 'allow', label: 'Allow' }, { action: 'allow', label: t('approval.action.allow') },
{ action: 'require', label: 'Req' }, { action: 'require', label: t('approval.chip.req') },
{ action: 'deny', label: 'Deny' }, { action: 'deny', label: t('approval.action.deny') },
]; ];
return html` return html`
<div class="apr-chip-group"> <div class="apr-chip-group">
@@ -827,11 +837,14 @@ export class ApprovalRulesPage extends LightElement {
const open = this._openSections.has(key); const open = this._openSections.has(key);
const groupId = this._selectedGroup.id; const groupId = this._selectedGroup.id;
const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length; 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` return html`
<div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}"> <div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}">
<div class="apr-cat-header" @click=${() => this._toggleSection(key)}> <div class="apr-cat-header" @click=${() => this._toggleSection(key)}>
<i class="bi bi-chevron-${open ? 'down' : 'right'} apr-cat-chevron"></i> <i class="bi bi-chevron-${open ? 'down' : 'right'} apr-cat-chevron"></i>
<span class="apr-cat-name">${key}</span> <span class="apr-cat-name">${label}</span>
${description ? html`<span class="apr-cat-desc">${description}</span>` : nothing} ${description ? html`<span class="apr-cat-desc">${description}</span>` : nothing}
<span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}"> <span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}">
${configured > 0 ? `${configured}/` : ''}${tools.length} ${configured > 0 ? `${configured}/` : ''}${tools.length}
@@ -851,12 +864,12 @@ export class ApprovalRulesPage extends LightElement {
return html` return html`
<div class="apr-matrix"> <div class="apr-matrix">
<div class="apr-matrix-header"> <div class="apr-matrix-header">
<span class="apr-matrix-title">Per-tool</span> <span class="apr-matrix-title">${t('approval.matrix.title')}</span>
<span class="apr-matrix-subtitle">priority = 0 · exact tool name · no path/source filters</span> <span class="apr-matrix-subtitle">${t('approval.matrix.subtitle')}</span>
</div> </div>
<div class="apr-matrix-body"> <div class="apr-matrix-body">
${groups.length === 0 ${groups.length === 0
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">Loading tools…</div>` ? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">${t('approval.matrix.loading')}</div>`
: groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))} : groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
</div> </div>
</div> </div>
@@ -865,6 +878,15 @@ export class ApprovalRulesPage extends LightElement {
// ── File System panel ───────────────────────────────────────────────────────── // ── 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) { _renderFsAccessSelect(value, onChange, allowUnset) {
return html` return html`
<select <select
@@ -872,10 +894,10 @@ export class ApprovalRulesPage extends LightElement {
@change=${(e) => onChange(e.target.value || null)} @change=${(e) => onChange(e.target.value || null)}
> >
${allowUnset ${allowUnset
? html`<option value="" ?selected=${!value}>Require (system default)</option>` ? html`<option value="" ?selected=${!value}>${t('approval.fs.default')}</option>`
: nothing} : nothing}
${Object.entries(FS_ACCESS).map(([k, v]) => html` ${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${value === k}>${v.label}</option> <option value=${k} ?selected=${value === k}>${this._fsAccessLabel(k)}</option>
`)} `)}
</select> </select>
`; `;
@@ -892,7 +914,7 @@ export class ApprovalRulesPage extends LightElement {
? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0"></span>` ? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0"></span>`
: html` : html`
${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)} ${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)}
<button class="apr-btn-icon apr-btn-delete" title="Remove" @click=${() => this._deleteFsRule(rule)}> <button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.remove')} @click=${() => this._deleteFsRule(rule)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
`} `}
@@ -907,7 +929,7 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-plus-circle apr-fs-row-icon"></i> <i class="bi bi-plus-circle apr-fs-row-icon"></i>
<input <input
class="form-control form-control-sm font-monospace apr-fs-path-input" class="form-control form-control-sm font-monospace apr-fs-path-input"
placeholder="Add directory path, e.g. docs" placeholder=${t('approval.fs.add_ph')}
.value=${this._fsNewPath} .value=${this._fsNewPath}
@input=${(e) => { this._fsNewPath = e.target.value; }} @input=${(e) => { this._fsNewPath = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }} @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" class="form-select form-select-sm apr-fs-select"
@change=${(e) => { this._fsNewAccess = e.target.value; }} @change=${(e) => { this._fsNewAccess = e.target.value; }}
> >
${Object.entries(FS_ACCESS).map(([k, v]) => html` ${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${v.label}</option> <option value=${k} ?selected=${this._fsNewAccess === k}>${this._fsAccessLabel(k)}</option>
`)} `)}
</select> </select>
<button class="btn btn-sm btn-primary apr-fs-add-btn" @click=${() => this._addFsRule()} ?disabled=${saving}> <button class="btn btn-sm btn-primary apr-fs-add-btn" @click=${() => this._addFsRule()} ?disabled=${saving}>
@@ -940,19 +962,19 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-side-panel-header" @click=${() => { this._fsOpen = !this._fsOpen; }}> <div class="apr-side-panel-header" @click=${() => { this._fsOpen = !this._fsOpen; }}>
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i> <i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
<i class="bi bi-hdd-stack apr-panel-icon"></i> <i class="bi bi-hdd-stack apr-panel-icon"></i>
<span class="apr-panel-title">File System</span> <span class="apr-panel-title">${t('approval.fs.title')}</span>
<span class="apr-panel-subtitle">path-scoped read / write access</span> <span class="apr-panel-subtitle">${t('approval.fs.subtitle')}</span>
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing} ${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
</div> </div>
${isOpen ? html` ${isOpen ? html`
<div class="apr-side-panel-body"> <div class="apr-side-panel-body">
${rules.length === 0 ${rules.length === 0
? html`<div class="apr-panel-empty">No path rules yet — add one below.</div>` ? html`<div class="apr-panel-empty">${t('approval.fs.empty')}</div>`
: rules.map(r => this._renderFsRow(r))} : rules.map(r => this._renderFsRow(r))}
${this._renderFsAddRow()} ${this._renderFsAddRow()}
<div class="apr-fs-row apr-fs-default"> <div class="apr-fs-row apr-fs-default">
<i class="bi bi-skip-end-fill apr-fs-row-icon"></i> <i class="bi bi-skip-end-fill apr-fs-row-icon"></i>
<span class="apr-fs-path apr-fs-default-label">Default <span class="apr-default-hint">unmatched paths</span></span> <span class="apr-fs-path apr-fs-default-label">${t('approval.fs.default_label')} <span class="apr-default-hint">${t('approval.fs.default_hint')}</span></span>
${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)} ${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)}
</div> </div>
</div> </div>
@@ -976,13 +998,13 @@ export class ApprovalRulesPage extends LightElement {
<button <button
class="btn btn-sm btn-outline-secondary apr-panel-add-btn" class="btn btn-sm btn-outline-secondary apr-panel-add-btn"
@click=${(e) => { e.stopPropagation(); onAdd(); }} @click=${(e) => { e.stopPropagation(); onAdd(); }}
><i class="bi bi-plus-lg me-1"></i>Add</button> ><i class="bi bi-plus-lg me-1"></i>${t('approval.sidebar.add')}</button>
</div> </div>
${isOpen ? html` ${isOpen ? html`
<div class="apr-side-panel-body"> <div class="apr-side-panel-body">
${formActive ? this._renderForm() : nothing} ${formActive ? this._renderForm() : nothing}
${rules.length === 0 && !formActive ${rules.length === 0 && !formActive
? html`<div class="apr-panel-empty">No rules yet.</div>` ? html`<div class="apr-panel-empty">${t('approval.sidebar.empty')}</div>`
: rules.map(r => this._renderCard(r))} : rules.map(r => this._renderCard(r))}
</div> </div>
` : nothing} ` : nothing}
@@ -998,12 +1020,12 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-default-bar"> <div class="apr-default-bar">
<div class="apr-default-label"> <div class="apr-default-label">
<i class="bi bi-skip-end-fill me-1"></i> <i class="bi bi-skip-end-fill me-1"></i>
<strong>Default action</strong> <strong>${t('approval.default_bar.title')}</strong>
<span class="apr-default-hint">if no rule matches</span> <span class="apr-default-hint">${t('approval.default_bar.hint')}</span>
</div> </div>
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))} ${this._renderChipGroup(action, (a) => this._setDefaultAction(a))}
${action === null ${action === null
? html`<span class="apr-default-unset">system default: allow</span>` ? html`<span class="apr-default-unset">${t('approval.default_bar.unset')}</span>`
: nothing} : nothing}
</div> </div>
`; `;
@@ -1029,11 +1051,11 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
<h2 class="apr-title"> <h2 class="apr-title">
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">Default</span>` : nothing} ${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">${t('approval.header.default_badge')}</span>` : nothing}
${group.name} ${group.name}
</h2> </h2>
<div class="apr-header-right"> <div class="apr-header-right">
<span class="apr-header-count">${totalRules} rule${totalRules === 1 ? '' : 's'}</span> <span class="apr-header-count">${totalRules === 1 ? t('approval.header.rule_count', { n: totalRules }) : t('approval.header.rule_count_plural', { n: totalRules })}</span>
</div> </div>
</div> </div>
@@ -1044,9 +1066,9 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-rules-body"> <div class="apr-rules-body">
${this._renderSidePanel( ${this._renderSidePanel(
'override', 'override',
'Overrides', t('approval.sidebar.overrides'),
'bi-exclamation-triangle-fill', 'bi-exclamation-triangle-fill',
'priority < 0 · evaluated first', t('approval.sidebar.overrides_sub'),
overrides, overrides,
this._overrideOpen, this._overrideOpen,
() => { this._overrideOpen = !this._overrideOpen; }, () => { this._overrideOpen = !this._overrideOpen; },
@@ -1059,9 +1081,9 @@ export class ApprovalRulesPage extends LightElement {
${this._renderSidePanel( ${this._renderSidePanel(
'lowprio', 'lowprio',
'Low Priority', t('approval.sidebar.lowprio'),
'bi-arrow-down-circle-fill', 'bi-arrow-down-circle-fill',
'priority 1999998 · evaluated after per-tool', t('approval.sidebar.lowprio_sub'),
lowPrio, lowPrio,
this._lowPrioOpen, this._lowPrioOpen,
() => { this._lowPrioOpen = !this._lowPrioOpen; }, () => { this._lowPrioOpen = !this._lowPrioOpen; },
+44 -51
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Connector catalog — blueprint §14/§15. Admin only. // Connector catalog — blueprint §14/§15. Admin only.
// //
@@ -54,15 +56,21 @@ export class CatalogPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'catalog'; this._open = e.detail.page === 'catalog';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load(); if (this._open) this._load();
}); });
// Close the chooser when clicking anywhere else.
document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); 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; } get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() { async _load() {
@@ -108,7 +116,7 @@ export class CatalogPage extends LightElement {
async _saveManual() { async _saveManual() {
const f = this._modal.form; 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); const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
try { try {
await jf('/api/mcp/catalog', { await jf('/api/mcp/catalog', {
@@ -135,7 +143,7 @@ export class CatalogPage extends LightElement {
} }
async _delete(row) { 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 { try {
await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' });
await this._load(); await this._load();
@@ -152,7 +160,7 @@ export class CatalogPage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-journal-text me-2"></i>Connector Catalog</h2> <h2 class="um-title"><i class="bi bi-journal-text me-2"></i>${t('catalog.title')}</h2>
<div class="um-header-right"> <div class="um-header-right">
${this._isAdmin ? this._renderAddButton() : nothing} ${this._isAdmin ? this._renderAddButton() : nothing}
</div> </div>
@@ -165,20 +173,13 @@ export class CatalogPage extends LightElement {
${this._me && !this._isAdmin ? html` ${this._me && !this._isAdmin ? html`
<div class="um-empty" style="padding:2rem"> <div class="um-empty" style="padding:2rem">
<i class="bi bi-shield-lock"></i> <i class="bi bi-shield-lock"></i>
<p>The catalog is managed by the admin.</p> <p>${t('catalog.not_admin')}</p>
<p style="font-size:.8rem;opacity:.7"> <p style="font-size:.8rem;opacity:.7">${unsafeHTML(t('catalog.not_admin_link'))}</p>
What you can activate is on the
<a href="#connectors" @click=${(e) => { e.preventDefault(); this._goConnectors(); }}>Connectors</a> page.
</p>
</div> </div>
` : loading ? html` ` : loading ? html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>Loading…</p></div> <div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>${t('catalog.loading')}</p></div>
` : html` ` : html`
<div class="text-muted mt-3 mb-3" style="font-size:.8rem"> <div class="text-muted mt-3 mb-3" style="font-size:.8rem">${unsafeHTML(t('catalog.desc'))}</div>
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
<a href="#connectors" @click=${(e) => { e.preventDefault(); this._goConnectors(); }}>Connectors</a> page.
</div>
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} ${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)}
`} `}
</div> </div>
@@ -194,27 +195,23 @@ export class CatalogPage extends LightElement {
return html` return html`
<div class="dropdown" style="position:relative" @click=${(e) => e.stopPropagation()}> <div class="dropdown" style="position:relative" @click=${(e) => e.stopPropagation()}>
<button class="btn btn-sm btn-primary" @click=${() => { this._addOpen = !this._addOpen; }}> <button class="btn btn-sm btn-primary" @click=${() => { this._addOpen = !this._addOpen; }}>
<i class="bi bi-plus-lg me-1"></i>Add connector <i class="bi bi-plus-lg me-1"></i>${t('catalog.btn.add')}
<i class="bi bi-chevron-down ms-1" style="font-size:.7rem"></i> <i class="bi bi-chevron-down ms-1" style="font-size:.7rem"></i>
</button> </button>
${this._addOpen ? html` ${this._addOpen ? html`
<div class="dropdown-menu show" style="right:0;left:auto;top:calc(100% + .25rem);min-width:280px"> <div class="dropdown-menu show" style="right:0;left:auto;top:calc(100% + .25rem);min-width:280px">
<button class="dropdown-item" style="white-space:normal" @click=${() => this._goMarketplace()}> <button class="dropdown-item" style="white-space:normal" @click=${() => this._goMarketplace()}>
<div style="display:flex;align-items:center;gap:.5rem"> <div style="display:flex;align-items:center;gap:.5rem">
<i class="bi bi-shop"></i><strong style="font-size:.85rem">From the marketplace</strong> <i class="bi bi-shop"></i><strong style="font-size:.85rem">${t('catalog.dropdown.marketplace')}</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
Vetted connectors, files verified by SHA-256.
</div> </div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">${t('catalog.dropdown.marketplace_desc')}</div>
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button class="dropdown-item" style="white-space:normal" @click=${() => this._openManual()}> <button class="dropdown-item" style="white-space:normal" @click=${() => this._openManual()}>
<div style="display:flex;align-items:center;gap:.5rem"> <div style="display:flex;align-items:center;gap:.5rem">
<i class="bi bi-pencil"></i><strong style="font-size:.85rem">Manually</strong> <i class="bi bi-pencil"></i><strong style="font-size:.85rem">${t('catalog.dropdown.manual')}</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
You supply the config, and vouch for it yourself.
</div> </div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">${t('catalog.dropdown.manual_desc')}</div>
</button> </button>
</div>` : nothing} </div>` : nothing}
</div>`; </div>`;
@@ -224,10 +221,10 @@ export class CatalogPage extends LightElement {
return html` return html`
<div class="um-empty" style="padding:2rem"> <div class="um-empty" style="padding:2rem">
<i class="bi bi-journal"></i> <i class="bi bi-journal"></i>
<p>The catalog is empty.</p> <p>${t('catalog.empty.title')}</p>
<p style="font-size:.8rem;opacity:.7">Add a connector from the marketplace to get started.</p> <p style="font-size:.8rem;opacity:.7">${t('catalog.empty.hint')}</p>
<button class="btn btn-sm btn-primary mt-2" @click=${() => this._goMarketplace()}> <button class="btn btn-sm btn-primary mt-2" @click=${() => this._goMarketplace()}>
<i class="bi bi-shop me-1"></i>Browse the marketplace <i class="bi bi-shop me-1"></i>${t('catalog.empty.action')}
</button> </button>
</div>`; </div>`;
} }
@@ -235,7 +232,7 @@ export class CatalogPage extends LightElement {
_renderTable(rows) { _renderTable(rows) {
return html` return html`
<table class="um-table"> <table class="um-table">
<thead><tr><th>Connector</th><th>Scope</th><th>Type</th><th>Auth</th><th></th></tr></thead> <thead><tr><th>${t('catalog.table.connector')}</th><th>${t('catalog.table.scope')}</th><th>${t('catalog.table.type')}</th><th>${t('catalog.table.auth')}</th><th></th></tr></thead>
<tbody> <tbody>
${rows.map(r => html` ${rows.map(r => html`
<tr> <tr>
@@ -247,12 +244,12 @@ export class CatalogPage extends LightElement {
text-overflow:ellipsis;white-space:nowrap" title=${r.description}>${r.description}</div>` : nothing} text-overflow:ellipsis;white-space:nowrap" title=${r.description}>${r.description}</div>` : nothing}
</td> </td>
<td><span class="badge ${r.scope === 'global' ? 'bg-info' : 'bg-secondary'}" style="font-size:.65rem"> <td><span class="badge ${r.scope === 'global' ? 'bg-info' : 'bg-secondary'}" style="font-size:.65rem">
${r.scope === 'global' ? 'global' : 'per-user'}</span></td> ${r.scope === 'global' ? t('catalog.badge.global') : t('catalog.badge.per_user')}</span></td>
<td><span class="badge ${r.source === 'local_script' ? 'bg-warning text-dark' : 'bg-secondary'}" style="font-size:.65rem"> <td><span class="badge ${r.source === 'local_script' ? 'bg-warning text-dark' : 'bg-secondary'}" style="font-size:.65rem">
${r.source === 'local_script' ? 'local script' : 'remote'}</span></td> ${r.source === 'local_script' ? t('catalog.badge.local_script') : t('catalog.badge.remote')}</span></td>
<td><span class="text-muted" style="font-size:.78rem">${r.auth_kind}</span></td> <td><span class="text-muted" style="font-size:.78rem">${r.auth_kind}</span></td>
<td><div class="um-actions"> <td><div class="um-actions">
<button class="um-btn-icon" title="Remove from catalog" @click=${() => this._delete(r)}> <button class="um-btn-icon" title=${t('catalog.action.remove')} @click=${() => this._delete(r)}>
<i class="bi bi-trash"></i></button> <i class="bi bi-trash"></i></button>
</div></td> </div></td>
</tr>`)} </tr>`)}
@@ -285,35 +282,31 @@ export class CatalogPage extends LightElement {
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> <div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal"> <div class="um-modal">
<div class="um-modal-header"> <div class="um-modal-header">
<i class="bi bi-pencil"></i><span>Add connector manually</span> <i class="bi bi-pencil"></i><span>${t('catalog.modal.title')}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button> <button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="um-modal-body"> <div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${isScript ? html` ${isScript ? html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem"> <div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">${unsafeHTML(t('catalog.modal.script_warn'))}</div>` : nothing}
<i class="bi bi-exclamation-triangle me-1"></i>A local script runs code on this box. ${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })}
Nothing verifies it — unlike the marketplace path, there is no digest to check. ${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
</div>` : nothing} ${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'slug', mono: true })} ${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
${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))}
${isScript ${isScript
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })} ? 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('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as <connector>/<file>, under ./connectors', 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('URL', f.url, e => this._patch('url', e.target.value), { mono: true })} : this._field(t('catalog.modal.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(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), 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._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('Auth', f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} ${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('Friendly name', f.friendly_name, e => this._patch('friendly_name', e.target.value))} ${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))}
${this._field('Description', f.description, e => this._patch('description', e.target.value), ${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })}
{ hint: 'the LLM reads this when deciding to activate the connector' })}
</div> </div>
<div class="um-modal-footer"> <div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('catalog.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveManual()}> <button class="btn btn-sm btn-primary" @click=${() => this._saveManual()}>
<i class="bi bi-check-lg me-1"></i>Add to catalog</button> <i class="bi bi-check-lg me-1"></i>${t('catalog.modal.save')}</button>
</div> </div>
</div> </div>
</div>`; </div>`;
+72 -4
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class ConfigPage extends LightElement { export class ConfigPage extends LightElement {
static properties = { static properties = {
@@ -9,6 +10,8 @@ export class ConfigPage extends LightElement {
_saving: { state: true }, // Set<key> _saving: { state: true }, // Set<key>
_saved: { state: true }, // Set<key> (brief flash) _saved: { state: true }, // Set<key> (brief flash)
_error: { state: true }, _error: { state: true },
_debugMode: { state: true },
_debugLoading: { state: true },
}; };
constructor() { constructor() {
@@ -19,17 +22,55 @@ export class ConfigPage extends LightElement {
this._saving = new Set(); this._saving = new Set();
this._saved = new Set(); this._saved = new Set();
this._error = null; this._error = null;
this._debugMode = false;
this._debugLoading = true;
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'config'; this._open = e.detail.page === 'config';
this.style.display = this._open ? 'flex' : 'none'; 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() { async _load() {
this._error = null; this._error = null;
try { try {
@@ -70,7 +111,7 @@ export class ConfigPage extends LightElement {
this._saved = new Set([...this._saved].filter(k => k !== key)); this._saved = new Set([...this._saved].filter(k => k !== key));
}, 1500); }, 1500);
} catch (e) { } catch (e) {
alert(`Error saving ${prop.name}: ${e.message}`); alert(t('config.error_save', { name: prop.name, msg: e.message }));
} finally { } finally {
this._saving = new Set([...this._saving].filter(k => k !== key)); this._saving = new Set([...this._saving].filter(k => k !== key));
} }
@@ -164,18 +205,45 @@ export class ConfigPage extends LightElement {
return html` return html`
<div class="config-page"> <div class="config-page">
<div class="config-page-header"> <div class="config-page-header">
<h2 class="llm-page-title">Config</h2> <h2 class="llm-page-title">${t('config.title')}</h2>
</div> </div>
${this._error ? html` ${this._error ? html`
<div class="alert alert-danger">${this._error}</div>` : nothing} <div class="alert alert-danger">${this._error}</div>` : nothing}
${this._properties.length === 0 && !this._error ? html` ${this._properties.length === 0 && !this._error ? html`
<p class="text-muted mt-2">Loading…</p>` : nothing} <p class="text-muted mt-2">${t('config.loading')}</p>` : nothing}
<div class="config-sets"> <div class="config-sets">
${this._properties.map(s => this._renderSet(s))} ${this._properties.map(s => this._renderSet(s))}
</div> </div>
<div class="config-set">
<div class="config-set-header">
<div class="config-set-name">${t('config.developer')}</div>
<div class="config-set-desc"></div>
</div>
<div class="config-rows">
<div class="config-row">
<div class="config-row-meta">
<div class="config-row-name">${t('config.debug')}</div>
<div class="config-row-desc">${t('config.debug.desc')}</div>
</div>
<div class="config-row-control">
<div class="form-check form-switch config-bool-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="cfg-debug-mode"
.checked=${this._debugMode}
?disabled=${this._debugLoading}
@change=${() => this._toggleDebugMode()} />
<label class="form-check-label" for="cfg-debug-mode">
${this._debugMode ? 'Enabled' : 'Disabled'}
</label>
</div>
</div>
</div>
</div>
</div>
</div>`; </div>`;
} }
} }
+58 -53
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { import {
announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf, announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf,
} from './shared/connector-common.js'; } from './shared/connector-common.js';
@@ -75,6 +76,8 @@ export class ConnectorDetailPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; 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 _isAdmin() { return this._me?.role_id === ADMIN_ID; }
get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; }
get _status() { return statusOf({ _act: this._act, _glob: this._glob }); } 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; const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null;
if (!entry && !glob) { 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; return;
} }
this._entry = entry; this._entry = entry;
@@ -193,7 +201,7 @@ export class ConnectorDetailPage extends LightElement {
}); });
if (res?.auth_state === 'pending') { if (res?.auth_state === 'pending') {
this._test = res.verify ?? { ok: false, message: 'Verification failed.' }; 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) { } else if (res?.error) {
this._error = res.error; this._error = res.error;
} }
@@ -204,7 +212,7 @@ export class ConnectorDetailPage extends LightElement {
} }
async _deactivate() { 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; this._busy = true;
try { try {
await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' }); 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) { if (res?.verify && !res.verify.ok && !res.verify.skipped) {
this._test = res.verify; 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) { } else if (res?.error) {
this._error = res.error; this._error = res.error;
} }
@@ -284,7 +292,7 @@ export class ConnectorDetailPage extends LightElement {
} }
async _disableGlobal() { 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; this._busy = true;
try { try {
await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' }); await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' });
@@ -328,7 +336,7 @@ export class ConnectorDetailPage extends LightElement {
} }
if (!this._entry && !this._glob) { if (!this._entry && !this._glob) {
return html`<div class="um-page">${this._renderHeader()} return html`<div class="um-page">${this._renderHeader()}
<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div></div>`; <div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('connectors.loading')}</div></div>`;
} }
return html` return html`
@@ -349,7 +357,7 @@ export class ConnectorDetailPage extends LightElement {
return html` return html`
<div class="um-header"> <div class="um-header">
<div class="d-flex align-items-center gap-2" style="min-width:0"> <div class="d-flex align-items-center gap-2" style="min-width:0">
<button class="btn btn-sm btn-outline-secondary" title="Back" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary" title=${t('connectors.detail.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">${title}</h2> <h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">${title}</h2>
@@ -362,6 +370,7 @@ export class ConnectorDetailPage extends LightElement {
const isScript = e?.source === 'local_script'; const isScript = e?.source === 'local_script';
const status = this._status; const status = this._status;
const desc = e?.description || this._glob?.description; 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` return html`
<div class="connector-card" style="margin-top:1rem"> <div class="connector-card" style="margin-top:1rem">
@@ -382,24 +391,24 @@ export class ConnectorDetailPage extends LightElement {
${desc ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${desc}</div>` : nothing} ${desc ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${desc}</div>` : nothing}
<div class="connector-chips"> <div class="connector-chips">
<span class="connector-chip connector-chip--scope"> <span class="connector-chip connector-chip--scope">
<i class="bi ${this._isGlobal ? 'bi-globe' : 'bi-person'}"></i>${this._isGlobal ? 'global' : 'per-user'} <i class="bi ${this._isGlobal ? 'bi-globe' : 'bi-person'}"></i>${this._isGlobal ? t('connectors.detail.detail_scope_global') : t('connectors.chip.per_user')}
</span> </span>
${isScript ? html` ${isScript ? html`
<span class="connector-chip connector-chip--script"> <span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>runs code on this box <i class="bi bi-file-earmark-code"></i>${t('connectors.detail.scope_local')}
</span>` : nothing} </span>` : nothing}
${e?.auth_kind && e.auth_kind !== 'none' ? html` ${e?.auth_kind && e.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${e.auth_kind}</span>` : nothing} <span class="connector-chip"><i class="bi bi-key"></i>${e.auth_kind}</span>` : nothing}
${status === 'active' ? html` ${status === 'active' ? html`
<span class="connector-chip connector-chip--ok"><i class="bi bi-check-circle"></i>active</span>` : nothing} <span class="connector-chip connector-chip--ok"><i class="bi bi-check-circle"></i>${t('connectors.detail.status.active')}</span>` : nothing}
${status === 'pending' ? html` ${status === 'pending' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-exclamation-triangle"></i>needs fixing</span>` : nothing} <span class="connector-chip connector-chip--script"><i class="bi bi-exclamation-triangle"></i>${t('connectors.detail.status.needs_fix')}</span>` : nothing}
${status === 'needs_login' ? html` ${status === 'needs_login' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-box-arrow-in-right"></i>needs sign-in</span>` : nothing} <span class="connector-chip connector-chip--script"><i class="bi bi-box-arrow-in-right"></i>${t('connectors.detail.status.needs_signin')}</span>` : nothing}
</div> </div>
${this._isGlobal ? html` ${this._isGlobal ? html`
<div class="connector-card-note"> <div class="connector-card-note">
<i class="bi bi-info-circle"></i>Runs once for the household, on the host. Nobody reaches it until they are granted access. <i class="bi bi-info-circle"></i>${t('connectors.detail.global_note')}
</div>` : nothing} </div>` : nothing}
</div>`; </div>`;
} }
@@ -412,8 +421,8 @@ export class ConnectorDetailPage extends LightElement {
return html` return html`
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<div class="um-empty" style="padding:1rem"><i class="bi bi-check2-circle"></i> <div class="um-empty" style="padding:1rem"><i class="bi bi-check2-circle"></i>
<p>This connector is managed for you.</p> <p>${t('connectors.detail.managed.title')}</p>
<p style="font-size:.8rem;opacity:.7">It is enabled by an admin and granted to you — there is nothing to configure.</p> <p style="font-size:.8rem;opacity:.7">${t('connectors.detail.managed.desc')}</p>
</div> </div>
</div>`; </div>`;
} }
@@ -436,7 +445,7 @@ export class ConnectorDetailPage extends LightElement {
return html` return html`
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem"> <div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-key me-2"></i>Sign in</h3> <h3 class="um-title" style="font-size:1rem"><i class="bi bi-key me-2"></i>${t('connectors.detail.oauth.title')}</h3>
</div> </div>
${this._renderOauth()} ${this._renderOauth()}
</div>`; </div>`;
@@ -446,20 +455,20 @@ export class ConnectorDetailPage extends LightElement {
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem"> <div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"> <h3 class="um-title" style="font-size:1rem">
<i class="bi bi-sliders me-2"></i>${active ? 'Configuration' : 'Set up'} <i class="bi bi-sliders me-2"></i>${active ? t('connectors.detail.config.title_active') : t('connectors.detail.config.title_setup')}
</h3> </h3>
</div> </div>
${active ? html` ${active ? html`
<div class="text-muted mb-3" style="font-size:.78rem"> <div class="text-muted mb-3" style="font-size:.78rem">
${this._isGlobal ${this._isGlobal
? 'Already enabled. Re-submitting replaces the stored credentials.' ? t('connectors.detail.config.already_global')
: 'Already active. Re-submitting replaces the stored credentials.'} : t('connectors.detail.config.already_user')}
</div>` : nothing} </div>` : nothing}
${e.auth_kind === 'api_key' && !schemaHasSecret ? html` ${e.auth_kind === 'api_key' && !schemaHasSecret ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API key<span class="text-danger">*</span></label> <label class="form-label">${t('connectors.detail.config.api_key')}<span class="text-danger">*</span></label>
<input class="form-control" type="password" .value=${this._form.api_key} <input class="form-control" type="password" .value=${this._form.api_key}
@input=${(ev) => { this._form = { ...this._form, api_key: ev.target.value }; }} /> @input=${(ev) => { this._form = { ...this._form, api_key: ev.target.value }; }} />
</div>` : nothing} </div>` : nothing}
@@ -472,25 +481,25 @@ export class ConnectorDetailPage extends LightElement {
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._test === 'running' || this._busy} <button class="btn btn-sm btn-outline-secondary" ?disabled=${this._test === 'running' || this._busy}
@click=${() => this._testCreds()}> @click=${() => this._testCreds()}>
<i class="bi bi-${this._test === 'running' ? 'arrow-repeat' : 'check2-gear'} me-1"></i> <i class="bi bi-${this._test === 'running' ? 'arrow-repeat' : 'check2-gear'} me-1"></i>
${this._test === 'running' ? 'Testing' : 'Test credentials'} ${this._test === 'running' ? t('connectors.detail.config.btn_testing') : t('connectors.detail.config.btn_test')}
</button>` : nothing} </button>` : nothing}
${this._isGlobal ${this._isGlobal
? html` ? html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._enableGlobal()}> <button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._enableGlobal()}>
<i class="bi bi-globe me-1"></i>${this._glob ? 'Save & restart' : 'Enable globally'} <i class="bi bi-globe me-1"></i>${this._glob ? t('connectors.detail.config.btn_save_restart') : t('connectors.detail.config.btn_enable_global')}
</button> </button>
${this._glob ? html` ${this._glob ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._disableGlobal()}> <button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._disableGlobal()}>
<i class="bi bi-trash me-1"></i>Disable <i class="bi bi-trash me-1"></i>${t('connectors.detail.config.btn_disable')}
</button>` : nothing}` </button>` : nothing}`
: html` : html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._activate()}> <button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._activate()}>
<i class="bi bi-plug me-1"></i>${this._act ? 'Save & restart' : 'Activate'} <i class="bi bi-plug me-1"></i>${this._act ? t('connectors.detail.config.btn_save_restart') : t('connectors.detail.config.btn_activate')}
</button> </button>
${this._act ? html` ${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}> <button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate <i class="bi bi-trash me-1"></i>${t('connectors.detail.config.btn_deactivate')}
</button>` : nothing}`} </button>` : nothing}`}
</div> </div>
</div>`; </div>`;
@@ -504,40 +513,38 @@ export class ConnectorDetailPage extends LightElement {
const scopes = parseJson(this._entry?.oauth_scopes_json, []); const scopes = parseJson(this._entry?.oauth_scopes_json, []);
return html` return html`
<div class="text-muted mb-3" style="font-size:.78rem"> <div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.detail.oauth.desc', { provider: label })}</div>
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.
</div>
${scopes.length ? html` ${scopes.length ? html`
<div class="mb-3" style="font-size:.72rem"> <div class="mb-3" style="font-size:.72rem">
<div class="text-muted mb-1">It will request access to:</div> <div class="text-muted mb-1">${t('connectors.detail.oauth.scopes')}</div>
<ul class="mb-0 ps-3">${scopes.map(s => html`<li><code style="font-size:.68rem">${s}</code></li>`)}</ul> <ul class="mb-0 ps-3">${scopes.map(s => html`<li><code style="font-size:.68rem">${s}</code></li>`)}</ul>
</div>` : nothing} </div>` : nothing}
${active ? html` ${active ? html`
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem"> <div class="alert alert-success py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-check-circle-fill me-1"></i>Signed in and active. <i class="bi bi-check-circle-fill me-1"></i>${t('connectors.detail.oauth.signed_in')}
</div>` : nothing} </div>` : nothing}
${!this._oauth ? html` ${!this._oauth ? html`
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._startOauth()}> <button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._startOauth()}>
<i class="bi bi-box-arrow-in-right me-1"></i>${active ? 'Sign in again' : (pending ? 'Finish sign-in' : `Sign in with ${label}`)} <i class="bi bi-box-arrow-in-right me-1"></i>
${active ? t('connectors.detail.oauth.btn_signin_again') : (pending ? t('connectors.detail.oauth.btn_finish') : t('connectors.detail.oauth.btn_signin', { provider: label }))}
</button> </button>
${this._act ? html` ${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}> <button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate <i class="bi bi-trash me-1"></i>${t('connectors.detail.oauth.deactivate')}
</button>` : nothing} </button>` : nothing}
</div>` </div>`
: html` : html`
<div class="connector-card" style="margin-top:.25rem"> <div class="connector-card" style="margin-top:.25rem">
<div class="mb-2" style="font-size:.8rem"> <div class="mb-2" style="font-size:.8rem">
<i class="bi bi-1-circle me-1"></i>A tab opened for ${label}. Approve access there. <i class="bi bi-1-circle me-1"></i>${t('connectors.detail.oauth.step1', { provider: label })}
<div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">Re-open the sign-in page</a></div> <div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">${t('connectors.detail.oauth.step1_link')}</a></div>
</div> </div>
<div class="mb-2" style="font-size:.8rem"> <div class="mb-2" style="font-size:.8rem">
<i class="bi bi-2-circle me-1"></i>Paste the code the page gave you: <i class="bi bi-2-circle me-1"></i>${t('connectors.detail.oauth.step2')}
</div> </div>
<input class="form-control font-monospace mb-2" placeholder="4/0A…" <input class="form-control font-monospace mb-2" placeholder="4/0A…"
.value=${this._oauth.code} .value=${this._oauth.code}
@@ -545,10 +552,10 @@ export class ConnectorDetailPage extends LightElement {
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy || !this._oauth.code.trim()} <button class="btn btn-sm btn-primary" ?disabled=${this._busy || !this._oauth.code.trim()}
@click=${() => this._completeOauth()}> @click=${() => this._completeOauth()}>
<i class="bi bi-check-lg me-1"></i>Complete sign-in <i class="bi bi-check-lg me-1"></i>${t('connectors.detail.oauth.btn_complete')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy} <button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
@click=${() => { this._oauth = null; }}>Cancel</button> @click=${() => { this._oauth = null; }}>${t('connectors.detail.oauth.cancel')}</button>
</div> </div>
</div>`} </div>`}
`; `;
@@ -574,20 +581,20 @@ export class ConnectorDetailPage extends LightElement {
} }
_renderVerifyBox() { _renderVerifyBox() {
const t = this._test; const result = this._test;
if (t === null) return nothing; if (result === null) return nothing;
if (t === 'running') { if (result === 'running') {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem"> return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-arrow-repeat me-1"></i>Testing credentials…</div>`; <i class="bi bi-arrow-repeat me-1"></i>${t('connectors.detail.test.running')}</div>`;
} }
if (t.skipped) { if (result.skipped) {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem"> return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-info-circle me-1"></i>${t.message || 'No verification step for this connector.'}</div>`; <i class="bi bi-info-circle me-1"></i>${result.message || t('connectors.detail.test.skipped')}</div>`;
} }
return html` return html`
<div class="alert alert-${t.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem"> <div class="alert alert-${result.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem">
<i class="bi ${t.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i> <i class="bi ${result.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>${t.ok ? 'OK' : 'Failed'}</strong> — ${t.message} <strong>${result.ok ? t('connectors.detail.test.ok_label') : t('connectors.detail.test.fail_label')}</strong> — ${result.message}
${t.details ? html` ${t.details ? html`
<pre class="mb-0 mt-1 p-2 rounded bg-dark text-light" <pre class="mb-0 mt-1 p-2 rounded bg-dark text-light"
style="font-size:.7rem;white-space:pre-wrap">${JSON.stringify(t.details, null, 2)}</pre>` : nothing} style="font-size:.7rem;white-space:pre-wrap">${JSON.stringify(t.details, null, 2)}</pre>` : nothing}
@@ -602,13 +609,11 @@ export class ConnectorDetailPage extends LightElement {
return html` return html`
<div style="margin-top:1.75rem"> <div style="margin-top:1.75rem">
<div class="um-header" style="padding:0 0 .5rem"> <div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>Who can use it</h3> <h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>${t('connectors.detail.access.title')}</h3>
</div>
<div class="text-muted mb-2" style="font-size:.78rem">
Ticking a box grants this connector's tools to that person's agent. Saving replaces the whole list.
</div> </div>
<div class="text-muted mb-2" style="font-size:.78rem">${t('connectors.detail.access.desc')}</div>
${users.length === 0 ${users.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>No users.</p></div>` ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>${t('connectors.detail.access.empty')}</p></div>`
: html` : html`
<div class="connector-card"> <div class="connector-card">
${users.map(u => html` ${users.map(u => html`
@@ -624,7 +629,7 @@ export class ConnectorDetailPage extends LightElement {
</div>`} </div>`}
<button class="btn btn-sm btn-primary mt-2" ?disabled=${this._busy || !this._access} <button class="btn btn-sm btn-primary mt-2" ?disabled=${this._busy || !this._access}
@click=${() => this._saveAccess()}> @click=${() => this._saveAccess()}>
<i class="bi bi-check-lg me-1"></i>Save access <i class="bi bi-check-lg me-1"></i>${t('connectors.detail.access.save')}
</button> </button>
</div>`; </div>`;
} }
+47 -44
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; 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. // Connectors (MCP) — blueprint §7/§14/§15.
// //
@@ -65,16 +66,21 @@ export class ConnectorsPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'connectors'; this._open = e.detail.page === 'connectors';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load(); 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(); }); 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; } get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() { async _load() {
@@ -153,11 +159,11 @@ export class ConnectorsPage extends LightElement {
async _saveProvider() { async _saveProvider() {
const f = this._pForm; const f = this._pForm;
if (!f.name.trim() || !f.client_id.trim()) { 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; return;
} }
if (f._isNew && !f.client_secret.trim()) { 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; return;
} }
this._pError = null; this._pError = null;
@@ -173,7 +179,7 @@ export class ConnectorsPage extends LightElement {
} }
async _deleteProvider(name) { 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 { try {
await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
this._providers = await jf('/api/mcp/providers'); this._providers = await jf('/api/mcp/providers');
@@ -240,17 +246,17 @@ export class ConnectorsPage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2> <h2 class="um-title"><i class="bi bi-plug me-2"></i>${t('connectors.title')}</h2>
<div class="um-header-right"> <div class="um-header-right">
${this._isAdmin ? html` ${this._isAdmin ? html`
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._openProviders()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._openProviders()}>
<i class="bi bi-key me-1"></i>Sign-in providers <i class="bi bi-key me-1"></i>${t('connectors.btn.signin_providers')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('catalog', '#catalog')}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('catalog', '#catalog')}>
<i class="bi bi-journal-text me-1"></i>Catalog <i class="bi bi-journal-text me-1"></i>${t('connectors.btn.catalog')}
</button> </button>
<button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}> <button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}>
<i class="bi bi-bag me-1"></i>Marketplace <i class="bi bi-bag me-1"></i>${t('connectors.btn.marketplace')}
</button>` : nothing} </button>` : nothing}
</div> </div>
</div> </div>
@@ -259,13 +265,13 @@ export class ConnectorsPage extends LightElement {
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing} <div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading ${loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('connectors.loading')}</div>`
: html` : html`
<div style="padding:0 1.25rem 1.5rem; overflow:auto"> <div style="padding:0 1.25rem 1.5rem; overflow:auto">
<div class="connector-filters"> <div class="connector-filters">
<div class="connector-search"> <div class="connector-search">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder="Search connectors…" <input class="form-control form-control-sm" placeholder=${t('connectors.search')}
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} /> .value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
</div> </div>
</div> </div>
@@ -283,15 +289,12 @@ export class ConnectorsPage extends LightElement {
@click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}> @click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
<div class="connector-card" style="width:100%;max-width:560px;cursor:default"> <div class="connector-card" style="width:100%;max-width:560px;cursor:default">
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>Sign-in providers</h3> <h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>${t('connectors.providers.title')}</h3>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeProviders()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeProviders()}>
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
</div> </div>
<div class="text-muted mb-3" style="font-size:.78rem"> <div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.providers.desc')}</div>
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.
</div>
${this._pError ? html` ${this._pError ? html`
<div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing} <div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing}
${this._pForm ? this._renderProviderForm() : this._renderProviderList()} ${this._pForm ? this._renderProviderForm() : this._renderProviderList()}
@@ -304,7 +307,7 @@ export class ConnectorsPage extends LightElement {
return html` return html`
${list.length === 0 ? html` ${list.length === 0 ? html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i> <div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i>
<p>No sign-in providers yet.</p></div>` : html` <p>${t('connectors.providers.empty')}</p></div>` : html`
<div class="d-flex flex-column gap-2 mb-3"> <div class="d-flex flex-column gap-2 mb-3">
${list.map(p => html` ${list.map(p => html`
<div class="d-flex align-items-center justify-content-between p-2 rounded" <div class="d-flex align-items-center justify-content-between p-2 rounded"
@@ -314,9 +317,9 @@ export class ConnectorsPage extends LightElement {
<code class="text-muted" style="font-size:.7rem">${p.name}</code></div> <code class="text-muted" style="font-size:.7rem">${p.name}</code></div>
<div class="text-muted" style="font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> <div class="text-muted" style="font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${p.has_client_secret ${p.has_client_secret
? html`<i class="bi bi-check-circle text-success"></i> secret set` ? html`<i class="bi bi-check-circle text-success"></i> ${t('connectors.providers.secret_set')}`
: html`<i class="bi bi-exclamation-triangle text-warning"></i> no secret`} : html`<i class="bi bi-exclamation-triangle text-warning"></i> ${t('connectors.providers.no_secret')}`}
· ${p.client_id || '(no client id)'} · ${p.client_id || t('connectors.providers.no_client_id')}
</div> </div>
</div> </div>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
@@ -329,10 +332,10 @@ export class ConnectorsPage extends LightElement {
</div>`} </div>`}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @click=${() => this._presetGoogle()}> <button class="btn btn-sm btn-primary" @click=${() => this._presetGoogle()}>
<i class="bi bi-google me-1"></i>Add Google <i class="bi bi-google me-1"></i>${t('connectors.providers.add_google')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = { ...this._blankProvider(), _isNew: true }; }}> <button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = { ...this._blankProvider(), _isNew: true }; }}>
<i class="bi bi-plus-lg me-1"></i>Add other <i class="bi bi-plus-lg me-1"></i>${t('connectors.providers.add_other')}
</button> </button>
</div>`; </div>`;
} }
@@ -350,24 +353,24 @@ export class ConnectorsPage extends LightElement {
${opts.help ? html`<div class="form-text" style="font-size:.7rem">${opts.help}</div>` : nothing} ${opts.help ? html`<div class="form-text" style="font-size:.7rem">${opts.help}</div>` : nothing}
</div>`; </div>`;
return html` return html`
${field('name', 'Provider id', { req: true, mono: true, ph: 'google', ${field('name', t('connectors.providers.field.name'), { req: true, mono: true, ph: 'google',
help: 'The slug a connector references (must match the manifest\'s auth.provider).' })} help: t('connectors.providers.field.name_help') })}
${field('display_name', 'Display name', { ph: 'Google' })} ${field('display_name', t('connectors.providers.field.display'), { ph: 'Google' })}
${field('client_id', 'Client id', { req: true, mono: true })} ${field('client_id', t('connectors.providers.field.client_id'), { req: true, mono: true })}
${field('client_secret', 'Client secret', { secret: true, mono: true, ${field('client_secret', t('connectors.providers.field.client_secret'), { secret: true, mono: true,
help: f._isNew ? 'Required.' : 'Leave blank to keep the stored secret.' })} help: f._isNew ? t('connectors.providers.field.secret_help_new') : t('connectors.providers.field.secret_help_edit') })}
${field('auth_url', 'Authorization URL', { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })} ${field('auth_url', t('connectors.providers.field.auth_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('token_url', t('connectors.providers.field.token_url'), { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
${field('redirect_uri', 'Redirect URI', { mono: true, ${field('redirect_uri', t('connectors.providers.field.redirect'), { mono: true,
help: 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.' })} help: t('connectors.providers.field.redirect_help') })}
${field('extra_params', 'Extra params (JSON)', { mono: true, ph: '{"access_type":"offline","prompt":"consent"}', ${field('extra_params', t('connectors.providers.field.extra'), { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
help: 'Merged into the consent URL. Google needs these two to return a refresh token.' })} help: t('connectors.providers.field.extra_help') })}
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-sm btn-primary" @click=${() => this._saveProvider()}> <button class="btn btn-sm btn-primary" @click=${() => this._saveProvider()}>
<i class="bi bi-check-lg me-1"></i>Save <i class="bi bi-check-lg me-1"></i>${t('connectors.providers.save')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = null; this._pError = null; }}> <button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = null; this._pError = null; }}>
Cancel ${t('connectors.providers.cancel')}
</button> </button>
</div>`; </div>`;
} }
@@ -375,14 +378,14 @@ export class ConnectorsPage extends LightElement {
_renderEmpty() { _renderEmpty() {
if (this._q.trim()) { if (this._q.trim()) {
return html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i> return html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
<p>No connector matches “${this._q}”.</p></div>`; <p>${t('connectors.empty.match', { query: this._q })}</p></div>`;
} }
return html` return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i> <div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i>
<p>${this._isAdmin ? 'No connectors installed yet.' : 'Nothing available to you yet.'}</p> <p>${this._isAdmin ? t('connectors.empty.installed') : t('connectors.empty.available')}</p>
${this._isAdmin ${this._isAdmin
? html`<p style="font-size:.8rem;opacity:.7">Install one from the Marketplace to get started.</p>` ? html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.install_hint')}</p>`
: html`<p style="font-size:.8rem;opacity:.7">Ask an admin to make one available.</p>`} : html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.ask_admin')}</p>`}
</div>`; </div>`;
} }
@@ -407,7 +410,7 @@ export class ConnectorsPage extends LightElement {
<div class="connector-card-sub">${r.name}</div> <div class="connector-card-sub">${r.name}</div>
</div> </div>
<span class=${`connector-chip${STATUS_LABEL[status].tone ? ` connector-chip--${STATUS_LABEL[status].tone}` : ''}`}> <span class=${`connector-chip${STATUS_LABEL[status].tone ? ` connector-chip--${STATUS_LABEL[status].tone}` : ''}`}>
${STATUS_LABEL[status].text} ${statusText(status)}
</span> </span>
</div> </div>
@@ -415,11 +418,11 @@ export class ConnectorsPage extends LightElement {
<div class="connector-chips"> <div class="connector-chips">
<span class="connector-chip connector-chip--scope"> <span class="connector-chip connector-chip--scope">
<i class="bi ${isGlobal ? 'bi-globe' : 'bi-person'}"></i>${isGlobal ? 'global' : 'per-user'} <i class="bi ${isGlobal ? 'bi-globe' : 'bi-person'}"></i>${isGlobal ? t('connectors.chip.global') : t('connectors.chip.per_user')}
</span> </span>
${isScript ? html` ${isScript ? html`
<span class="connector-chip connector-chip--script"> <span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>local script <i class="bi bi-file-earmark-code"></i>${t('connectors.chip.local_script')}
</span>` : nothing} </span>` : nothing}
${r.auth_kind && r.auth_kind !== 'none' ? html` ${r.auth_kind && r.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${r.auth_kind}</span>` : nothing} <span class="connector-chip"><i class="bi bi-key"></i>${r.auth_kind}</span>` : nothing}
+37 -36
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderMarkdown } from '../lib/base.js'; import { renderMarkdown } from '../lib/base.js';
import { openFile } from '../lib/open-file.js'; import { openFile } from '../lib/open-file.js';
import { t } from '../lib/i18n.js';
// ── Utilities ──────────────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────────────
@@ -33,7 +34,7 @@ function renderPath(seg, path) {
if (!path || seg !== path) return html`<code>${seg}</code>`; if (!path || seg !== path) return html`<code>${seg}</code>`;
const open = (e) => { e.stopPropagation(); openFile(seg); }; const open = (e) => { e.stopPropagation(); openFile(seg); };
return html`<span class="copilot-tool-path" role="button" tabindex="0" return html`<span class="copilot-tool-path" role="button" tabindex="0"
title="Open in viewer" title=${t('copilot.open_in_viewer')}
@click=${open} @click=${open}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }} @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }}
>${seg}</span>`; >${seg}</span>`;
@@ -90,7 +91,7 @@ export function renderDiff(oldText, newText) {
result.push(html`<span class="diff-unchanged">${eqBuf.join('\n')}\n</span>`); result.push(html`<span class="diff-unchanged">${eqBuf.join('\n')}\n</span>`);
} else { } else {
result.push(html`<span class="diff-unchanged">${eqBuf.slice(0, 3).join('\n')}\n</span>`); result.push(html`<span class="diff-unchanged">${eqBuf.slice(0, 3).join('\n')}\n</span>`);
result.push(html`<span class="diff-ellipsis">${eqBuf.length - 6} unchanged lines</span>`); result.push(html`<span class="diff-ellipsis">${t('copilot.unchanged_lines', { n: eqBuf.length - 6 })}</span>`);
result.push(html`<span class="diff-unchanged">\n${eqBuf.slice(-3).join('\n')}\n</span>`); result.push(html`<span class="diff-unchanged">\n${eqBuf.slice(-3).join('\n')}\n</span>`);
} }
eqBuf = []; eqBuf = [];
@@ -118,15 +119,15 @@ export function renderPendingWrite(host, msg) {
<div class="copilot-approval-header"> <div class="copilot-approval-header">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
<span class="copilot-approval-path copilot-tool-path" role="button" tabindex="0" <span class="copilot-approval-path copilot-tool-path" role="button" tabindex="0"
title="Open in viewer" title=${t('copilot.open_in_viewer')}
@click=${() => openFile(msg.path)} @click=${() => openFile(msg.path)}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }} @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }}
>${msg.path}</span> >${msg.path}</span>
${msg.status === 'pending' ${msg.status === 'pending'
? html`<span class="badge bg-warning text-dark ms-auto">Pending approval</span>` ? html`<span class="badge bg-warning text-dark ms-auto">${t('approval.pending')}</span>`
: msg.status === 'approved' : msg.status === 'approved'
? html`<span class="badge bg-success ms-auto">Approved</span>` ? html`<span class="badge bg-success ms-auto">${t('approval.approved')}</span>`
: html`<span class="badge bg-danger ms-auto">Rejected</span>`} : html`<span class="badge bg-danger ms-auto">${t('approval.rejected')}</span>`}
</div> </div>
<pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre> <pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre>
@@ -137,33 +138,33 @@ export function renderPendingWrite(host, msg) {
<textarea <textarea
class="form-control form-control-sm copilot-reject-note" class="form-control form-control-sm copilot-reject-note"
rows="2" rows="2"
placeholder="Optional: explain why you rejected this (sent to the LLM)" placeholder=${t('approval.reject_hint')}
.value=${host._rejectNote} .value=${host._rejectNote}
@input=${(e) => { host._rejectNote = e.target.value; }} @input=${(e) => { host._rejectNote = e.target.value; }}
></textarea> ></textarea>
<div class="copilot-approval-btns"> <div class="copilot-approval-btns">
<button class="btn btn-sm btn-danger" @click=${() => host._confirmReject(msg)}> <button class="btn btn-sm btn-danger" @click=${() => host._confirmReject(msg)}>
<i class="bi bi-x-circle me-1"></i>Confirm reject <i class="bi bi-x-circle me-1"></i>${t('approval.confirm_reject')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}> <button class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}>
Cancel ${t('copilot.cancel')}
</button> </button>
</div> </div>
` : html` ` : html`
<div class="copilot-approval-btns"> <div class="copilot-approval-btns">
<button class="btn btn-sm btn-success" @click=${() => host._approve(msg)}> <button class="btn btn-sm btn-success" @click=${() => host._approve(msg)}>
<i class="bi bi-check-circle me-1"></i>Approve <i class="bi bi-check-circle me-1"></i>${t('approval.approve')}
</button> </button>
<button class="btn btn-sm btn-outline-danger" @click=${() => host._startReject(msg)}> <button class="btn btn-sm btn-outline-danger" @click=${() => host._startReject(msg)}>
<i class="bi bi-x-circle me-1"></i>Reject <i class="bi bi-x-circle me-1"></i>${t('approval.reject')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes" <button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_15')}
@click=${() => host._approveWriteBypass(msg, 900)}> @click=${() => host._approveWriteBypass(msg, 900)}>
<i class="bi bi-clock me-1"></i>15 min <i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session" <button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_all')}
@click=${() => host._approveWriteBypass(msg, 0)}> @click=${() => host._approveWriteBypass(msg, 0)}>
<i class="bi bi-arrow-repeat me-1"></i>Session <i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
</button> </button>
</div> </div>
`} `}
@@ -183,13 +184,13 @@ export function renderTool(host, msg) {
msg.status === 'running' msg.status === 'running'
? html`<span class="spinner-border spinner-border-sm" role="status"></span>` ? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
: isPending : isPending
? html`<span class="spinner-border spinner-border-sm text-warning" role="status" title="Awaiting approval"></span>` ? html`<span class="spinner-border spinner-border-sm text-warning" role="status" title=${t('copilot.status_awaiting')}></span>`
: msg.status === 'done' : msg.status === 'done'
? html`<i class="bi bi-check-circle-fill text-success"></i>` ? html`<i class="bi bi-check-circle-fill text-success"></i>`
: msg.status === 'cancelled' : msg.status === 'cancelled'
? html`<i class="bi bi-slash-circle-fill text-secondary" title="Cancelled by user"></i>` ? html`<i class="bi bi-slash-circle-fill text-secondary" title=${t('copilot.status_cancelled')}></i>`
: msg.status === 'rejected' : msg.status === 'rejected'
? html`<i class="bi bi-shield-fill-x text-warning" title="Denied by policy"></i>` ? html`<i class="bi bi-shield-fill-x text-warning" title=${t('copilot.status_denied')}></i>`
: html`<i class="bi bi-x-circle-fill text-danger"></i>`; : html`<i class="bi bi-x-circle-fill text-danger"></i>`;
return html` return html`
@@ -197,7 +198,7 @@ export function renderTool(host, msg) {
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}> <button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
<span class="copilot-tool-status">${statusIcon}</span> <span class="copilot-tool-status">${statusIcon}</span>
<span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span> <span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span>
${isPending ? html`<span class="badge bg-warning text-dark ms-2">Pending approval</span>` : nothing} ${isPending ? html`<span class="badge bg-warning text-dark ms-2">${t('approval.pending')}</span>` : nothing}
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i> <i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>
</button> </button>
${isOpen ? html` ${isOpen ? html`
@@ -226,7 +227,7 @@ export function renderTool(host, msg) {
<textarea <textarea
class="form-control form-control-sm copilot-reject-note" class="form-control form-control-sm copilot-reject-note"
rows="2" rows="2"
placeholder="Type your answer…" placeholder=${t('copilot.clarification_ph')}
.value=${host._clarificationAnswer} .value=${host._clarificationAnswer}
@input=${(e) => { host._clarificationAnswer = e.target.value; }} @input=${(e) => { host._clarificationAnswer = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host._answerQuestion(msg); } }} @keydown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host._answerQuestion(msg); } }}
@@ -234,7 +235,7 @@ export function renderTool(host, msg) {
<button class="btn btn-sm btn-primary ms-2" <button class="btn btn-sm btn-primary ms-2"
@click=${() => host._answerQuestion(msg)} @click=${() => host._answerQuestion(msg)}
?disabled=${!host._clarificationAnswer.trim()}> ?disabled=${!host._clarificationAnswer.trim()}>
<i class="bi bi-send me-1"></i>Send <i class="bi bi-send me-1"></i>${t('copilot.send')}
</button> </button>
</div> </div>
</div> </div>
@@ -244,38 +245,38 @@ export function renderTool(host, msg) {
<textarea <textarea
class="form-control form-control-sm copilot-reject-note" class="form-control form-control-sm copilot-reject-note"
rows="2" rows="2"
placeholder="Reason for rejection (optional, sent to the LLM)" placeholder=${t('approval.reject_hint')}
.value=${host._rejectNote} .value=${host._rejectNote}
@input=${(e) => { host._rejectNote = e.target.value; }} @input=${(e) => { host._rejectNote = e.target.value; }}
></textarea> ></textarea>
<div class="copilot-approval-btns"> <div class="copilot-approval-btns">
<button class="btn btn-sm btn-danger" <button class="btn btn-sm btn-danger"
@click=${() => msg.request_id != null ? host._rejectWsTool(msg) : host._rejectTool(msg)}> @click=${() => msg.request_id != null ? host._rejectWsTool(msg) : host._rejectTool(msg)}>
<i class="bi bi-x-circle me-1"></i>Confirm reject <i class="bi bi-x-circle me-1"></i>${t('approval.confirm_reject')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" <button class="btn btn-sm btn-outline-secondary"
@click=${() => { host._rejectingId = null; }}> @click=${() => { host._rejectingId = null; }}>
Cancel ${t('copilot.cancel')}
</button> </button>
</div> </div>
` : html` ` : html`
<div class="copilot-approval-btns"> <div class="copilot-approval-btns">
<button class="btn btn-sm btn-success" <button class="btn btn-sm btn-success"
@click=${(e) => { e.stopPropagation(); msg.request_id != null ? host._approveWsTool(msg) : host._approveTool(msg); }}> @click=${(e) => { e.stopPropagation(); msg.request_id != null ? host._approveWsTool(msg) : host._approveTool(msg); }}>
<i class="bi bi-check-circle me-1"></i>Approve <i class="bi bi-check-circle me-1"></i>${t('approval.approve')}
</button> </button>
<button class="btn btn-sm btn-outline-danger" <button class="btn btn-sm btn-outline-danger"
@click=${(e) => { e.stopPropagation(); host._rejectingId = msg.tool_call_id; host._rejectNote = ''; }}> @click=${(e) => { e.stopPropagation(); host._rejectingId = msg.tool_call_id; host._rejectNote = ''; }}>
<i class="bi bi-x-circle me-1"></i>Reject <i class="bi bi-x-circle me-1"></i>${t('approval.reject')}
</button> </button>
${msg.request_id != null ? html` ${msg.request_id != null ? html`
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes" <button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_15')}
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 900); }}> @click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 900); }}>
<i class="bi bi-clock me-1"></i>15 min <i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session" <button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_all')}
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 0); }}> @click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 0); }}>
<i class="bi bi-arrow-repeat me-1"></i>Session <i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
</button> </button>
` : nothing} ` : nothing}
</div> </div>
@@ -284,7 +285,7 @@ export function renderTool(host, msg) {
`) : msg.status !== 'running' ? ( `) : msg.status !== 'running' ? (
msg.status === 'done' && msg.result_type === 'json' ? html` msg.status === 'done' && msg.result_type === 'json' ? html`
<div class="copilot-tool-section"> <div class="copilot-tool-section">
<span class="copilot-tool-label copilot-tool-label--done">result · json</span> <span class="copilot-tool-label copilot-tool-label--done">${t('copilot.result_json')}</span>
<pre class="copilot-tool-pre copilot-tool-pre--done copilot-tool-pre--json">${ <pre class="copilot-tool-pre copilot-tool-pre--done copilot-tool-pre--json">${
truncate(prettyJson(msg.result)) truncate(prettyJson(msg.result))
}</pre> }</pre>
@@ -292,7 +293,7 @@ export function renderTool(host, msg) {
` : html` ` : html`
<div class="copilot-tool-section"> <div class="copilot-tool-section">
<span class="copilot-tool-label copilot-tool-label--${msg.status}"> <span class="copilot-tool-label copilot-tool-label--${msg.status}">
${msg.status === 'done' ? 'result' : 'error'} ${msg.status === 'done' ? t('copilot.result') : t('copilot.error_label')}
</span> </span>
<pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${ <pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${
truncate(msg.status === 'done' ? msg.result : msg.error) truncate(msg.status === 'done' ? msg.result : msg.error)
@@ -316,7 +317,7 @@ export function renderAgent(msg) {
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i> <i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.agent_id}</strong> <strong>${msg.agent_id}</strong>
</span> </span>
${msg.done ? html`<span class="copilot-agent-badge done">done</span>` : html`<span class="copilot-agent-badge running">running…</span>`} ${msg.done ? html`<span class="copilot-agent-badge done">${t('copilot.agent_done')}</span>` : html`<span class="copilot-agent-badge running">${t('copilot.agent_running')}</span>`}
</div> </div>
${msg.prompt_preview ? html` ${msg.prompt_preview ? html`
<pre class="copilot-agent-preview">${msg.prompt_preview}</pre> <pre class="copilot-agent-preview">${msg.prompt_preview}</pre>
@@ -335,7 +336,7 @@ export function renderAgentEnd(msg) {
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i> <i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.parent_agent_id ?? 'main'}</strong> <strong>${msg.parent_agent_id ?? 'main'}</strong>
</span> </span>
<span class="copilot-agent-badge done">finished</span> <span class="copilot-agent-badge done">${t('copilot.agent_finished')}</span>
</div> </div>
${msg.result_preview ? html` ${msg.result_preview ? html`
<pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre> <pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre>
@@ -345,7 +346,7 @@ export function renderAgentEnd(msg) {
} }
function failedBadge() { function failedBadge() {
return html`<span class="copilot-failed-badge" title="This message is not sent to the LLM"> return html`<span class="copilot-failed-badge" title=${t('copilot.not_sent_to_llm')}>
<i class="bi bi-exclamation-triangle-fill"></i> <i class="bi bi-exclamation-triangle-fill"></i>
</span>`; </span>`;
} }
@@ -393,7 +394,7 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
<span class="attach-chip-name">${att.name}</span> <span class="attach-chip-name">${att.name}</span>
${att.filesize != null ? html`<span class="attach-chip-size">${fmtSize(att.filesize)}</span>` : nothing} ${att.filesize != null ? html`<span class="attach-chip-size">${fmtSize(att.filesize)}</span>` : nothing}
${removable ? html` ${removable ? html`
<button class="attach-chip-remove" title="Remove" <button class="attach-chip-remove" title=${t('copilot.remove')}
@click=${(e) => { e.stopPropagation(); host._removeAttachment(i); }}> @click=${(e) => { e.stopPropagation(); host._removeAttachment(i); }}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button>` : nothing} </button>` : nothing}
+117 -36
View File
@@ -1,26 +1,29 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js'; import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js'; import { renderMsg, renderAttachmentChips } from './copilot-render.js';
// Built-in (server-handled) slash commands shown at the top of the composer // Built-in (server-handled) slash commands shown at the top of the composer
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from // autocomplete. Custom commands (from `commands/<name>/`) are fetched from
// `/api/commands` and appended below. // `/api/commands` and appended below.
const SYSTEM_COMMAND_ITEMS = [ const SYSTEM_COMMAND_ITEMS = [
{ name: 'help', description: 'Show available commands' }, { name: 'help', description: () => t('copilot.cmd.help') },
{ name: 'clear', description: 'Start a new conversation' }, { name: 'clear', description: () => t('copilot.cmd.clear') },
{ name: 'new', description: 'Alias for /clear' }, { name: 'new', description: () => t('copilot.cmd.new') },
{ name: 'models', description: 'List available LLM models' }, { name: 'models', description: () => t('copilot.cmd.models') },
{ name: 'model', description: 'Select the model for this chat' }, { name: 'model', description: () => t('copilot.cmd.model') },
{ name: 'context', description: "Last turn's token usage" }, { name: 'context', description: () => t('copilot.cmd.context') },
{ name: 'cost', description: 'Session spend (USD)' }, { name: 'cost', description: () => t('copilot.cmd.cost') },
{ name: 'compact', description: 'Force context compaction' }, { name: 'compact', description: () => t('copilot.cmd.compact') },
{ name: 'resettools', description: 'Remove activated tool groups' }, { name: 'resettools', description: () => t('copilot.cmd.resettools') },
{ name: 'sethome', description: 'Set web as notification home' }, { name: 'sethome', description: () => t('copilot.cmd.sethome') },
]; ];
export class AppCopilot extends ChatSession { export class AppCopilot extends I18nMixin(ChatSession) {
static properties = { static properties = {
_collapsed: { state: true }, _collapsed: { state: true },
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true }, _modelOpen: { state: true },
_tabs: { state: true }, _tabs: { state: true },
_activeSource: { state: true }, _activeSource: { state: true },
@@ -31,6 +34,9 @@ export class AppCopilot extends ChatSession {
constructor() { constructor() {
super(); super();
this._collapsed = false; this._collapsed = false;
// 'full' fills the workspace (home route), 'dock' is the side panel.
this._mode = 'dock';
this._me = null;
this._modelOpen = false; this._modelOpen = false;
this._resizing = false; this._resizing = false;
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown // Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
@@ -41,23 +47,53 @@ export class AppCopilot extends ChatSession {
this._allCommands = null; this._allCommands = null;
// Browser-style tabs: 'General' (the default 'web' source) is always present and // 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. // 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._onResizeMove = this._onResizeMove.bind(this);
this._onResizeUp = this._onResizeUp.bind(this); this._onResizeUp = this._onResizeUp.bind(this);
this._onKeydown = this._onKeydown.bind(this); this._onKeydown = this._onKeydown.bind(this);
this._onKeyup = this._onKeyup.bind(this); this._onKeyup = this._onKeyup.bind(this);
this._onProjectChatOpen = this._onProjectChatOpen.bind(this); this._onProjectChatOpen = this._onProjectChatOpen.bind(this);
this._onCopilotOpen = this._onCopilotOpen.bind(this); this._onCopilotOpen = this._onCopilotOpen.bind(this);
this._onPageChange = this._onPageChange.bind(this);
} }
connectedCallback() { connectedCallback() {
super.connectedCallback?.(); super.connectedCallback?.();
this._restoreState(); this._restoreState();
this._loadCommands(); 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('keydown', this._onKeydown);
window.addEventListener('keyup', this._onKeyup); window.addEventListener('keyup', this._onKeyup);
window.addEventListener('project-chat-open', this._onProjectChatOpen); window.addEventListener('project-chat-open', this._onProjectChatOpen);
window.addEventListener('copilot-open', this._onCopilotOpen); 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() { _restoreState() {
@@ -74,6 +110,7 @@ export class AppCopilot extends ChatSession {
window.removeEventListener('keyup', this._onKeyup); window.removeEventListener('keyup', this._onKeyup);
window.removeEventListener('project-chat-open', this._onProjectChatOpen); window.removeEventListener('project-chat-open', this._onProjectChatOpen);
window.removeEventListener('copilot-open', this._onCopilotOpen); window.removeEventListener('copilot-open', this._onCopilotOpen);
window.removeEventListener('llm-page-change', this._onPageChange);
} }
_onCopilotOpen() { _onCopilotOpen() {
@@ -254,36 +291,82 @@ export class AppCopilot extends ChatSession {
// ── Render ──────────────────────────────────────────────────────────────────── // ── 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`<div class="copilot-msg assistant">${t('chat.hello')}</div>`;
}
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`
<div class="chat-hero">
<img class="chat-hero-logo" src="/assets/icons/icon-192.png" alt="" />
<h1 class="chat-hero-title">${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}</h1>
<p class="chat-hero-sub">${t('chat.greeting.sub')}</p>
<div class="chat-suggestions">
${suggestions.map(s => html`
<button class="chat-suggestion" @click=${() => this._sendSuggestion(s.text)}>
<i class="bi ${s.icon}"></i>
<span>${s.text}</span>
</button>
`)}
</div>
</div>
`;
}
render() { 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` return html`
${!full ? html`
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div> <div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
` : nothing}
<div class="copilot-header"> <div class="copilot-header">
<i class="bi bi-stars"></i> <i class="bi bi-stars"></i>
<span>Copilot</span> <span>${t('chat.title')}</span>
<span class="chat-privacy" title=${t('chat.privacy.hint')}>
<i class="bi bi-lock-fill"></i>${t('chat.privacy')}
</span>
${!full ? html`
<button <button
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn" class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
title="Collapse copilot" title=${t('chat.collapse')}
@click=${() => { this._setCollapsed(true); }} @click=${() => { this._setCollapsed(true); }}
> >
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
</button> </button>
` : nothing}
</div> </div>
${this._tabs.length > 1 ? html` ${this._tabs.length > 1 ? html`
<div class="copilot-tabs"> <div class="copilot-tabs">
${this._tabs.map(t => html` ${this._tabs.map(tab => html`
<div <div
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}" class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
@click=${() => this._selectTab(t.source)} @click=${() => this._selectTab(tab.source)}
title=${t.label} title=${tab.label}
> >
<span class="copilot-tab-label">${t.label}</span> <span class="copilot-tab-label">${tab.label}</span>
${t.source !== 'web' ? html` ${tab.source !== 'web' ? html`
<button class="copilot-tab-close" title="Close tab" <button class="copilot-tab-close" title=${t('chat.close_tab')}
@click=${e => this._closeTab(t.source, e)}> @click=${e => this._closeTab(tab.source, e)}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
` : nothing} ` : nothing}
@@ -293,16 +376,14 @@ export class AppCopilot extends ChatSession {
` : nothing} ` : nothing}
<div class="copilot-messages"> <div class="copilot-messages">
${this._messages.length === 0 ? html` ${this._messages.length === 0
<div class="copilot-msg assistant"> ? this._renderEmptyState()
Hello! How can I help you today? : this._messages.map(m => renderMsg(this, m))}
</div>
` : this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html` ${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking"> <div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span> <span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking ${t('chat.thinking')}
</div> </div>
` : nothing} ` : nothing}
</div> </div>
@@ -319,7 +400,7 @@ export class AppCopilot extends ChatSession {
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }} @mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
> >
<span class="copilot-cmd-name">/${c.name}</span> <span class="copilot-cmd-name">/${c.name}</span>
<span class="copilot-cmd-desc">${c.description}</span> <span class="copilot-cmd-desc">${typeof c.description === 'function' ? c.description() : c.description}</span>
</button> </button>
`)} `)}
</div> </div>
@@ -335,7 +416,7 @@ export class AppCopilot extends ChatSession {
<textarea <textarea
class="copilot-textarea" class="copilot-textarea"
rows="1" rows="1"
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)" placeholder=${t('chat.placeholder')}
@keydown=${this._composerKeydown} @keydown=${this._composerKeydown}
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }} @input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
@paste=${(e) => this._onPaste(e)} @paste=${(e) => this._onPaste(e)}
@@ -344,7 +425,7 @@ export class AppCopilot extends ChatSession {
<div class="copilot-toolbar-left"> <div class="copilot-toolbar-left">
<button <button
class="copilot-toolbar-btn" class="copilot-toolbar-btn"
title="Attach files" title=${t('chat.attach')}
@click=${() => this.querySelector('.copilot-file-input')?.click()} @click=${() => this.querySelector('.copilot-file-input')?.click()}
><i class="bi bi-paperclip"></i></button> ><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html` ${this._providers.length > 1 ? html`
@@ -369,7 +450,7 @@ export class AppCopilot extends ChatSession {
` : nothing} ` : nothing}
<button <button
class="copilot-toolbar-btn" class="copilot-toolbar-btn"
title="New session" title=${t('chat.new_session')}
@click=${() => this._startNewSession()} @click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button> ><i class="bi bi-trash"></i></button>
</div> </div>
@@ -377,18 +458,18 @@ export class AppCopilot extends ChatSession {
${this._hasTranscribe ? html` ${this._hasTranscribe ? html`
<button <button
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}" class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice (Ctrl+Space)'}" title="${this._recording ? t('chat.stop') : t('chat.attach')}"
@click=${() => this._toggleRecording()} @click=${() => this._toggleRecording()}
> >
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i> <i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
</button> </button>
` : nothing} ` : nothing}
${this._waiting ${this._waiting
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title="Stop"> ? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title=${t('chat.stop')}>
<i class="bi bi-stop-fill"></i> <i class="bi bi-stop-fill"></i>
</button>` </button>`
: nothing} : nothing}
<button class="copilot-send-btn" @click=${() => this._send()} title="Send"> <button class="copilot-send-btn" @click=${() => this._send()} title=${t('chat.send')}>
<i class="bi bi-send-fill"></i> <i class="bi bi-send-fill"></i>
</button> </button>
</div> </div>
@@ -1,53 +1,9 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { InboxMixin } from '../lib/inbox-mixin.js'; import { InboxMixin } from '../lib/inbox-mixin.js';
const GUIDE = [ export class DashboardPage extends InboxMixin(LightElement) {
{
icon: 'bi-chat-dots-fill',
title: 'Copilot',
desc: 'The chat panel on the right knows everything — ask it to run agents, enable plugins, write code, or search the web.',
color: '#0d6efd',
},
{
icon: 'bi-inbox',
title: 'Inbox',
desc: 'Pending approvals and agent questions that need your input before background tasks can continue.',
color: '#f59e0b',
},
{
icon: 'bi-people',
title: 'Agents',
desc: 'Specialized sub-agents (engineer, architect, QA…). Each has a focused system prompt, tool set, and model selection.',
color: '#8b5cf6',
},
{
icon: 'bi-clock',
title: 'Cron',
desc: 'Scheduled tasks that run automatically at set intervals, even when the Copilot is idle.',
color: '#f97316',
},
{
icon: 'bi-cpu',
title: 'Models',
desc: 'Manage LLM, transcription, and image generation models. Drag to reorder priority.',
color: '#10b981',
},
{
icon: 'bi-plug',
title: 'Providers',
desc: 'Add API keys for LLM providers (Anthropic, OpenAI, OpenRouter, Ollama…).',
color: '#06b6d4',
},
{
icon: 'bi-shield-check',
title: 'Security',
desc: 'Define rules to auto-approve or auto-reject tool calls — skip repetitive confirmation prompts.',
color: '#ef4444',
},
];
export class HomePage extends InboxMixin(LightElement) {
static get properties() { static get properties() {
return { return {
@@ -55,8 +11,6 @@ export class HomePage extends InboxMixin(LightElement) {
_open: { state: true }, _open: { state: true },
_models: { state: true }, _models: { state: true },
_plugins: { state: true }, _plugins: { state: true },
_debugMode: { state: true },
_debugLoading: { state: true },
_stats: { state: true }, _stats: { state: true },
_statsRange: { state: true }, _statsRange: { state: true },
}; };
@@ -68,8 +22,6 @@ export class HomePage extends InboxMixin(LightElement) {
this._models = null; // null = loading, [] = no models configured this._models = null; // null = loading, [] = no models configured
this._plugins = null; this._plugins = null;
this._pollTimer = null; this._pollTimer = null;
this._debugMode = false;
this._debugLoading = true;
this._stats = null; // null = loading this._stats = null; // null = loading
this._statsRange = 'week'; this._statsRange = 'week';
this._chartInstances = {}; this._chartInstances = {};
@@ -78,8 +30,10 @@ export class HomePage extends InboxMixin(LightElement) {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'home'; this._open = e.detail.page === 'dashboard';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) { if (this._open) {
this._loadAll(); this._loadAll();
@@ -92,6 +46,7 @@ export class HomePage extends InboxMixin(LightElement) {
} }
disconnectedCallback() { disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback(); super.disconnectedCallback();
this._stopPolling(); this._stopPolling();
this._destroyCharts(); this._destroyCharts();
@@ -120,39 +75,9 @@ export class HomePage extends InboxMixin(LightElement) {
this._loadModels(), this._loadModels(),
this._loadPlugins(), this._loadPlugins(),
this._loadInbox(), this._loadInbox(),
this._loadDebugMode(),
]); ]);
} }
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 _loadModels() { async _loadModels() {
try { try {
const res = await fetch('/api/llm/models'); const res = await fetch('/api/llm/models');
@@ -195,15 +120,27 @@ export class HomePage extends InboxMixin(LightElement) {
} }
get _statusInfo() { get _statusInfo() {
if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: 'Loading' }; if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: t('dashboard.status.loading') };
if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'No LLM models' }; if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.no_models') };
if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: 'Online & ready' }; if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: t('dashboard.status.online') };
if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: 'Degraded' }; if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: t('dashboard.status.degraded') };
return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'All models offline' }; return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.offline') };
}
get _guide() {
return [
{ icon: 'bi-chat-dots-fill', title: t('dashboard.guide.chat.title'), desc: t('dashboard.guide.chat.desc'), color: '#d95d4e' },
{ icon: 'bi-inbox', title: t('dashboard.guide.inbox.title'), desc: t('dashboard.guide.inbox.desc'), color: '#f59e0b' },
{ icon: 'bi-people', title: t('dashboard.guide.agents.title'), desc: t('dashboard.guide.agents.desc'), color: '#8b5cf6' },
{ icon: 'bi-clock', title: t('dashboard.guide.cron.title'), desc: t('dashboard.guide.cron.desc'), color: '#f97316' },
{ icon: 'bi-cpu', title: t('dashboard.guide.models.title'), desc: t('dashboard.guide.models.desc'), color: '#10b981' },
{ icon: 'bi-plug', title: t('dashboard.guide.providers.title'), desc: t('dashboard.guide.providers.desc'), color: '#06b6d4' },
{ icon: 'bi-shield-check', title: t('dashboard.guide.security.title'), desc: t('dashboard.guide.security.desc'), color: '#ef4444' },
];
} }
_nav(page) { _nav(page) {
const url = page === 'home' ? location.pathname : '#' + page; const url = '#' + page;
history.pushState({ page }, '', url); history.pushState({ page }, '', url);
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } })); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
} }
@@ -225,7 +162,7 @@ export class HomePage extends InboxMixin(LightElement) {
} }
get _periodLabel() { get _periodLabel() {
return { hour: '/ min', day: '/ hour', week: '/ day', month: '/ day' }[this._statsRange] ?? '/ day'; return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day');
} }
// Generates the full sequence of expected slots for the current range and // Generates the full sequence of expected slots for the current range and
@@ -341,15 +278,15 @@ export class HomePage extends InboxMixin(LightElement) {
data: { data: {
labels: days, labels: days,
datasets: isHour ? [ datasets: isHour ? [
lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: 'Input' }), lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: t('dashboard.stats.chart.input') }),
lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: 'Output' }), lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: t('dashboard.stats.chart.output') }),
lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: 'Cached' }), lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: t('dashboard.stats.chart.cached') }),
] : (() => { ] : (() => {
const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0))); const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0)));
return [ return [
{ label: 'Cached', data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false }, { label: t('dashboard.stats.chart.cached'), data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
{ label: 'Non-cached', data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false }, { label: t('dashboard.stats.chart.non_cached'), data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false },
{ label: 'Output', data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false }, { label: t('dashboard.stats.chart.output'), data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false },
]; ];
})(), })(),
}, },
@@ -365,7 +302,7 @@ export class HomePage extends InboxMixin(LightElement) {
const total = inp[idx] ?? 0; const total = inp[idx] ?? 0;
if (!total) return ''; if (!total) return '';
const pct = Math.round((cache[idx] ?? 0) / total * 100); const pct = Math.round((cache[idx] ?? 0) / total * 100);
return `Cache hit: ${pct}%`; return t('dashboard.stats.chart.cache_hit', { pct });
}, },
}, },
}, },
@@ -413,7 +350,7 @@ export class HomePage extends InboxMixin(LightElement) {
_renderStats() { _renderStats() {
if (this._stats === null) { if (this._stats === null) {
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> Loading stats…</div>`; return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
} }
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0; const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
@@ -421,7 +358,7 @@ export class HomePage extends InboxMixin(LightElement) {
return html` return html`
<div class="home-stats-empty"> <div class="home-stats-empty">
<i class="bi bi-bar-chart"></i> <i class="bi bi-bar-chart"></i>
<span>No LLM requests in the selected range.</span> <span>${t('dashboard.stats.empty')}</span>
</div> </div>
`; `;
} }
@@ -429,19 +366,19 @@ export class HomePage extends InboxMixin(LightElement) {
return html` return html`
<div class="home-stats-grid"> <div class="home-stats-grid">
<div class="home-stat-card"> <div class="home-stat-card">
<div class="home-stat-card-title">Requests ${this._periodLabel}</div> <div class="home-stat-card-title">${t('dashboard.stats.requests', { per: this._periodLabel })}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-requests"></canvas></div> <div class="home-stat-canvas-wrap"><canvas id="chart-requests"></canvas></div>
</div> </div>
<div class="home-stat-card"> <div class="home-stat-card">
<div class="home-stat-card-title">Tokens ${this._periodLabel}</div> <div class="home-stat-card-title">${t('dashboard.stats.tokens', { per: this._periodLabel })}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-tokens"></canvas></div> <div class="home-stat-canvas-wrap"><canvas id="chart-tokens"></canvas></div>
</div> </div>
<div class="home-stat-card"> <div class="home-stat-card">
<div class="home-stat-card-title">Avg latency (ms)</div> <div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div> <div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div> </div>
<div class="home-stat-card"> <div class="home-stat-card">
<div class="home-stat-card-title">Models</div> <div class="home-stat-card-title">${t('dashboard.stats.models')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div> <div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
</div> </div>
</div> </div>
@@ -459,28 +396,14 @@ export class HomePage extends InboxMixin(LightElement) {
return html` return html`
<div class="home-page"> <div class="home-page">
<!-- Debug toggle -->
<div class="home-debug-bar">
<label class="home-debug-toggle" title="${this._debugMode ? 'Debug mode on' : 'Debug mode off'}">
<i class="bi bi-bug-fill"></i>
<span>Debug</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox"
.checked=${this._debugMode}
@change=${this._toggleDebugMode}
?disabled=${this._debugLoading} />
</div>
</label>
</div>
<!-- Hero --> <!-- Hero -->
<div class="home-hero"> <div class="home-hero">
<div class="home-hero-image"> <div class="home-hero-image">
<img src="/assets/icons/icon-1024.png" alt="Skald" /> <img src="/assets/icons/icon-1024.png" alt=${t('chat.title')} />
</div> </div>
<div class="home-hero-text"> <div class="home-hero-text">
<h1 class="home-hero-title">Skald</h1> <h1 class="home-hero-title">${t('chat.title')}</h1>
<p class="home-hero-desc">Your AI command centre research, code, plan, and orchestrate. All in one place.</p> <p class="home-hero-desc">${t('dashboard.hero.subtitle')}</p>
<div class="home-hero-status home-hero-status--${st.cls}"> <div class="home-hero-status home-hero-status--${st.cls}">
${st.dot ? html`<span class="home-hero-dot"></span>` : nothing} ${st.dot ? html`<span class="home-hero-dot"></span>` : nothing}
${st.icon ? html`<i class="bi ${st.icon}"></i>` : nothing} ${st.icon ? html`<i class="bi ${st.icon}"></i>` : nothing}
@@ -494,11 +417,11 @@ export class HomePage extends InboxMixin(LightElement) {
<div class="home-banner home-banner--error"> <div class="home-banner home-banner--error">
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div> <div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
<div class="home-banner-body"> <div class="home-banner-body">
<strong>No LLM models configured.</strong> <strong>${t('dashboard.banner.no_models.title')}</strong>
Start by adding a provider (Anthropic, OpenAI, OpenRouter), then add at least one model in the Models section. ${t('dashboard.banner.no_models.desc')}
</div> </div>
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}> <button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
Add a provider ${t('dashboard.banner.no_models.action')}
</button> </button>
</div> </div>
` : nothing} ` : nothing}
@@ -506,9 +429,9 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- LLM Stats --> <!-- LLM Stats -->
<div class="home-section-title"> <div class="home-section-title">
<i class="bi bi-bar-chart-fill"></i> <i class="bi bi-bar-chart-fill"></i>
<span>LLM Stats</span> <span>${t('dashboard.section.stats')}</span>
<div class="home-stats-range ms-auto"> <div class="home-stats-range ms-auto">
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html` ${[['hour', t('dashboard.stats.range.hour')], ['day', t('dashboard.stats.range.day')], ['week', t('dashboard.stats.range.week')], ['month', t('dashboard.stats.range.month')]].map(([r, label]) => html`
<button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}" <button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}"
@click=${() => this._setRange(r)}>${label}</button> @click=${() => this._setRange(r)}>${label}</button>
`)} `)}
@@ -519,9 +442,9 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- Pending inbox --> <!-- Pending inbox -->
<div class="home-section-title"> <div class="home-section-title">
<i class="bi bi-inbox"></i> <i class="bi bi-inbox"></i>
<span>Pending</span> <span>${t('dashboard.section.pending')}</span>
${inboxTotal > 0 ? html`<span class="badge bg-danger">${inboxTotal}</span>` : nothing} ${inboxTotal > 0 ? html`<span class="badge bg-danger">${inboxTotal}</span>` : nothing}
<button class="inbox-refresh-btn ms-auto" title="Refresh" @click=${() => this._loadInbox()}> <button class="inbox-refresh-btn ms-auto" title=${t('dashboard.refresh')} @click=${() => this._loadInbox()}>
<i class="bi bi-arrow-clockwise"></i> <i class="bi bi-arrow-clockwise"></i>
</button> </button>
</div> </div>
@@ -532,8 +455,8 @@ export class HomePage extends InboxMixin(LightElement) {
<div class="home-tip"> <div class="home-tip">
<div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div> <div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div>
<div class="home-tip-body"> <div class="home-tip-body">
<strong>Enable Honcho</strong> <strong>${t('dashboard.tip.honcho.title')}</strong>
<span>Persistent long-term memory the agent learns your preferences over time. Ask the Copilot to enable it.</span> <span>${t('dashboard.tip.honcho.desc')}</span>
</div> </div>
</div> </div>
` : nothing} ` : nothing}
@@ -541,10 +464,10 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- Quick guide --> <!-- Quick guide -->
<div class="home-section-title"> <div class="home-section-title">
<i class="bi bi-map"></i> <i class="bi bi-map"></i>
<span>Quick guide</span> <span>${t('dashboard.section.guide')}</span>
</div> </div>
<div class="home-guide"> <div class="home-guide">
${GUIDE.map(s => html` ${this._guide.map(s => html`
<div class="home-card" style="--home-card-color: ${s.color}"> <div class="home-card" style="--home-card-color: ${s.color}">
<div class="home-card-icon"> <div class="home-card-icon">
<i class="bi ${s.icon}"></i> <i class="bi ${s.icon}"></i>
+3 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import { FileViewerBase } from './shared/file-viewer-base.js'; import { FileViewerBase } from './shared/file-viewer-base.js';
const PAGE_ID = 'file_viewer'; const PAGE_ID = 'file_viewer';
@@ -58,14 +59,14 @@ export class FileViewerPage extends FileViewerBase {
<div class="llm-page fv-page"> <div class="llm-page fv-page">
<div class="llm-page-header"> <div class="llm-page-header">
<div class="llm-header-left"> <div class="llm-header-left">
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary back-btn" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
<h2 class="llm-page-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2> <h2 class="llm-page-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2>
</div> </div>
<div class="fv-header-actions"> <div class="fv-header-actions">
${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')} ${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')}
<button class="btn btn-sm btn-outline-secondary fv-download-btn" title="Download" @click=${() => this._download()}> <button class="btn btn-sm btn-outline-secondary fv-download-btn" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i> <i class="bi bi-download"></i>
</button> </button>
</div> </div>
+34 -27
View File
@@ -1,5 +1,6 @@
import { html } from 'lit'; import { html } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyForm(firstTypeId = '') { function emptyForm(firstTypeId = '') {
return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' }; return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' };
@@ -35,6 +36,8 @@ export class LlmProvidersPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'providers'; this._open = e.detail.page === 'providers';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
@@ -42,6 +45,11 @@ export class LlmProvidersPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
try { try {
const [typesRes, provRes, modelsRes] = await Promise.all([ const [typesRes, provRes, modelsRes] = await Promise.all([
@@ -49,9 +57,9 @@ export class LlmProvidersPage extends LightElement {
fetch('/api/llm/providers'), fetch('/api/llm/providers'),
fetch('/api/llm/models'), fetch('/api/llm/models'),
]); ]);
if (!typesRes.ok) throw new Error(`Provider types: HTTP ${typesRes.status}`); if (!typesRes.ok) throw new Error(`HTTP ${typesRes.status}`);
if (!provRes.ok) throw new Error(`Providers: HTTP ${provRes.status}`); if (!provRes.ok) throw new Error(`HTTP ${provRes.status}`);
if (!modelsRes.ok) throw new Error(`Models: HTTP ${modelsRes.status}`); if (!modelsRes.ok) throw new Error(`HTTP ${modelsRes.status}`);
const providerTypes = await typesRes.json(); const providerTypes = await typesRes.json();
const providers = await provRes.json(); const providers = await provRes.json();
@@ -104,7 +112,7 @@ export class LlmProvidersPage extends LightElement {
} }
async _delete(provider) { async _delete(provider) {
if (!confirm(`Delete provider "${provider.name}"? All associated models will be deleted too.`)) return; if (!confirm(t('providers.confirm.delete', { name: provider.name }))) return;
try { try {
const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' }); const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -176,15 +184,15 @@ export class LlmProvidersPage extends LightElement {
<span class="pv-card-name">${p.name}</span> <span class="pv-card-name">${p.name}</span>
<span class="pv-card-type-badge">${label}</span> <span class="pv-card-type-badge">${label}</span>
${count != null ? html` ${count != null ? html`
<span class="pv-card-count" title="Models using this provider"> <span class="pv-card-count" title=${t('providers.card.models_title')}>
<i class="bi bi-cpu me-1"></i>${count} <i class="bi bi-cpu me-1"></i>${count}
</span> </span>
` : ''} ` : ''}
<div class="pv-card-actions"> <div class="pv-card-actions">
<button class="pv-btn-icon pv-btn-edit" title="Edit" @click=${() => this._openEdit(p)}> <button class="pv-btn-icon pv-btn-edit" title=${t('providers.card.edit')} @click=${() => this._openEdit(p)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="pv-btn-icon pv-btn-delete" title="Delete" @click=${() => this._delete(p)}> <button class="pv-btn-icon pv-btn-delete" title=${t('providers.card.delete')} @click=${() => this._delete(p)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -199,10 +207,10 @@ export class LlmProvidersPage extends LightElement {
<div class="pv-card-row3"> <div class="pv-card-row3">
<span class="pv-card-tag ${hasKey ? 'pv-tag-ok' : 'pv-tag-missing'}"> <span class="pv-card-tag ${hasKey ? 'pv-tag-ok' : 'pv-tag-missing'}">
<i class="bi ${hasKey ? 'bi-lock-fill' : 'bi-unlock'}"></i> <i class="bi ${hasKey ? 'bi-lock-fill' : 'bi-unlock'}"></i>
API key ${hasKey ? 'configured' : 'missing'} ${hasKey ? t('providers.card.api_key_configured') : t('providers.card.api_key_missing')}
</span> </span>
${needsUrl && p.base_url ? html` ${needsUrl && p.base_url ? html`
<span class="pv-card-tag pv-tag-url" title="Base URL"> <span class="pv-card-tag pv-tag-url" title=${t('providers.card.base_url')}>
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
<span class="pv-card-url-text">${p.base_url}</span> <span class="pv-card-url-text">${p.base_url}</span>
</span> </span>
@@ -232,7 +240,7 @@ export class LlmProvidersPage extends LightElement {
<div class="agent-dialog pv-modal"> <div class="agent-dialog pv-modal">
<div class="pv-modal-header"> <div class="pv-modal-header">
<i class="bi bi-plug"></i> <i class="bi bi-plug"></i>
<span>${isEdit ? 'Edit Provider' : 'Add Provider'}</span> <span>${isEdit ? t('providers.modal.edit') : t('providers.modal.add')}</span>
<button type="button" class="pv-modal-close" @click=${() => this._closeModal()}> <button type="button" class="pv-modal-close" @click=${() => this._closeModal()}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
@@ -242,13 +250,13 @@ export class LlmProvidersPage extends LightElement {
<form @submit=${(e) => this._onSubmit(e)}> <form @submit=${(e) => this._onSubmit(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.name')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.name} required <input type="text" class="form-control form-control-sm" .value=${f.name} required
placeholder="e.g. My Anthropic" @input=${(e) => this._setField('name', e.target.value)} /> placeholder=${t('providers.modal.name_ph')} @input=${(e) => this._setField('name', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Type</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.type')}</label>
<select class="form-select form-select-sm" .value=${f.type} <select class="form-select form-select-sm" .value=${f.type}
@change=${(e) => this._setField('type', e.target.value)}> @change=${(e) => this._setField('type', e.target.value)}>
${this._providerTypes.map(t => html`<option value=${t.type_id}>${t.display_name}</option>`)} ${this._providerTypes.map(t => html`<option value=${t.type_id}>${t.display_name}</option>`)}
@@ -257,35 +265,35 @@ export class LlmProvidersPage extends LightElement {
${needsKey ? html` ${needsKey ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">API Key</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.api_key')}</label>
<input type="password" class="form-control form-control-sm" .value=${f.api_key} <input type="password" class="form-control form-control-sm" .value=${f.api_key}
autocomplete="new-password" autocomplete="new-password"
placeholder=${isEdit ? 'Leave blank to keep existing key' : ''} placeholder=${isEdit ? t('providers.modal.api_key_ph') : ''}
@input=${(e) => this._setField('api_key', e.target.value)} /> @input=${(e) => this._setField('api_key', e.target.value)} />
</div> </div>
` : ''} ` : ''}
${needsUrl ? html` ${needsUrl ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Base URL</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.base_url')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.base_url} <input type="text" class="form-control form-control-sm" .value=${f.base_url}
placeholder=${f.type === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'} placeholder=${f.type === 'ollama' ? t('providers.modal.base_url_ollama') : t('providers.modal.base_url_oai')}
@input=${(e) => this._setField('base_url', e.target.value)} /> @input=${(e) => this._setField('base_url', e.target.value)} />
</div> </div>
` : ''} ` : ''}
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.description')} <span class="text-muted fw-normal">${t('providers.modal.description_optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.description} <input type="text" class="form-control form-control-sm" .value=${f.description}
@input=${(e) => this._setField('description', e.target.value)} /> @input=${(e) => this._setField('description', e.target.value)} />
</div> </div>
<div class="pv-modal-actions"> <div class="pv-modal-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('providers.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('providers.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save changes' : 'Add provider'}`} : html`<i class="bi bi-check-lg me-1"></i>${isEdit ? t('providers.modal.save_changes') : t('providers.modal.add_provider')}`}
</button> </button>
</div> </div>
</form> </form>
@@ -301,13 +309,12 @@ export class LlmProvidersPage extends LightElement {
<div class="pv-page"> <div class="pv-page">
<div class="pv-header"> <div class="pv-header">
<h2 class="pv-title"> <h2 class="pv-title">
<i class="bi bi-plug me-2"></i>Providers <i class="bi bi-plug me-2"></i>${t('providers.title')}
</h2> </h2>
<div class="pv-header-right"> <div class="pv-header-right">
<span class="pv-header-count">${this._providers.length}</span> <span class="pv-header-count">${t('providers.count', { n: this._providers.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add <i class="bi bi-plus-lg me-1"></i>${t('providers.add')}
</button>
</div> </div>
</div> </div>
@@ -319,9 +326,9 @@ export class LlmProvidersPage extends LightElement {
${this._providers.length === 0 ? html` ${this._providers.length === 0 ? html`
<div class="pv-empty"> <div class="pv-empty">
<i class="bi bi-plug"></i> <i class="bi bi-plug"></i>
<p>No providers configured yet.</p> <p>${t('providers.empty')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first provider <i class="bi bi-plus-lg me-1"></i>${t('providers.add_first')}
</button> </button>
</div> </div>
` : this._providers.map(p => this._renderCard(p))} ` : this._providers.map(p => this._renderCard(p))}
+36 -24
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../lib/base.js'; import { LightElement, renderMarkdown } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -180,6 +181,17 @@ export class LlmRequestDetail extends LightElement {
this._expandedTools = new Set(); this._expandedTools = new Set();
} }
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
updated(changed) { updated(changed) {
if (changed.has('detailId') && this.detailId != null) { if (changed.has('detailId') && this.detailId != null) {
this._detail = null; this._detail = null;
@@ -263,20 +275,20 @@ export class LlmRequestDetail extends LightElement {
_renderStatBar(d) { _renderStatBar(d) {
return html` return html`
<div class="llmr-detail-statbar"> <div class="llmr-detail-statbar">
<span class="llmr-badge-agent">${d.agent_id ?? 'no agent'}</span> <span class="llmr-badge-agent">${d.agent_id ?? t('llmr.detail.no_agent')}</span>
<span class="llmr-badge-source">${d.source ?? '—'}</span> <span class="llmr-badge-source">${d.source ?? '—'}</span>
<span class="llmr-detail-model">${d.model_name}</span> <span class="llmr-detail-model">${d.model_name}</span>
${d.stack_id != null ? html`<span class="llmr-detail-pill llmr-detail-pill--stack">stack #${d.stack_id}</span>` : nothing} ${d.stack_id != null ? html`<span class="llmr-detail-pill llmr-detail-pill--stack">stack #${d.stack_id}</span>` : nothing}
<span class="llmr-detail-sep"></span> <span class="llmr-detail-sep"></span>
<span class="llmr-detail-stat" title="Input tokens"> <span class="llmr-detail-stat" title=${t('llmr.detail.stat_input')}>
<i class="bi bi-arrow-up-circle"></i> ${fmtTokens(d.input_tokens)} <i class="bi bi-arrow-up-circle"></i> ${fmtTokens(d.input_tokens)}
</span> </span>
<span class="llmr-detail-stat" title="Output tokens"> <span class="llmr-detail-stat" title=${t('llmr.detail.stat_output')}>
<i class="bi bi-arrow-down-circle"></i> ${fmtTokens(d.output_tokens)} <i class="bi bi-arrow-down-circle"></i> ${fmtTokens(d.output_tokens)}
</span> </span>
${d.cache_read_tokens > 0 ? html` ${d.cache_read_tokens > 0 ? html`
<span class="llmr-detail-stat llmr-detail-stat--cache" title=${cacheTooltip(d)}> <span class="llmr-detail-stat llmr-detail-stat--cache" title=${cacheTooltip(d)}>
<i class="bi bi-lightning-charge"></i> cache ${cacheHitPct(d)} <i class="bi bi-lightning-charge"></i> ${t('llmr.detail.cache_label', { pct: cacheHitPct(d) })}
</span> </span>
` : nothing} ` : nothing}
<span class="llmr-detail-stat"> <span class="llmr-detail-stat">
@@ -285,7 +297,7 @@ export class LlmRequestDetail extends LightElement {
<span class="llmr-detail-date">${formatDate(d.created_at)}</span> <span class="llmr-detail-date">${formatDate(d.created_at)}</span>
${d.error_text ? html` ${d.error_text ? html`
<span class="llmr-detail-error-badge" title=${d.error_text}> <span class="llmr-detail-error-badge" title=${d.error_text}>
<i class="bi bi-exclamation-triangle-fill"></i> error <i class="bi bi-exclamation-triangle-fill"></i> ${t('llmr.detail.error_badge')}
</span> </span>
` : nothing} ` : nothing}
</div> </div>
@@ -312,7 +324,7 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-reasoning-block"> <div class="llmr-reasoning-block">
<div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}> <div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}>
<i class="bi bi-lightbulb"></i> <i class="bi bi-lightbulb"></i>
<span>reasoning</span> <span>${t('llmr.detail.reasoning_label')}</span>
<span class="llmr-tool-toggle ms-auto"> <span class="llmr-tool-toggle ms-auto">
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i> <i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
</span> </span>
@@ -346,11 +358,11 @@ export class LlmRequestDetail extends LightElement {
</div> </div>
${open ? html` ${open ? html`
<div class="llmr-tool-expanded"> <div class="llmr-tool-expanded">
<div class="llmr-tool-section-label">Parameters</div> <div class="llmr-tool-section-label">${t('llmr.detail.tool_params')}</div>
<pre class="llmr-tool-pre">${args}</pre> <pre class="llmr-tool-pre">${args}</pre>
${result != null ? html` ${result != null ? html`
<div class="llmr-tool-section-label llmr-tool-section-label--result"> <div class="llmr-tool-section-label llmr-tool-section-label--result">
Result ${result.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing} ${t('llmr.detail.tool_result')} ${result.is_error ? html`<span class="badge bg-danger ms-1">${t('llmr.detail.error_badge')}</span>` : nothing}
</div> </div>
<pre class="llmr-tool-pre">${result.content}</pre> <pre class="llmr-tool-pre">${result.content}</pre>
` : nothing} ` : nothing}
@@ -370,9 +382,9 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-tool-block llmr-tool-block--result ${block.is_error ? 'llmr-tool-block--error' : ''}"> <div class="llmr-tool-block llmr-tool-block--result ${block.is_error ? 'llmr-tool-block--error' : ''}">
<div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}> <div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}>
<i class="bi bi-arrow-return-left"></i> <i class="bi bi-arrow-return-left"></i>
<span class="llmr-tool-name">result</span> <span class="llmr-tool-name">${t('llmr.detail.tool_result')}</span>
<span class="llmr-tool-id">${block.tool_use_id ?? ''}</span> <span class="llmr-tool-id">${block.tool_use_id ?? ''}</span>
${block.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing} ${block.is_error ? html`<span class="badge bg-danger ms-1">${t('llmr.detail.error_badge')}</span>` : nothing}
<span class="llmr-tool-toggle ms-auto"> <span class="llmr-tool-toggle ms-auto">
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i> <i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
</span> </span>
@@ -403,7 +415,7 @@ export class LlmRequestDetail extends LightElement {
if (!text) return nothing; if (!text) return nothing;
return html` return html`
<div class="llmr-msg llmr-msg--system"> <div class="llmr-msg llmr-msg--system">
<div class="llmr-msg-role"><i class="bi bi-shield-lock-fill"></i> system</div> <div class="llmr-msg-role"><i class="bi bi-shield-lock-fill"></i> ${t('llmr.detail.system_role')}</div>
<div class="llmr-msg-body"> <div class="llmr-msg-body">
<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div> <div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div>
</div> </div>
@@ -434,12 +446,12 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page"> <div class="llmr-page">
<div class="llmr-detail-back"> <div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back <i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button> </button>
</div> </div>
<div class="llmr-state"> <div class="llmr-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div> <div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading</span> <span>${t('llmr.detail.loading')}</span>
</div> </div>
</div> </div>
`; `;
@@ -448,7 +460,7 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page"> <div class="llmr-page">
<div class="llmr-detail-back"> <div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back <i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button> </button>
</div> </div>
<div class="llmr-state llmr-state--error"> <div class="llmr-state llmr-state--error">
@@ -479,10 +491,10 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page"> <div class="llmr-page">
<div class="llmr-detail-back"> <div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back <i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button> </button>
<span class="llmr-detail-title"> <span class="llmr-detail-title">
<i class="bi bi-journal-code"></i> Request <span class="llmr-detail-id">#${d.id}</span> <i class="bi bi-journal-code"></i> ${t('llmr.detail.request')} <span class="llmr-detail-id">#${d.id}</span>
</span> </span>
</div> </div>
@@ -491,34 +503,34 @@ export class LlmRequestDetail extends LightElement {
${payloadMissing ? html` ${payloadMissing ? html`
<div class="llmr-purged-banner"> <div class="llmr-purged-banner">
<i class="bi bi-hourglass-split"></i> <i class="bi bi-hourglass-split"></i>
Payload not available this request has been purged by the retention policy. ${t('llmr.detail.purged')}
</div> </div>
` : nothing} ` : nothing}
${hdrs ? this._renderSection('req-headers', 'Request Headers', ${hdrs ? this._renderSection('req-headers', t('llmr.detail.section_req_headers'),
this._renderKvTable(Object.entries(hdrs)) this._renderKvTable(Object.entries(hdrs))
) : nothing} ) : nothing}
${respHdrs ? this._renderSection('resp-headers', 'Response Headers', ${respHdrs ? this._renderSection('resp-headers', t('llmr.detail.section_resp_headers'),
this._renderKvTable(Object.entries(respHdrs)) this._renderKvTable(Object.entries(respHdrs))
) : nothing} ) : nothing}
${params.length ? this._renderSection('params', 'Parameters', ${params.length ? this._renderSection('params', t('llmr.detail.section_params'),
this._renderKvTable(params) this._renderKvTable(params)
) : nothing} ) : nothing}
${system ? this._renderSection('system', 'System Prompt', ${system ? this._renderSection('system', t('llmr.detail.section_system'),
html`<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(system))}</div>` html`<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(system))}</div>`
) : nothing} ) : nothing}
${msgs.length ? this._renderSection('conversation', 'Conversation', ${msgs.length ? this._renderSection('conversation', t('llmr.detail.section_conversation'),
html`<div class="llmr-msg-list"> html`<div class="llmr-msg-list">
${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))} ${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))}
</div>`, </div>`,
msgs.length msgs.length
) : nothing} ) : nothing}
${tools.length ? this._renderSection('tools', 'Tools Defined', ${tools.length ? this._renderSection('tools', t('llmr.detail.section_tools'),
html`<div class="llmr-tool-def-list"> html`<div class="llmr-tool-def-list">
${tools.map((t, i) => { ${tools.map((t, i) => {
// Anthropic: { name, description, input_schema } // Anthropic: { name, description, input_schema }
@@ -550,7 +562,7 @@ export class LlmRequestDetail extends LightElement {
tools.length tools.length
) : nothing} ) : nothing}
${resp ? this._renderSection('response', 'Response', ${resp ? this._renderSection('response', t('llmr.detail.section_response'),
html` html`
${respMeta.length ? this._renderKvTable(respMeta) : nothing} ${respMeta.length ? this._renderKvTable(respMeta) : nothing}
<div class="llmr-msg-list llmr-msg-list--resp"> <div class="llmr-msg-list llmr-msg-list--resp">
+31 -23
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'llm-requests'; const PAGE_ID = 'llm-requests';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -25,8 +26,8 @@ function cacheHitPct(item) {
function cacheTooltip(item) { function cacheTooltip(item) {
const parts = []; const parts = [];
if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`); if (item.cache_read_tokens != null) parts.push(t('llmr.cache_read', { n: item.cache_read_tokens.toLocaleString() }));
if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_tokens.toLocaleString()} tk`); if (item.cache_creation_tokens != null) parts.push(t('llmr.cache_write', { n: item.cache_creation_tokens.toLocaleString() }));
return parts.length ? parts.join(' | ') : ''; return parts.length ? parts.join(' | ') : '';
} }
@@ -64,6 +65,8 @@ export class LlmRequestsPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
@@ -75,6 +78,11 @@ export class LlmRequestsPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
_idFromHash() { _idFromHash() {
const parts = location.hash.replace('#', '').split('/'); const parts = location.hash.replace('#', '').split('/');
if (parts[0] === PAGE_ID && parts[1]) { if (parts[0] === PAGE_ID && parts[1]) {
@@ -138,29 +146,29 @@ export class LlmRequestsPage extends LightElement {
return html` return html`
<div class="llmr-filters"> <div class="llmr-filters">
<div class="llmr-filter-group"> <div class="llmr-filter-group">
<label class="llmr-filter-label">Agent ID</label> <label class="llmr-filter-label">${t('llmr.filter.agent_id')}</label>
<input class="form-control form-control-sm" type="text" <input class="form-control form-control-sm" type="text"
placeholder="e.g. main" placeholder=${t('llmr.filter.agent_ph')}
.value=${this._agentId} .value=${this._agentId}
@input=${e => this._agentId = e.target.value} @input=${e => this._agentId = e.target.value}
@keydown=${e => e.key === 'Enter' && this._apply()} /> @keydown=${e => e.key === 'Enter' && this._apply()} />
</div> </div>
<div class="llmr-filter-group"> <div class="llmr-filter-group">
<label class="llmr-filter-label">Source</label> <label class="llmr-filter-label">${t('llmr.filter.source')}</label>
<input class="form-control form-control-sm" type="text" <input class="form-control form-control-sm" type="text"
placeholder="e.g. web, tic, cron" placeholder=${t('llmr.filter.source_ph')}
.value=${this._source} .value=${this._source}
@input=${e => this._source = e.target.value} @input=${e => this._source = e.target.value}
@keydown=${e => e.key === 'Enter' && this._apply()} /> @keydown=${e => e.key === 'Enter' && this._apply()} />
</div> </div>
<div class="llmr-filter-group"> <div class="llmr-filter-group">
<label class="llmr-filter-label">From</label> <label class="llmr-filter-label">${t('llmr.filter.from')}</label>
<input class="form-control form-control-sm" type="date" <input class="form-control form-control-sm" type="date"
.value=${this._from} .value=${this._from}
@change=${e => this._from = e.target.value} /> @change=${e => this._from = e.target.value} />
</div> </div>
<div class="llmr-filter-group"> <div class="llmr-filter-group">
<label class="llmr-filter-label">To</label> <label class="llmr-filter-label">${t('llmr.filter.to')}</label>
<input class="form-control form-control-sm" type="date" <input class="form-control form-control-sm" type="date"
.value=${this._to} .value=${this._to}
@change=${e => this._to = e.target.value} /> @change=${e => this._to = e.target.value} />
@@ -168,11 +176,11 @@ export class LlmRequestsPage extends LightElement {
<div class="llmr-filter-actions"> <div class="llmr-filter-actions">
<button class="btn btn-sm btn-primary" @click=${() => this._apply()} <button class="btn btn-sm btn-primary" @click=${() => this._apply()}
?disabled=${this._loading}> ?disabled=${this._loading}>
Apply ${t('llmr.filter.apply')}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()} <button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()}
?disabled=${this._loading}> ?disabled=${this._loading}>
Reset ${t('llmr.filter.reset')}
</button> </button>
</div> </div>
</div> </div>
@@ -183,7 +191,7 @@ export class LlmRequestsPage extends LightElement {
if (this._loading) return html` if (this._loading) return html`
<div class="llmr-state"> <div class="llmr-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div> <div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading</span> <span>${t('llmr.loading')}</span>
</div> </div>
`; `;
if (this._error) return html` if (this._error) return html`
@@ -195,7 +203,7 @@ export class LlmRequestsPage extends LightElement {
if (this._items.length === 0) return html` if (this._items.length === 0) return html`
<div class="llmr-state"> <div class="llmr-state">
<i class="bi bi-inbox"></i> <i class="bi bi-inbox"></i>
<span>No requests found.</span> <span>${t('llmr.empty')}</span>
</div> </div>
`; `;
@@ -204,14 +212,14 @@ export class LlmRequestsPage extends LightElement {
<table class="table table-sm llmr-table"> <table class="table table-sm llmr-table">
<thead> <thead>
<tr> <tr>
<th>Agent</th> <th>${t('llmr.table.agent')}</th>
<th>Source</th> <th>${t('llmr.table.source')}</th>
<th>Model</th> <th>${t('llmr.table.model')}</th>
<th>Date</th> <th>${t('llmr.table.date')}</th>
<th class="text-end">In tokens</th> <th class="text-end">${t('llmr.table.in_tokens')}</th>
<th class="text-end">Out tokens</th> <th class="text-end">${t('llmr.table.out_tokens')}</th>
<th class="text-end">Cache hit</th> <th class="text-end">${t('llmr.table.cache_hit')}</th>
<th class="text-end">ms</th> <th class="text-end">${t('llmr.table.ms')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -254,7 +262,7 @@ export class LlmRequestsPage extends LightElement {
@click=${() => this._fetch(cur - 1)}> @click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</button> </button>
<span class="llmr-page-info">Page ${cur} of ${pages} &mdash; ${this._total} results</span> <span class="llmr-page-info">${t('llmr.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages} <button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}> @click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
@@ -276,8 +284,8 @@ export class LlmRequestsPage extends LightElement {
return html` return html`
<div class="llmr-page"> <div class="llmr-page">
<div class="llmr-header"> <div class="llmr-header">
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> LLM Requests</h2> <h2 class="llmr-title"><i class="bi bi-journal-code"></i> ${t('llmr.title')}</h2>
<span class="llmr-total-badge">${this._total} rows</span> <span class="llmr-total-badge">${t('llmr.total', { n: this._total })}</span>
</div> </div>
${this._renderFilters()} ${this._renderFilters()}
${this._renderTable()} ${this._renderTable()}
+11 -10
View File
@@ -1,7 +1,8 @@
import { html } from 'lit'; import { html } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
export class LoginPage extends LightElement { export class LoginPage extends I18nMixin(LightElement) {
static get properties() { static get properties() {
return { return {
@@ -27,7 +28,7 @@ export class LoginPage extends LightElement {
this._error = null; this._error = null;
if (!this._username.trim() || !this._password) { if (!this._username.trim() || !this._password) {
this._error = 'Enter your username and password.'; this._error = t('login.missing');
return; return;
} }
@@ -46,13 +47,13 @@ export class LoginPage extends LightElement {
}), }),
}); });
if (!res.ok) { if (!res.ok) {
this._error = 'Invalid username or password.'; this._error = t('login.error');
return; return;
} }
// Logged in — reload into the app. // Logged in — reload into the app.
window.location.reload(); window.location.reload();
} catch { } catch {
this._error = 'Network error — please try again.'; this._error = t('login.network');
} finally { } finally {
this._busy = false; this._busy = false;
} }
@@ -60,8 +61,8 @@ export class LoginPage extends LightElement {
render() { render() {
const btnLabel = this._busy const btnLabel = this._busy
? html`<span class="login-spinner"></span>Signing in…` ? html`<span class="login-spinner"></span>${t('login.signing')}`
: 'Sign in'; : t('login.submit');
return html` return html`
<div class="login-page"> <div class="login-page">
@@ -69,13 +70,13 @@ export class LoginPage extends LightElement {
<div class="login-logo"> <div class="login-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" /> <img src="/assets/icons/icon-192.png" alt="Skald" />
</div> </div>
<h1 class="login-title">Welcome back</h1> <h1 class="login-title">${t('login.title')}</h1>
<p class="login-subtitle">Sign in to your account.</p> <p class="login-subtitle">${t('login.subtitle')}</p>
${this._error ? html`<div class="login-error">${this._error}</div>` : null} ${this._error ? html`<div class="login-error">${this._error}</div>` : null}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">${t('login.username')}</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
@@ -86,7 +87,7 @@ export class LoginPage extends LightElement {
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">${t('login.password')}</label>
<input <input
type="password" type="password"
class="form-control" class="form-control"
+34 -30
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Connector marketplace — blueprint §14/§15. // Connector marketplace — blueprint §14/§15.
// //
@@ -57,6 +59,8 @@ export class MarketplacePage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'marketplace'; this._open = e.detail.page === 'marketplace';
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
@@ -64,6 +68,11 @@ export class MarketplacePage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() { async _load() {
@@ -91,9 +100,9 @@ export class MarketplacePage extends LightElement {
async _install(card) { async _install(card) {
const warn = card.source === 'local_script' const warn = card.source === 'local_script'
? `\n\nThis puts code on this box:\n${card.file_count} file(s), each verified against its SHA-256\n • installed into ./connectors/${card.id}/` ? '\n\n' + t('marketplace.confirm.install_warn', { n: card.file_count, id: card.id })
: ''; : '';
if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return; if (!confirm(t('marketplace.confirm.install_body', { name: card.name }) + warn)) return;
this._installing = card.id; this._installing = card.id;
this._error = null; this._error = null;
try { try {
@@ -137,13 +146,13 @@ export class MarketplacePage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-shop me-2"></i>Marketplace</h2> <h2 class="um-title"><i class="bi bi-shop me-2"></i>${t('marketplace.title')}</h2>
<div class="um-header-right"> <div class="um-header-right">
<button class="btn btn-sm btn-outline-primary" @click=${() => this._goCatalog()}> <button class="btn btn-sm btn-outline-primary" @click=${() => this._goCatalog()}>
<i class="bi bi-arrow-left me-1"></i>Catalog <i class="bi bi-arrow-left me-1"></i>${t('marketplace.btn.catalog')}
</button> </button>
${this._isAdmin ? html` ${this._isAdmin ? html`
<button class="um-btn-icon ms-1" title="Refetch the feed" <button class="um-btn-icon ms-1" title=${t('marketplace.action.refetch')}
@click=${() => this._loadFeed(true)}><i class="bi bi-arrow-clockwise"></i></button> @click=${() => this._loadFeed(true)}><i class="bi bi-arrow-clockwise"></i></button>
` : nothing} ` : nothing}
</div> </div>
@@ -156,28 +165,23 @@ export class MarketplacePage extends LightElement {
${this._me && !this._isAdmin ? html` ${this._me && !this._isAdmin ? html`
<div class="um-empty" style="padding:2rem"> <div class="um-empty" style="padding:2rem">
<i class="bi bi-shield-lock"></i> <i class="bi bi-shield-lock"></i>
<p>The marketplace is managed by the admin.</p> <p>${t('marketplace.not_admin')}</p>
<p style="font-size:.8rem;opacity:.7"> <p style="font-size:.8rem;opacity:.7">
Connectors the admin has installed appear on the ${unsafeHTML(t('marketplace.not_admin_link'))}</p>
<a href="#connectors" @click=${(e) => { e.preventDefault();
history.pushState({ page: 'connectors' }, '', '#connectors');
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); }}>Connectors</a> page.
</p>
</div> </div>
` : html` ` : html`
<div class="text-muted mt-3 mb-3" style="font-size:.8rem"> <div class="text-muted mt-3 mb-3" style="font-size:.8rem">
Vetted connectors you can add to this box's catalog. Installing does not ${unsafeHTML(t('marketplace.desc'))}
activate anything it makes a connector <em>available</em>.
</div> </div>
${this._feedErr ? html` ${this._feedErr ? html`
<div class="alert alert-warning py-2" style="font-size:.82rem"> <div class="alert alert-warning py-2" style="font-size:.82rem">
<i class="bi bi-wifi-off me-1"></i>Marketplace unreachable ${this._feedErr} <i class="bi bi-wifi-off me-1"></i>${t('marketplace.feed_unreachable', { error: this._feedErr })}
</div>` : nothing} </div>` : nothing}
${this._renderFilters()} ${this._renderFilters()}
${loading ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>Loading feed…</p></div>` ${loading ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>${t('marketplace.loading')}</p></div>`
: this._renderGrid()} : this._renderGrid()}
`} `}
</div> </div>
@@ -202,13 +206,13 @@ export class MarketplacePage extends LightElement {
<div class="connector-filters"> <div class="connector-filters">
<div class="connector-search"> <div class="connector-search">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder="Search connectors…" <input class="form-control form-control-sm" placeholder=${t('marketplace.filter.search')}
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} /> .value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
</div> </div>
${this._segment('Scope', this._scope, (v) => { this._scope = v; }, ${this._segment(t('marketplace.filter.scope'), this._scope, (v) => { this._scope = v; },
[['All', 'all'], ['Global', 'global'], ['Per-user', 'per_user']])} [[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.global'), 'global'], [t('marketplace.filter.per_user'), 'per_user']])}
${this._segment('Type', this._source, (v) => { this._source = v; }, ${this._segment(t('marketplace.filter.type'), this._source, (v) => { this._source = v; },
[['All', 'all'], ['Remote', 'remote'], ['Local', 'local_script']])} [[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.remote'), 'remote'], [t('marketplace.filter.local'), 'local_script']])}
</div>`; </div>`;
} }
@@ -218,7 +222,7 @@ export class MarketplacePage extends LightElement {
if (cards.length === 0) { if (cards.length === 0) {
return html` return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i> <div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
<p>${total === 0 ? 'The feed is empty.' : 'No connector matches these filters.'}</p></div>`; <p>${total === 0 ? t('marketplace.grid.empty_feed') : t('marketplace.grid.no_match')}</p></div>`;
} }
return html` return html`
<div class="connector-grid"> <div class="connector-grid">
@@ -243,7 +247,7 @@ export class MarketplacePage extends LightElement {
<div class="connector-card-name">${c.name}</div> <div class="connector-card-name">${c.name}</div>
<div class="connector-card-sub">${c.id}${c.version ? ` · v${c.version}` : ''}</div> <div class="connector-card-sub">${c.id}${c.version ? ` · v${c.version}` : ''}</div>
</div> </div>
${c.installed ? html`<span class="connector-chip connector-chip--ok">installed</span>` : nothing} ${c.installed ? html`<span class="connector-chip connector-chip--ok">${t('marketplace.card.installed')}</span>` : nothing}
</div> </div>
${c.user_description ? html`<div class="connector-card-desc">${c.user_description}</div>` : nothing} ${c.user_description ? html`<div class="connector-card-desc">${c.user_description}</div>` : nothing}
@@ -251,11 +255,11 @@ export class MarketplacePage extends LightElement {
<div class="connector-chips"> <div class="connector-chips">
<span class="connector-chip connector-chip--scope"> <span class="connector-chip connector-chip--scope">
<i class="bi ${c.scope === 'global' ? 'bi-globe' : 'bi-person'}"></i> <i class="bi ${c.scope === 'global' ? 'bi-globe' : 'bi-person'}"></i>
${c.scope === 'global' ? 'global' : 'per-user'} ${c.scope === 'global' ? t('marketplace.card.scope_global') : t('marketplace.card.scope_per_user')}
</span> </span>
<span class="connector-chip ${isScript ? 'connector-chip--script' : ''}"> <span class="connector-chip ${isScript ? 'connector-chip--script' : ''}">
<i class="bi ${isScript ? 'bi-file-earmark-code' : 'bi-cloud'}"></i> <i class="bi ${isScript ? 'bi-file-earmark-code' : 'bi-cloud'}"></i>
${isScript ? 'local script' : 'remote'} ${isScript ? t('marketplace.card.type_script') : t('marketplace.card.type_remote')}
</span> </span>
${c.auth_kind !== 'none' ? html` ${c.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${c.auth_kind}</span>` : nothing} <span class="connector-chip"><i class="bi bi-key"></i>${c.auth_kind}</span>` : nothing}
@@ -264,24 +268,24 @@ export class MarketplacePage extends LightElement {
${isScript ? html` ${isScript ? html`
<div class="connector-card-note"> <div class="connector-card-note">
<i class="bi bi-shield-check"></i>${c.file_count} file${c.file_count === 1 ? '' : 's'}, SHA-256 verified on install <i class="bi bi-shield-check"></i>${t(c.file_count === 1 ? 'marketplace.card.files_one' : 'marketplace.card.files_other', { n: c.file_count })}
</div>` : nothing} </div>` : nothing}
${c.oauth_scopes?.length ? html` ${c.oauth_scopes?.length ? html`
<details class="connector-card-scopes"> <details class="connector-card-scopes">
<summary>Requests ${c.oauth_scopes.length} OAuth scope${c.oauth_scopes.length === 1 ? '' : 's'}</summary> <summary>${t(c.oauth_scopes.length === 1 ? 'marketplace.card.oauth_scopes_one' : 'marketplace.card.oauth_scopes_other', { n: c.oauth_scopes.length })}</summary>
${c.oauth_scopes.map((s) => html`<code>${s}</code>`)} ${c.oauth_scopes.map((s) => html`<code>${s}</code>`)}
</details>` : nothing} </details>` : nothing}
<div class="connector-card-actions"> <div class="connector-card-actions">
<button class="btn btn-sm ${c.installed ? 'btn-outline-primary' : 'btn-primary'}" <button class="btn btn-sm ${c.installed ? 'btn-outline-primary' : 'btn-primary'}"
?disabled=${busy} @click=${() => this._install(c)}> ?disabled=${busy} @click=${() => this._install(c)}>
${busy ? html`<i class="bi bi-hourglass-split me-1"></i>Installing` ${busy ? html`<i class="bi bi-hourglass-split me-1"></i>${t('marketplace.card.installing')}`
: c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>Reinstall` : c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>${t('marketplace.card.reinstall')}`
: html`<i class="bi bi-download me-1"></i>Install`} : html`<i class="bi bi-download me-1"></i>${t('marketplace.card.install')}`}
</button> </button>
${c.homepage ? html` ${c.homepage ? html`
<a class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
href=${c.homepage} target="_blank" rel="noopener noreferrer" title="Homepage"> href=${c.homepage} target="_blank" rel="noopener noreferrer" title=${t('marketplace.card.homepage')}>
<i class="bi bi-box-arrow-up-right"></i></a>` : nothing} <i class="bi bi-box-arrow-up-right"></i></a>` : nothing}
</div> </div>
</div>`; </div>`;
+7 -6
View File
@@ -1,4 +1,5 @@
import { LitElement, html, nothing } from 'lit'; import { LitElement, html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import './shared/inbox-page.js'; import './shared/inbox-page.js';
import './shared/chat-page.js'; import './shared/chat-page.js';
import './shared/projects-page.js'; import './shared/projects-page.js';
@@ -199,18 +200,18 @@ class MobileApp extends LitElement {
${['notifications', 'settings'].includes(s) ? html` ${['notifications', 'settings'].includes(s) ? html`
<div class="mobile-coming-soon"> <div class="mobile-coming-soon">
<i class="bi bi-tools"></i> <i class="bi bi-tools"></i>
<p>Coming soon</p> <p>${t('mobile.coming_soon')}</p>
</div> </div>
` : ''} ` : ''}
</div> </div>
${this._native ? nothing : html` ${this._native ? nothing : html`
<nav class="mobile-nav"> <nav class="mobile-nav">
${item('inbox', 'bi-inbox', 'Inbox')} ${item('inbox', 'bi-inbox', t('mobile.nav.inbox'))}
${item('projects', 'bi-folder2-open', 'Projects')} ${item('projects', 'bi-folder2-open', t('mobile.nav.projects'))}
${item('chat', '', 'Chat', 'chat-btn')} ${item('chat', '', t('mobile.nav.chat'), 'chat-btn')}
${item('notifications', 'bi-bell', 'Alerts')} ${item('notifications', 'bi-bell', t('mobile.nav.alerts'))}
${item('settings', 'bi-sliders', 'Settings')} ${item('settings', 'bi-sliders', t('mobile.nav.settings'))}
</nav> </nav>
`} `}
</div> </div>
+21 -13
View File
@@ -1,30 +1,31 @@
import { html } from 'lit'; import { html } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const CARDS = [ const CARDS = [
{ {
id: 'llm', id: 'llm',
icon: 'bi-cpu', icon: 'bi-cpu',
title: 'LLM', titleKey: 'models.hub.card.llm.title',
desc: 'Chat & completion models for agents and tools', descKey: 'models.hub.card.llm.desc',
}, },
{ {
id: 'transcribe', id: 'transcribe',
icon: 'bi-mic', icon: 'bi-mic',
title: 'Transcription', titleKey: 'models.hub.card.transcribe.title',
desc: 'Speech-to-text models via cloud or local plugin', descKey: 'models.hub.card.transcribe.desc',
}, },
{ {
id: 'image', id: 'image',
icon: 'bi-image', icon: 'bi-image',
title: 'Image Generation', titleKey: 'models.hub.card.image.title',
desc: 'Text-to-image models via cloud API', descKey: 'models.hub.card.image.desc',
}, },
{ {
id: 'tts', id: 'tts',
icon: 'bi-volume-up', icon: 'bi-volume-up',
title: 'Text-to-Speech', titleKey: 'models.hub.card.tts.title',
desc: 'Speech synthesis models via cloud or local plugin', descKey: 'models.hub.card.tts.desc',
}, },
]; ];
@@ -44,6 +45,8 @@ export class ModelsHubPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
const open = e.detail.page === 'models'; const open = e.detail.page === 'models';
this.style.display = open ? 'flex' : 'none'; this.style.display = open ? 'flex' : 'none';
@@ -54,6 +57,11 @@ export class ModelsHubPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
_sectionFromHash() { _sectionFromHash() {
const parts = location.hash.slice(1).split('/'); const parts = location.hash.slice(1).split('/');
if (parts[0] === 'models' && parts[1]) { if (parts[0] === 'models' && parts[1]) {
@@ -103,7 +111,7 @@ export class ModelsHubPage extends LightElement {
_countLabel(id) { _countLabel(id) {
const n = this._counts[id] ?? 0; const n = this._counts[id] ?? 0;
return n === 0 ? 'No models' : n === 1 ? '1 model' : `${n} models`; return n === 0 ? t('models.hub.count.none') : n === 1 ? t('models.hub.count.one') : t('models.hub.count.many', { n });
} }
render() { render() {
@@ -118,9 +126,9 @@ export class ModelsHubPage extends LightElement {
return html` return html`
<div class="models-hub"> <div class="models-hub">
<h2 class="llm-page-title">Models</h2> <h2 class="llm-page-title">${t('models.hub.title')}</h2>
<p class="text-muted" style="font-size:0.88rem;margin-top:0.25rem"> <p class="text-muted" style="font-size:0.88rem;margin-top:0.25rem">
Configure LLM, transcription, and image generation providers. ${t('models.hub.subtitle')}
</p> </p>
<div class="models-hub-grid"> <div class="models-hub-grid">
${CARDS.map(card => html` ${CARDS.map(card => html`
@@ -128,8 +136,8 @@ export class ModelsHubPage extends LightElement {
<div class="models-type-card-icon"> <div class="models-type-card-icon">
<i class="bi ${card.icon}"></i> <i class="bi ${card.icon}"></i>
</div> </div>
<div class="models-type-card-title">${card.title}</div> <div class="models-type-card-title">${t(card.titleKey)}</div>
<div class="models-type-card-desc">${card.desc}</div> <div class="models-type-card-desc">${t(card.descKey)}</div>
<div class="models-type-card-count ${this._counts[card.id] > 0 ? 'has-models' : ''}"> <div class="models-type-card-count ${this._counts[card.id] > 0 ? 'has-models' : ''}">
${this._loading ? '…' : this._countLabel(card.id)} ${this._loading ? '…' : this._countLabel(card.id)}
</div> </div>
+37 -29
View File
@@ -1,5 +1,7 @@
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyIgForm() { function emptyIgForm() {
return { provider_id: '', model_id: '', name: '', priority: 100 }; return { provider_id: '', model_id: '', name: '', priority: 100 };
@@ -31,9 +33,16 @@ export class ModelsImageSection extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
this._load(); this._load();
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
try { try {
const [modelsRes, providersRes] = await Promise.all([ const [modelsRes, providersRes] = await Promise.all([
@@ -88,7 +97,7 @@ export class ModelsImageSection extends LightElement {
// ── Delete ─────────────────────────────────────────────────────────────────── // ── Delete ───────────────────────────────────────────────────────────────────
async _delete(m) { async _delete(m) {
if (!confirm(`Delete image model "${m.name}"?`)) return; if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.image.title'), name: m.name }))) return;
try { try {
const res = await fetch(`/api/image-generate/models/${m.id}`, { method: 'DELETE' }); const res = await fetch(`/api/image-generate/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -167,19 +176,19 @@ export class ModelsImageSection extends LightElement {
<div class="llm-card"> <div class="llm-card">
<div class="llm-card-row1"> <div class="llm-card-row1">
${isPlugin ${isPlugin
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>` ? html`<span class="ig-source-badge ig-source-plugin">${t('models.source_plugin')}</span>`
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`} : html`<span class="ig-source-badge ig-source-cloud">${t('models.source_cloud')}</span>`}
<span class="llm-card-name">${m.name}</span> <span class="llm-card-name">${m.name}</span>
<div class="llm-card-actions"> <div class="llm-card-actions">
${isPlugin ? html` ${isPlugin ? html`
<span class="llm-btn-icon" title="Managed by plugin" style="cursor:default;opacity:0.4"> <span class="llm-btn-icon" title=${t('models.managed_plugin')} style="cursor:default;opacity:0.4">
<i class="bi bi-lock"></i> <i class="bi bi-lock"></i>
</span> </span>
` : html` ` : html`
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}> <button class="llm-btn-icon llm-btn-edit" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}> <button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
`} `}
@@ -189,7 +198,7 @@ export class ModelsImageSection extends LightElement {
<div class="llm-card-row2"> <div class="llm-card-row2">
${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''} ${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''}
<span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span> <span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span>
<span class="ig-priority-tag" title="Priority">#${m.priority}</span> <span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>
</div> </div>
${m.description ? html` ${m.description ? html`
@@ -208,7 +217,7 @@ export class ModelsImageSection extends LightElement {
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title">Add Image Model Choose Provider</div> <div class="llm-modal-title">${t('models.add_model_provider', { type: t('models.hub.card.image.title') })}</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="llm-provider-grid"> <div class="llm-provider-grid">
${igProviders.map(p => html` ${igProviders.map(p => html`
@@ -219,7 +228,7 @@ export class ModelsImageSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -232,8 +241,8 @@ export class ModelsImageSection extends LightElement {
const f = this._form; const f = this._form;
const p = this._provider; const p = this._provider;
const title = isEdit const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>` ? html`${t('models.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add Image Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`; : html`${t('models.add_model_type', { type: t('models.hub.card.image.title') })} <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
@@ -244,35 +253,35 @@ export class ModelsImageSection extends LightElement {
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Model ID <span class="text-muted fw-normal">(sent to API)</span> ${t('models.model_id')} <span class="text-muted fw-normal">${t('models.label.sent_to_api')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required <input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder="e.g. x-ai/grok-2-vision" placeholder=${t('models.ph.model_id_image')}
?disabled=${isEdit} ?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} /> @input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''} ${isEdit ? html`<div class="form-text">${t('models.form.model_lock')}</div>` : ''}
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Name / Alias <span class="text-muted fw-normal">(used as provider_id in the LLM tool)</span> ${unsafeHTML(t('models.form.name_as_provider'))}
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'} placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._form = { ...this._form, name: e.target.value }} /> @input=${(e) => this._form = { ...this._form, name: e.target.value }} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.priority')}</label>
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1" <input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} /> @input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
<div class="form-text">Lower number = tried first. Default: 100.</div> <div class="form-text">${t('models.form.priority_img')}</div>
</div> </div>
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving' : isEdit ? 'Save changes' : 'Add model'} ${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button> </button>
</div> </div>
</form> </form>
@@ -292,17 +301,17 @@ export class ModelsImageSection extends LightElement {
<div class="llm-page-header"> <div class="llm-page-header">
<div class="llm-header-left"> <div class="llm-header-left">
${this.onback ? html` ${this.onback ? html`
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}> <button class="btn btn-sm btn-outline-secondary back-btn" title=${t('models.back')} @click=${this.onback}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
` : ''} ` : ''}
<div> <div>
<h2 class="llm-page-title">Image Generation Models</h2> <h2 class="llm-page-title">${t('models.image.title')}</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span> <span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
<i class="bi bi-plus-lg me-1"></i>Add <i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button> </button>
</div> </div>
@@ -310,7 +319,7 @@ export class ModelsImageSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">No provider supports image generation yet. Add an <strong>OpenRouter</strong> provider first.</p> <p class="mb-0">${t('models.no_providers_image')}</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -319,8 +328,7 @@ export class ModelsImageSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only managed automatically by the plugin that registered them. <p class="mb-0">${t('models.readonly_plugin_full')}</p>
To add, modify, or remove them, ask the agent directly: it has all the documentation it needs.</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -333,10 +341,10 @@ export class ModelsImageSection extends LightElement {
${this._models.length === 0 ? html` ${this._models.length === 0 ? html`
<div class="llm-empty-state"> <div class="llm-empty-state">
<i class="bi bi-image"></i> <i class="bi bi-image"></i>
<p>No image generation models configured.</p> <p>${t('models.list_empty_image')}</p>
${canAdd ? html` ${canAdd ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first model <i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button> </button>
` : ''} ` : ''}
</div> </div>
+71 -61
View File
@@ -1,5 +1,7 @@
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const STRENGTH_COLORS = { const STRENGTH_COLORS = {
very_high: '#ef4444', very_high: '#ef4444',
@@ -77,9 +79,16 @@ export class ModelsLlmSection extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
this._load(); this._load();
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
try { try {
const [modelsRes, providersRes, typesRes] = await Promise.all([ const [modelsRes, providersRes, typesRes] = await Promise.all([
@@ -144,7 +153,7 @@ export class ModelsLlmSection extends LightElement {
)); ));
await this._load(); await this._load();
} catch (e) { } catch (e) {
this._error = `Failed to save order: ${e.message}`; this._error = t('models.error.save_order', { msg: e.message });
await this._load(); await this._load();
} }
} }
@@ -183,7 +192,7 @@ export class ModelsLlmSection extends LightElement {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
this._orModels = await res.json(); this._orModels = await res.json();
} catch (e) { } catch (e) {
this._error = `Failed to load models: ${e.message}`; this._error = t('models.error.load_models', { msg: e.message });
} finally { } finally {
this._orLoading = false; this._orLoading = false;
} }
@@ -226,7 +235,7 @@ export class ModelsLlmSection extends LightElement {
// ── Delete ─────────────────────────────────────────────────────────────────── // ── Delete ───────────────────────────────────────────────────────────────────
async _delete(model) { async _delete(model) {
if (!confirm(`Delete model "${model.name}"?`)) return; if (!confirm(t('models.confirm_delete', { type: 'LLM', name: model.name }))) return;
try { try {
const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' }); const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -248,7 +257,7 @@ export class ModelsLlmSection extends LightElement {
let extra_params = null; let extra_params = null;
if (f.extra_params && f.extra_params.trim()) { if (f.extra_params && f.extra_params.trim()) {
try { extra_params = JSON.parse(f.extra_params); } try { extra_params = JSON.parse(f.extra_params); }
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; } catch { this._error = t('models.error.invalid_json'); this._saving = false; return; }
} }
try { try {
@@ -282,7 +291,7 @@ export class ModelsLlmSection extends LightElement {
async _submitCatalog(e) { async _submitCatalog(e) {
e.preventDefault(); e.preventDefault();
if (this._saving) return; if (this._saving) return;
if (!this._orForm.model_id) { this._error = 'Select a model'; return; } if (!this._orForm.model_id) { this._error = t('models.error.select_model'); return; }
this._saving = true; this._saving = true;
this._error = null; this._error = null;
@@ -334,7 +343,7 @@ export class ModelsLlmSection extends LightElement {
let extra_params = null; let extra_params = null;
if (f.extra_params && f.extra_params.trim()) { if (f.extra_params && f.extra_params.trim()) {
try { extra_params = JSON.parse(f.extra_params); } try { extra_params = JSON.parse(f.extra_params); }
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; } catch { this._error = t('models.error.invalid_json'); this._saving = false; return; }
} }
try { try {
@@ -415,26 +424,27 @@ export class ModelsLlmSection extends LightElement {
const out = this._fmtP(model.price_output_per_million); const out = this._fmtP(model.price_output_per_million);
if (!inp && !out) return html`<span style="opacity:0.3">—</span>`; if (!inp && !out) return html`<span style="opacity:0.3">—</span>`;
return html` return html`
<span class="llm-price-tag" title="Input/Output per 1M tokens"> <span class="llm-price-tag" title=${t('models.llm.price_tooltip')}>
${inp ?? '?'} <span style="opacity:0.45"></span> ${out ?? '?'} ${inp ?? '?'} <span style="opacity:0.45"></span> ${out ?? '?'}
</span> </span>
`; `;
} }
_renderStrengthDot(strength) { _renderStrengthDot(strength) {
if (!strength) return html`<span style="opacity:0.3"></span>`; if (!strength) return html`<span style="opacity:0.3">${'—'}</span>`;
const label = { very_high: t('models.strength.very_high'), high: t('models.strength.high'), average: t('models.strength.average'), low: t('models.strength.low'), very_low: t('models.strength.very_low') }[strength] ?? strength;
return html` return html`
<span class="llm-strength-dot" <span class="llm-strength-dot"
style="background:${STRENGTH_COLORS[strength] ?? '#888'}" style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
title=${STRENGTH_LABELS[strength] ?? strength}></span> title=${label}></span>
`; `;
} }
_renderStatus(status) { _renderStatus(status) {
const cfg = { const cfg = {
healthy: { color: '#22c55e', title: 'Healthy' }, healthy: { color: '#22c55e', title: t('models.status_healthy') },
degraded: { color: '#eab308', title: 'Degraded' }, degraded: { color: '#eab308', title: t('models.status_degraded') },
down: { color: '#ef4444', title: 'Down' }, down: { color: '#ef4444', title: t('models.status_down') },
}[status] ?? { color: '#888', title: status }; }[status] ?? { color: '#888', title: status };
return html`<span class="llm-strength-dot" style="background:${cfg.color}" title=${cfg.title}></span>`; return html`<span class="llm-strength-dot" style="background:${cfg.color}" title=${cfg.title}></span>`;
} }
@@ -446,12 +456,12 @@ export class ModelsLlmSection extends LightElement {
<div class="llm-card"> <div class="llm-card">
<div class="llm-card-row1"> <div class="llm-card-row1">
<div class="llm-move-btns"> <div class="llm-move-btns">
<button class="llm-move-btn" title="Move up" <button class="llm-move-btn" title=${t('models.move_up')}
?disabled=${first} ?disabled=${first}
@click=${() => this._moveUp(i)}> @click=${() => this._moveUp(i)}>
<i class="bi bi-chevron-up"></i> <i class="bi bi-chevron-up"></i>
</button> </button>
<button class="llm-move-btn" title="Move down" <button class="llm-move-btn" title=${t('models.move_down')}
?disabled=${last} ?disabled=${last}
@click=${() => this._moveDown(i)}> @click=${() => this._moveDown(i)}>
<i class="bi bi-chevron-down"></i> <i class="bi bi-chevron-down"></i>
@@ -460,12 +470,12 @@ export class ModelsLlmSection extends LightElement {
${this._renderStrengthDot(m.strength)} ${this._renderStrengthDot(m.strength)}
${this._renderStatus(m.status)} ${this._renderStatus(m.status)}
<span class="llm-card-name">${m.name}</span> <span class="llm-card-name">${m.name}</span>
${m.is_default ? html`<span class="llm-card-badge">default</span>` : ''} ${m.is_default ? html`<span class="llm-card-badge">${t('models.default')}</span>` : ''}
<div class="llm-card-actions"> <div class="llm-card-actions">
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}> <button class="llm-btn-icon llm-btn-edit" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}> <button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -480,7 +490,7 @@ export class ModelsLlmSection extends LightElement {
${(m.scope ?? []).length > 0 || m.extra_params ? html` ${(m.scope ?? []).length > 0 || m.extra_params ? html`
<div class="llm-card-row3"> <div class="llm-card-row3">
${(m.scope ?? []).map(s => html`<span class="llm-scope-pill">${s}</span>`)} ${(m.scope ?? []).map(s => html`<span class="llm-scope-pill">${s}</span>`)}
${m.extra_params ? html`<span class="llm-scope-pill llm-params-pill" title=${JSON.stringify(m.extra_params)}>+params</span>` : ''} ${m.extra_params ? html`<span class="llm-scope-pill llm-params-pill" title=${JSON.stringify(m.extra_params)}>+${t('models.extra_params').toLowerCase()}</span>` : ''}
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -496,10 +506,10 @@ export class ModelsLlmSection extends LightElement {
if (mode.type === 'value_set') { if (mode.type === 'value_set') {
return html` return html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Reasoning</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.reasoning.label')}</label>
<select class="form-select form-select-sm" <select class="form-select form-select-sm"
@change=${(e) => setField('reasoning', e.target.value || null)}> @change=${(e) => setField('reasoning', e.target.value || null)}>
<option value="" ?selected=${form.reasoning == null}> off </option> <option value="" ?selected=${form.reasoning == null}>${t('models.reasoning.off')}</option>
${mode.values.map(v => html` ${mode.values.map(v => html`
<option value=${v} ?selected=${form.reasoning === v}>${v}</option> <option value=${v} ?selected=${form.reasoning === v}>${v}</option>
`)} `)}
@@ -515,7 +525,7 @@ export class ModelsLlmSection extends LightElement {
.checked=${enabled} .checked=${enabled}
@change=${(e) => setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} /> @change=${(e) => setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} />
<label class="form-check-label fw-semibold" for="m-reasoning-on" style="font-size:0.82rem"> <label class="form-check-label fw-semibold" for="m-reasoning-on" style="font-size:0.82rem">
Reasoning (thinking) ${t('models.reasoning.thinking')}
</label> </label>
</div> </div>
${enabled ? html` ${enabled ? html`
@@ -535,24 +545,24 @@ export class ModelsLlmSection extends LightElement {
${this._renderReasoning(form, setField, reasoningMode)} ${this._renderReasoning(form, setField, reasoningMode)}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-8"> <div class="col-8">
<label class="form-label fw-semibold" style="font-size:0.82rem">Strength</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.strength')}</label>
<select class="form-select form-select-sm" <select class="form-select form-select-sm"
@change=${(e) => setField('strength', e.target.value)}> @change=${(e) => setField('strength', e.target.value)}>
<option value=""> none </option> <option value="">${t('models.strength.none')}</option>
${STRENGTH_OPTIONS.map(s => html` ${STRENGTH_OPTIONS.map(s => html`
<option value=${s} ?selected=${form.strength === s}>${STRENGTH_LABELS[s]}</option> <option value=${s} ?selected=${form.strength === s}>${({ very_high: t('models.strength.very_high'), high: t('models.strength.high'), average: t('models.strength.average'), low: t('models.strength.low'), very_low: t('models.strength.very_low') })[s]}</option>
`)} `)}
</select> </select>
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.priority')}</label>
<input type="number" class="form-control form-control-sm" .value=${String(form.priority)} min="1" <input type="number" class="form-control form-control-sm" .value=${String(form.priority)} min="1"
@input=${(e) => setField('priority', e.target.value)} /> @input=${(e) => setField('priority', e.target.value)} />
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Scope</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.scope')}</label>
<div class="llm-scope-grid"> <div class="llm-scope-grid">
${SCOPE_OPTIONS.map(s => html` ${SCOPE_OPTIONS.map(s => html`
<div class="form-check"> <div class="form-check">
@@ -568,7 +578,7 @@ export class ModelsLlmSection extends LightElement {
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="m-is-default" <input class="form-check-input" type="checkbox" id="m-is-default"
.checked=${form.is_default} @change=${(e) => setField('is_default', e.target.checked)} /> .checked=${form.is_default} @change=${(e) => setField('is_default', e.target.checked)} />
<label class="form-check-label" for="m-is-default" style="font-size:0.82rem">Default model</label> <label class="form-check-label" for="m-is-default" style="font-size:0.82rem">${t('models.default_model')}</label>
</div> </div>
</div> </div>
`; `;
@@ -580,7 +590,7 @@ export class ModelsLlmSection extends LightElement {
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title">Add Model Choose Provider</div> <div class="llm-modal-title">${t('models.add_model_title')} ${t('models.choose_provider')}</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="llm-provider-grid"> <div class="llm-provider-grid">
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html` ${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html`
@@ -591,7 +601,7 @@ export class ModelsLlmSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -607,27 +617,27 @@ export class ModelsLlmSection extends LightElement {
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title"> <div class="llm-modal-title">
Add Model ${t('models.add_model_title')}
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span> <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div> </div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitDefault(e)}> <form @submit=${(e) => this._submitDefault(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Model ID <span class="text-muted fw-normal">(sent to API)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.model_id')} <span class="text-muted fw-normal">${t('models.label.sent_to_api')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required <input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder="e.g. gpt-4o" placeholder=${t('models.ph.model_id')}
@input=${(e) => this._setField('model_id', e.target.value)} @input=${(e) => this._setField('model_id', e.target.value)}
@change=${(e) => this._fetchDefaultReasoning(e.target.value)} /> @change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.name_alias')} <span class="text-muted fw-normal">${t('models.label.optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'} placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._setField('name', e.target.value)} /> @input=${(e) => this._setField('name', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Extra params <span class="text-muted fw-normal">(JSON, optional)</span> ${t('models.extra_params')} <span class="text-muted fw-normal">${t('models.extra_params_hint')}</span>
</label> </label>
<textarea class="form-control form-control-sm font-monospace" rows="3" <textarea class="form-control form-control-sm font-monospace" rows="3"
.value=${f.extra_params} .value=${f.extra_params}
@@ -636,9 +646,9 @@ export class ModelsLlmSection extends LightElement {
</div> </div>
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)} ${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving' : 'Add model'} ${this._saving ? t('models.saving') : t('models.add_model')}
</button> </button>
</div> </div>
</form> </form>
@@ -671,25 +681,25 @@ export class ModelsLlmSection extends LightElement {
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal llm-modal-wide"> <div class="agent-dialog llm-modal llm-modal-wide">
<div class="llm-modal-title"> <div class="llm-modal-title">
Add Model ${t('models.add_model_title')}
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span> <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div> </div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitCatalog(e)}> <form @submit=${(e) => this._submitCatalog(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Model</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.model_label')}</label>
<input type="text" class="form-control form-control-sm mb-1" <input type="text" class="form-control form-control-sm mb-1"
placeholder="Search models…" placeholder=${t('models.search')}
.value=${this._orSearch} .value=${this._orSearch}
@input=${(e) => { this._orSearch = e.target.value; }} /> @input=${(e) => { this._orSearch = e.target.value; }} />
${this._orLoading ${this._orLoading
? html`<div class="text-muted py-2" style="font-size:0.82rem">Loading models…</div>` ? html`<div class="text-muted py-2" style="font-size:0.82rem">${t('models.loading')}</div>`
: html` : html`
<div class="llm-or-model-list"> <div class="llm-or-model-list">
${filtered.length === 0 ${filtered.length === 0
? html`<div class="text-muted px-2 py-1" style="font-size:0.82rem">No models found</div>` ? html`<div class="text-muted px-2 py-1" style="font-size:0.82rem">${t('models.no_results')}</div>`
: filtered.map(m => html` : filtered.map(m => html`
<div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}" <div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}"
@click=${() => this._setOrField('model_id', m.id)}> @click=${() => this._setOrField('model_id', m.id)}>
@@ -712,17 +722,17 @@ export class ModelsLlmSection extends LightElement {
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.name_alias')} <span class="text-muted fw-normal">${t('models.label.optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${selected?.name || f.model_id || 'same as model ID'} placeholder=${selected?.name || f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._setOrField('name', e.target.value)} /> @input=${(e) => this._setOrField('name', e.target.value)} />
</div> </div>
${supportsMaxTokens ? html` ${supportsMaxTokens ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Max output tokens <span class="text-muted fw-normal">(optional)</span></label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.label.max_output')} <span class="text-muted fw-normal">${t('models.label.max_output_hint')}</span></label>
<input type="number" class="form-control form-control-sm" .value=${f.max_tokens} <input type="number" class="form-control form-control-sm" .value=${f.max_tokens}
placeholder=${selected?.max_completion_tokens ? `up to ${selected.max_completion_tokens.toLocaleString()}` : ''} placeholder=${selected?.max_completion_tokens ? t('models.ph.max_tokens', { n: selected.max_completion_tokens.toLocaleString() }) : ''}
min="1" min="1"
@input=${(e) => this._setOrField('max_tokens', e.target.value)} /> @input=${(e) => this._setOrField('max_tokens', e.target.value)} />
</div> </div>
@@ -731,9 +741,9 @@ export class ModelsLlmSection extends LightElement {
${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)} ${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)}
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}>
${this._saving ? 'Saving' : 'Add model'} ${this._saving ? t('models.saving') : t('models.add_model')}
</button> </button>
</div> </div>
</form> </form>
@@ -751,26 +761,26 @@ export class ModelsLlmSection extends LightElement {
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title"> <div class="llm-modal-title">
Edit ${t('models.edit')}
<span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${m.name}</span> <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${m.name}</span>
</div> </div>
<p class="text-muted mb-3" style="font-size:0.8rem"> <p class="text-muted mb-3" style="font-size:0.8rem">
Model ID and provider cannot be changed. To use a different model, add a new entry. ${t('models.edit_info')}
</p> </p>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitEdit(e)}> <form @submit=${(e) => this._submitEdit(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.name_alias')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${m.model_id || 'model name'} placeholder=${m.model_id || 'model name'}
@input=${(e) => this._setField('name', e.target.value)} /> @input=${(e) => this._setField('name', e.target.value)} />
<div class="form-text" style="font-size:0.75rem">Used to reference this model (e.g. in an agent's <code>client</code>). Must be unique.</div> <div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('models.name_help'))}</div>
</div> </div>
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)} ${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving' : 'Save changes'} ${this._saving ? t('models.saving') : t('models.save_changes')}
</button> </button>
</div> </div>
</form> </form>
@@ -785,18 +795,18 @@ export class ModelsLlmSection extends LightElement {
<div class="llm-page-header"> <div class="llm-page-header">
<div class="llm-header-left"> <div class="llm-header-left">
${this.onback ? html` ${this.onback ? html`
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}> <button class="btn btn-sm btn-outline-secondary back-btn" title=${t('models.back')} @click=${this.onback}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
` : ''} ` : ''}
<div> <div>
<h2 class="llm-page-title">LLM Models</h2> <h2 class="llm-page-title">${t('models.llm.title')}</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span> <span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}
?disabled=${this._providers.length === 0}> ?disabled=${this._providers.length === 0}>
<i class="bi bi-plus-lg me-1"></i>Add <i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button> </button>
</div> </div>
@@ -804,7 +814,7 @@ export class ModelsLlmSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">Add a <strong>Provider</strong> first, then come back here to add models.</p> <p class="mb-0">${t('models.no_providers_llm')}</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -817,9 +827,9 @@ export class ModelsLlmSection extends LightElement {
${this._models.length === 0 && this._providers.length > 0 ? html` ${this._models.length === 0 && this._providers.length > 0 ? html`
<div class="llm-empty-state"> <div class="llm-empty-state">
<i class="bi bi-cpu"></i> <i class="bi bi-cpu"></i>
<p>No models configured yet.</p> <p>${t('models.list_empty_llm')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first model <i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button> </button>
</div> </div>
` : this._models.map((m, i) => this._renderCard(m, i))} ` : this._models.map((m, i) => this._renderCard(m, i))}
+45 -37
View File
@@ -1,5 +1,6 @@
import { html } from 'lit'; import { html } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyTForm() { function emptyTForm() {
return { provider_id: '', model_id: '', name: '', language: '', priority: 100 }; return { provider_id: '', model_id: '', name: '', language: '', priority: 100 };
@@ -35,9 +36,16 @@ export class ModelsTranscribeSection extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
this._load(); this._load();
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
try { try {
const [modelsRes, providersRes] = await Promise.all([ const [modelsRes, providersRes] = await Promise.all([
@@ -115,7 +123,7 @@ export class ModelsTranscribeSection extends LightElement {
// ── Delete ─────────────────────────────────────────────────────────────────── // ── Delete ───────────────────────────────────────────────────────────────────
async _delete(m) { async _delete(m) {
if (!confirm(`Delete transcription model "${m.name}"?`)) return; if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.transcribe.title'), name: m.name }))) return;
try { try {
const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' }); const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -196,23 +204,23 @@ export class ModelsTranscribeSection extends LightElement {
<tr class="llm-row"> <tr class="llm-row">
<td> <td>
${isPlugin ${isPlugin
? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">Plugin</span>` ? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">${t('models.source_plugin')}</span>`
: html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">Cloud</span>`} : html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">${t('models.source_cloud')}</span>`}
</td> </td>
<td><span class="fw-semibold">${m.name}</span></td> <td><span class="fw-semibold">${m.name}</span></td>
<td class="text-muted" style="font-size:0.8rem">${isPlugin ? '—' : m.provider_name}</td> <td class="text-muted" style="font-size:0.8rem">${isPlugin ? '—' : m.provider_name}</td>
<td class="llm-model" title=${m.model_id}>${m.model_id}</td> <td class="llm-model" title=${m.model_id}>${m.model_id}</td>
<td style="font-size:0.8rem">${m.language ?? html`<span style="opacity:0.35">auto</span>`}</td> <td style="font-size:0.8rem">${m.language ?? html`<span style="opacity:0.35">${t('models.language_auto')}</span>`}</td>
<td class="llm-actions"> <td class="llm-actions">
${isPlugin ? html` ${isPlugin ? html`
<span class="text-muted" style="font-size:0.75rem" title="Managed by plugin"> <span class="text-muted" style="font-size:0.75rem" title=${t('models.managed_plugin')}>
<i class="bi bi-lock"></i> <i class="bi bi-lock"></i>
</span> </span>
` : html` ` : html`
<button class="btn btn-sm btn-link" title="Edit" @click=${() => this._openEdit(m)}> <button class="btn btn-sm btn-link" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-link text-danger" title="Delete" @click=${() => this._delete(m)}> <button class="btn btn-sm btn-link text-danger" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
`} `}
@@ -228,7 +236,7 @@ export class ModelsTranscribeSection extends LightElement {
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title">Add Transcription Model Choose Provider</div> <div class="llm-modal-title">${t('models.add_model_provider', { type: t('models.hub.card.transcribe.title') })}</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="llm-provider-grid"> <div class="llm-provider-grid">
${tProviders.map(p => html` ${tProviders.map(p => html`
@@ -239,7 +247,7 @@ export class ModelsTranscribeSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -252,12 +260,12 @@ export class ModelsTranscribeSection extends LightElement {
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title"> <div class="llm-modal-title">
Add Transcription Model ${t('models.add_model_type', { type: t('models.hub.card.transcribe.title') })}
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span> <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div> </div>
${this._loadingModels ? html` ${this._loadingModels ? html`
<div class="text-center py-4 text-muted" style="font-size:0.85rem"> <div class="text-center py-4 text-muted" style="font-size:0.85rem">
<div class="spinner-border spinner-border-sm me-2"></div>Loading models <div class="spinner-border spinner-border-sm me-2"></div>${t('models.loading')}
</div> </div>
` : html` ` : html`
<div class="tts-model-pick-list"> <div class="tts-model-pick-list">
@@ -272,9 +280,9 @@ export class ModelsTranscribeSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
Enter model ID manually ${t('models.enter_id')}
</button> </button>
</div> </div>
`} `}
@@ -287,8 +295,8 @@ export class ModelsTranscribeSection extends LightElement {
const f = this._form; const f = this._form;
const p = this._provider; const p = this._provider;
const title = isEdit const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>` ? html`${t('models.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add Transcription Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`; : html`${t('models.add_model_type', { type: t('models.hub.card.transcribe.title') })} <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
@@ -299,44 +307,44 @@ export class ModelsTranscribeSection extends LightElement {
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Model ID <span class="text-muted fw-normal">(sent to API)</span> ${t('models.model_id')} <span class="text-muted fw-normal">${t('models.label.sent_to_api')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required <input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder="e.g. openai/whisper-1" placeholder=${t('models.ph.model_id_transcribe')}
?disabled=${isEdit} ?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} /> @input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''} ${isEdit ? html`<div class="form-text">${t('models.form.model_lock')}</div>` : ''}
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Name / Alias <span class="text-muted fw-normal">(optional)</span> ${t('models.name_alias')} <span class="text-muted fw-normal">${t('models.label.optional')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'} placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._form = { ...this._form, name: e.target.value }} /> @input=${(e) => this._form = { ...this._form, name: e.target.value }} />
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-8"> <div class="col-8">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Language <span class="text-muted fw-normal">(BCP-47, optional)</span> ${t('models.language_col')} <span class="text-muted fw-normal">${t('models.label.bcp47')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.language} <input type="text" class="form-control form-control-sm" .value=${f.language}
placeholder="e.g. it, en — leave blank for auto-detect" placeholder=${t('models.ph.language')}
@input=${(e) => this._form = { ...this._form, language: e.target.value }} /> @input=${(e) => this._form = { ...this._form, language: e.target.value }} />
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.priority')}</label>
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1" <input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} /> @input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
</div> </div>
</div> </div>
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving' : isEdit ? 'Save changes' : 'Add model'} ${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button> </button>
</div> </div>
</form> </form>
@@ -356,14 +364,14 @@ export class ModelsTranscribeSection extends LightElement {
<div class="llm-page-header"> <div class="llm-page-header">
<div class="llm-header-left"> <div class="llm-header-left">
${this.onback ? html` ${this.onback ? html`
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}> <button class="btn btn-sm btn-outline-secondary back-btn" title=${t('models.back')} @click=${this.onback}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
` : ''} ` : ''}
<h2 class="llm-page-title">Transcription Models</h2> <h2 class="llm-page-title">${t('models.transcribe.title')}</h2>
</div> </div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
<i class="bi bi-plus-lg me-1"></i>Add <i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button> </button>
</div> </div>
@@ -371,7 +379,7 @@ export class ModelsTranscribeSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">No provider supports transcription yet. Add an <strong>OpenAI</strong> or <strong>OpenRouter</strong> provider first.</p> <p class="mb-0">${t('models.no_providers_transcribe')}</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -382,20 +390,20 @@ export class ModelsTranscribeSection extends LightElement {
${this._models.length === 0 ? html` ${this._models.length === 0 ? html`
<p class="text-muted" style="font-size:0.9rem"> <p class="text-muted" style="font-size:0.9rem">
No transcription models configured. ${t('models.list_empty_transcribe')}
${canAdd ? html`Click <strong>Add</strong> to add a cloud model.` : ''} ${canAdd ? html` ${t('models.list_empty_add_hint')}` : ''}
Activate the <strong>Whisper Local</strong> plugin for on-device transcription. ${t('models.list_empty_whisper')}
</p> </p>
` : html` ` : html`
<div class="table-responsive"> <div class="table-responsive">
<table class="table llm-table mb-0"> <table class="table llm-table mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:5rem">Source</th> <th style="width:5rem">${t('models.source')}</th>
<th>Name</th> <th>${t('models.name_col')}</th>
<th>Provider</th> <th>${t('models.provider_col')}</th>
<th>Model ID</th> <th>${t('models.model_id_col')}</th>
<th>Language</th> <th>${t('models.language_col')}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
+55 -52
View File
@@ -1,5 +1,7 @@
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint. // Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint.
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']; const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
@@ -38,9 +40,16 @@ export class ModelsTtsSection extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
this._load(); this._load();
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() { async _load() {
try { try {
const [modelsRes, providersRes] = await Promise.all([ const [modelsRes, providersRes] = await Promise.all([
@@ -123,7 +132,7 @@ export class ModelsTtsSection extends LightElement {
// ── Delete ──────────────────────────────────────────────────────────────────── // ── Delete ────────────────────────────────────────────────────────────────────
async _delete(m) { async _delete(m) {
if (!confirm(`Delete TTS model "${m.name}"?`)) return; if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.tts.title'), name: m.name }))) return;
try { try {
const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' }); const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -202,19 +211,19 @@ export class ModelsTtsSection extends LightElement {
<div class="llm-card"> <div class="llm-card">
<div class="llm-card-row1"> <div class="llm-card-row1">
${isPlugin ${isPlugin
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>` ? html`<span class="ig-source-badge ig-source-plugin">${t('models.source_plugin')}</span>`
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`} : html`<span class="ig-source-badge ig-source-cloud">${t('models.source_cloud')}</span>`}
<span class="llm-card-name">${m.name}</span> <span class="llm-card-name">${m.name}</span>
<div class="llm-card-actions"> <div class="llm-card-actions">
${isPlugin ? html` ${isPlugin ? html`
<span class="llm-btn-icon" title="Managed by plugin" style="cursor:default;opacity:0.4"> <span class="llm-btn-icon" title=${t('models.managed_plugin')} style="cursor:default;opacity:0.4">
<i class="bi bi-lock"></i> <i class="bi bi-lock"></i>
</span> </span>
` : html` ` : html`
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}> <button class="llm-btn-icon llm-btn-edit" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}> <button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
`} `}
@@ -224,9 +233,9 @@ export class ModelsTtsSection extends LightElement {
<div class="llm-card-row2"> <div class="llm-card-row2">
${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''} ${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''}
<span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span> <span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span>
${m.voice_id ? html`<span class="llm-model-id" style="opacity:0.6" title="Voice ID">${m.voice_id}</span>` : ''} ${m.voice_id ? html`<span class="llm-model-id" style="opacity:0.6" title=${t('models.label.voice_id')}>${m.voice_id}</span>` : ''}
${m.response_format ? html`<span class="llm-model-id" style="opacity:0.6" title="Response format">${m.response_format}</span>` : ''} ${m.response_format ? html`<span class="llm-model-id" style="opacity:0.6" title=${t('models.label.response_fmt')}>${m.response_format}</span>` : ''}
${!isPlugin ? html`<span class="ig-priority-tag" title="Priority">#${m.priority}</span>` : ''} ${!isPlugin ? html`<span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>` : ''}
</div> </div>
${m.description ? html` ${m.description ? html`
@@ -249,7 +258,7 @@ export class ModelsTtsSection extends LightElement {
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title">Add TTS Model Choose Provider</div> <div class="llm-modal-title">${t('models.add_model_provider', { type: t('models.hub.card.tts.title') })}</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="llm-provider-grid"> <div class="llm-provider-grid">
${ttsProviders.map(p => html` ${ttsProviders.map(p => html`
@@ -260,7 +269,7 @@ export class ModelsTtsSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -275,12 +284,12 @@ export class ModelsTtsSection extends LightElement {
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal"> <div class="agent-dialog llm-modal">
<div class="llm-modal-title"> <div class="llm-modal-title">
Add TTS Model ${t('models.add_model_type', { type: t('models.hub.card.tts.title') })}
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span> <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div> </div>
${this._loadingModels ? html` ${this._loadingModels ? html`
<div class="text-center py-4 text-muted" style="font-size:0.85rem"> <div class="text-center py-4 text-muted" style="font-size:0.85rem">
<div class="spinner-border spinner-border-sm me-2"></div>Loading models <div class="spinner-border spinner-border-sm me-2"></div>${t('models.loading')}
</div> </div>
` : html` ` : html`
<div class="tts-model-pick-list"> <div class="tts-model-pick-list">
@@ -289,7 +298,7 @@ export class ModelsTtsSection extends LightElement {
<div class="tts-model-pick-row1"> <div class="tts-model-pick-row1">
<span class="tts-model-pick-name">${m.name}</span> <span class="tts-model-pick-name">${m.name}</span>
${m.cost_factor != null ? html` ${m.cost_factor != null ? html`
<span class="tts-model-pick-cost" title="Cost multiplier relative to base rate">×${m.cost_factor.toFixed(1)}</span> <span class="tts-model-pick-cost" title=${t('models.tts.cost_multiplier')}>×${m.cost_factor.toFixed(1)}</span>
` : ''} ` : ''}
</div> </div>
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''} ${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
@@ -300,9 +309,9 @@ export class ModelsTtsSection extends LightElement {
`)} `)}
</div> </div>
<div class="agent-dialog-actions mt-3"> <div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}> <button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
Enter model ID manually ${t('models.enter_id')}
</button> </button>
</div> </div>
`} `}
@@ -317,8 +326,8 @@ export class ModelsTtsSection extends LightElement {
const f = this._form; const f = this._form;
const p = this._provider; const p = this._provider;
const title = isEdit const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>` ? html`${t('models.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add TTS Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`; : html`${t('models.add_model_type', { type: t('models.hub.card.tts.title') })} <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
return html` return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}> <div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
@@ -329,82 +338,76 @@ export class ModelsTtsSection extends LightElement {
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Model ID <span class="text-muted fw-normal">(sent to API)</span> ${t('models.model_id')} <span class="text-muted fw-normal">${t('models.label.sent_to_api')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required <input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder="e.g. tts-1-hd" placeholder=${t('models.ph.model_id_tts')}
?disabled=${isEdit} ?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} /> @input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''} ${isEdit ? html`<div class="form-text">${t('models.form.model_lock')}</div>` : ''}
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Voice ID <span class="text-muted fw-normal">(optional required for ElevenLabs)</span> ${t('models.label.voice_id')} <span class="text-muted fw-normal">${t('models.label.voice_id_hint')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.voice_id} <input type="text" class="form-control form-control-sm" .value=${f.voice_id}
placeholder="e.g. alloy, Kore, 21m00Tcm4TlvDq8ikWAM" placeholder=${t('models.ph.voice_id')}
@input=${(e) => this._form = { ...this._form, voice_id: e.target.value }} /> @input=${(e) => this._form = { ...this._form, voice_id: e.target.value }} />
<div class="form-text"> <div class="form-text">${unsafeHTML(t('models.form.voice_hint'))}</div>
Speaker voice. OpenAI: <code>alloy</code>/<code>echo</code>/<code>nova</code> (default <code>alloy</code> if empty);
Gemini: <code>Kore</code>/<code>Puck</code>/<code>Zephyr</code>; ElevenLabs: the voice ID.
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.name_alias')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.name} <input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'} placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._form = { ...this._form, name: e.target.value }} /> @input=${(e) => this._form = { ...this._form, name: e.target.value }} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Description <span class="text-muted fw-normal">(optional)</span> ${t('models.label.description')} <span class="text-muted fw-normal">${t('models.label.description_hint')}</span>
</label> </label>
<input type="text" class="form-control form-control-sm" .value=${f.description} <input type="text" class="form-control form-control-sm" .value=${f.description}
placeholder="e.g. High quality, slow — best for long responses" placeholder=${t('models.ph.description')}
@input=${(e) => this._form = { ...this._form, description: e.target.value }} /> @input=${(e) => this._form = { ...this._form, description: e.target.value }} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Instructions <span class="text-muted fw-normal">(optional shown to LLM)</span> ${t('models.label.instructions')} <span class="text-muted fw-normal">${t('models.label.instructions_hint')}</span>
</label> </label>
<textarea class="form-control form-control-sm" rows="3" .value=${f.instructions} <textarea class="form-control form-control-sm" rows="3" .value=${f.instructions}
placeholder="e.g. Speak in a calm, neutral tone. Pause slightly between sentences." placeholder=${t('models.ph.instructions')}
@input=${(e) => this._form = { ...this._form, instructions: e.target.value }}></textarea> @input=${(e) => this._form = { ...this._form, instructions: e.target.value }}></textarea>
<div class="form-text">Voice/tone guidance injected into the LLM system prompt when this model is active.</div> <div class="form-text">${t('models.form.instructions_hint')}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem"> <label class="form-label fw-semibold" style="font-size:0.82rem">
Response format <span class="text-muted fw-normal">(optional)</span> ${t('models.label.response_fmt')} <span class="text-muted fw-normal">${t('models.label.response_fmt_hint')}</span>
</label> </label>
<select class="form-select form-select-sm" .value=${f.response_format} <select class="form-select form-select-sm" .value=${f.response_format}
@change=${(e) => this._form = { ...this._form, response_format: e.target.value }}> @change=${(e) => this._form = { ...this._form, response_format: e.target.value }}>
<option value="">Provider default (mp3)</option> <option value="">${t('models.form.response_default')}</option>
${TTS_RESPONSE_FORMATS.map(fmt => html` ${TTS_RESPONSE_FORMATS.map(fmt => html`
<option value=${fmt}>${fmt}</option> <option value=${fmt}>${fmt}</option>
`)} `)}
</select> </select>
<div class="form-text"> <div class="form-text">${unsafeHTML(t('models.form.response_hint'))}</div>
Audio format requested from the provider. Leave empty unless the model requires
a specific one e.g. Gemini TTS only accepts <code>pcm</code>.
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.priority')}</label>
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1" <input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} /> @input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
<div class="form-text">Lower number = used first. Default: 100.</div> <div class="form-text">${t('models.priority_hint_short')}</div>
</div> </div>
<div class="agent-dialog-actions"> <div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button> <button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving' : isEdit ? 'Save changes' : 'Add model'} ${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button> </button>
</div> </div>
</form> </form>
@@ -424,17 +427,17 @@ export class ModelsTtsSection extends LightElement {
<div class="llm-page-header"> <div class="llm-page-header">
<div class="llm-header-left"> <div class="llm-header-left">
${this.onback ? html` ${this.onback ? html`
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}> <button class="btn btn-sm btn-outline-secondary back-btn" title=${t('models.back')} @click=${this.onback}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
` : ''} ` : ''}
<div> <div>
<h2 class="llm-page-title">Text-to-Speech Models</h2> <h2 class="llm-page-title">${t('models.tts.title')}</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span> <span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
<i class="bi bi-plus-lg me-1"></i>Add <i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button> </button>
</div> </div>
@@ -442,7 +445,7 @@ export class ModelsTtsSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">No provider supports TTS yet. Add an <strong>OpenAI</strong> provider first.</p> <p class="mb-0">${t('models.no_providers_tts')}</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -451,7 +454,7 @@ export class ModelsTtsSection extends LightElement {
<div class="agent-info-banner"> <div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div> <div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body"> <div class="agent-info-banner-body">
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only managed automatically by the plugin that registered them.</p> <p class="mb-0">${t('models.readonly_plugin')}</p>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -464,10 +467,10 @@ export class ModelsTtsSection extends LightElement {
${this._models.length === 0 ? html` ${this._models.length === 0 ? html`
<div class="llm-empty-state"> <div class="llm-empty-state">
<i class="bi bi-volume-up"></i> <i class="bi bi-volume-up"></i>
<p>No TTS models configured.</p> <p>${t('models.list_empty_tts')}</p>
${canAdd ? html` ${canAdd ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first model <i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button> </button>
` : ''} ` : ''}
</div> </div>
+63 -20
View File
@@ -1,7 +1,8 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; 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() { static get properties() {
return { return {
@@ -10,6 +11,8 @@ export class ProfilePage extends LightElement {
_displayName: { state: true }, _displayName: { state: true },
_savingName: { state: true }, _savingName: { state: true },
_nameMsg: { state: true }, _nameMsg: { state: true },
_locale: { state: true },
_localeMsg: { state: true },
_pwCurrent: { state: true }, _pwCurrent: { state: true },
_pwNew: { state: true }, _pwNew: { state: true },
_pwConfirm: { state: true }, _pwConfirm: { state: true },
@@ -25,6 +28,8 @@ export class ProfilePage extends LightElement {
this._displayName = ''; this._displayName = '';
this._savingName = false; this._savingName = false;
this._nameMsg = null; this._nameMsg = null;
this._locale = '';
this._localeMsg = null;
this._pwCurrent = ''; this._pwCurrent = '';
this._pwNew = ''; this._pwNew = '';
this._pwConfirm = ''; this._pwConfirm = '';
@@ -47,6 +52,7 @@ export class ProfilePage extends LightElement {
if (res.ok) { if (res.ok) {
this._me = await res.json(); this._me = await res.json();
this._displayName = this._me.display_name ?? ''; this._displayName = this._me.display_name ?? '';
this._locale = this._me.locale ?? '';
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -62,7 +68,7 @@ export class ProfilePage extends LightElement {
body: JSON.stringify({ display_name: this._displayName.trim() || null }), body: JSON.stringify({ display_name: this._displayName.trim() || null }),
}); });
if (!res.ok) throw new Error(await res.text()); 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(); await this._load();
} catch (e) { } catch (e) {
this._nameMsg = { type: 'err', text: e.message }; 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() { async _savePassword() {
if (this._savingPw) return; if (this._savingPw) return;
this._pwMsg = null; this._pwMsg = null;
if (this._pwNew.length < 4) { 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; return;
} }
if (this._pwNew !== this._pwConfirm) { if (this._pwNew !== this._pwConfirm) {
this._pwMsg = { type: 'err', text: 'Passwords do not match.' }; this._pwMsg = { type: 'err', text: t('profile.pw.mismatch') };
return; return;
} }
this._savingPw = true; this._savingPw = true;
@@ -93,7 +118,7 @@ export class ProfilePage extends LightElement {
}), }),
}); });
if (!res.ok) throw new Error(await res.text()); 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._pwCurrent = '';
this._pwNew = ''; this._pwNew = '';
this._pwConfirm = ''; this._pwConfirm = '';
@@ -107,71 +132,89 @@ export class ProfilePage extends LightElement {
render() { render() {
if (!this._open) return nothing; if (!this._open) return nothing;
const me = this._me; const me = this._me;
const defaultLabel = LOCALES.find(l => l.id === me?.default_locale)?.label ?? me?.default_locale ?? 'English';
return html` return html`
<div class="um-page" style="display:flex"> <div class="um-page" style="display:flex">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>Profile</h2> <h2 class="um-title"><i class="bi bi-person-circle me-2"></i>${t('profile.title')}</h2>
</div> </div>
<div style="padding:0 24px 48px;max-width:480px"> <div style="padding:0 24px 48px;max-width:480px">
${me ? html` ${me ? html`
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px"> <div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Account</h6> <h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.account')}</h6>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Username</label> <label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.username')}</label>
<input class="form-control" .value=${me.username} disabled /> <input class="form-control" .value=${me.username} disabled />
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Role</label> <label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.role')}</label>
<input class="form-control" .value=${me.role_id} disabled /> <input class="form-control" .value=${me.role_id} disabled />
</div> </div>
</div> </div>
</div> </div>
` : nothing} ` : nothing}
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px"> <div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Display name</h6> <h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.name')}</h6>
<div class="mb-3"> <div class="mb-3">
<input class="form-control" placeholder="Your name" <input class="form-control" placeholder=${t('profile.name.ph')}
.value=${this._displayName} .value=${this._displayName}
@input=${e => this._displayName = e.target.value} /> @input=${e => this._displayName = e.target.value} />
</div> </div>
${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing} ${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}> <button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}>
${this._savingName ? 'Saving' : 'Save'} ${this._savingName ? t('common.saving') : t('common.save')}
</button> </button>
</div> </div>
</div> </div>
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px"> <div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Change password</h6> <h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.language')}</h6>
<div class="mb-3">
<select class="form-select"
.value=${this._locale}
@change=${e => this._changeLocale(e.target.value)}>
<option value="">${t('profile.language.default', { locale: defaultLabel })}</option>
${LOCALES.map(l => html`
<option value=${l.id} ?selected=${this._locale === l.id}>${l.label}</option>
`)}
</select>
</div>
${this._localeMsg ? html`<div class="alert alert-${this._localeMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._localeMsg.text}</div>` : nothing}
</div>
</div>
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.pw')}</h6>
${me?.role_id === 'admin' || me?.encrypted ? html` ${me?.role_id === 'admin' || me?.encrypted ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Current password</label> <label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.current')}</label>
<input type="password" class="form-control" autocomplete="current-password" <input type="password" class="form-control" autocomplete="current-password"
.value=${this._pwCurrent} .value=${this._pwCurrent}
@input=${e => this._pwCurrent = e.target.value} /> @input=${e => this._pwCurrent = e.target.value} />
</div> </div>
` : nothing} ` : nothing}
<div class="mb-3"> <div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">New password</label> <label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.new')}</label>
<input type="password" class="form-control" autocomplete="new-password" <input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwNew} .value=${this._pwNew}
@input=${e => this._pwNew = e.target.value} /> @input=${e => this._pwNew = e.target.value} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Confirm new password</label> <label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.confirm')}</label>
<input type="password" class="form-control" autocomplete="new-password" <input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwConfirm} .value=${this._pwConfirm}
@input=${e => this._pwConfirm = e.target.value} /> @input=${e => this._pwConfirm = e.target.value} />
</div> </div>
${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing} ${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}> <button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}>
${this._savingPw ? 'Changing' : 'Change password'} ${this._savingPw ? t('common.saving') : t('profile.pw.submit')}
</button> </button>
</div> </div>
</div> </div>
+33 -25
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js'; import { LightElement, renderMarkdown } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { formatDate } from '../tasks/utils.js'; import { formatDate } from '../tasks/utils.js';
export class ProjectBoardSection extends LightElement { export class ProjectBoardSection extends LightElement {
@@ -35,7 +36,14 @@ export class ProjectBoardSection extends LightElement {
this._activeTab = 'tickets'; this._activeTab = 'tickets';
} }
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() { disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback(); super.disconnectedCallback();
this._stopPolling(); this._stopPolling();
} }
@@ -166,7 +174,7 @@ export class ProjectBoardSection extends LightElement {
} }
async _deleteTicket(ticket) { async _deleteTicket(ticket) {
if (!confirm(`Delete ticket "${ticket.title}"?`)) return; if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return;
try { try {
const res = await fetch( const res = await fetch(
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`, `/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
@@ -272,7 +280,7 @@ export class ProjectBoardSection extends LightElement {
${ticket.status === 'todo' ? html` ${ticket.status === 'todo' ? html`
<button class="btn btn-sm btn-outline-primary ticket-card-btn" <button class="btn btn-sm btn-outline-primary ticket-card-btn"
@click=${() => this._startTicket(ticket)}> @click=${() => this._startTicket(ticket)}>
<i class="bi bi-play-fill me-1"></i>Start <i class="bi bi-play-fill me-1"></i>${t('project_board.ticket.start')}
</button> </button>
<button class="btn btn-sm btn-outline-danger ticket-card-btn" <button class="btn btn-sm btn-outline-danger ticket-card-btn"
@click=${() => this._deleteTicket(ticket)}> @click=${() => this._deleteTicket(ticket)}>
@@ -281,7 +289,7 @@ export class ProjectBoardSection extends LightElement {
` : nothing} ` : nothing}
${isRunning ? html` ${isRunning ? html`
<span class="ticket-card-running-label">Running</span> <span class="ticket-card-running-label">${t('project_board.ticket.running')}</span>
${ticket.session_id != null ? html` ${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}" class="ticket-card-session-link"> <a href="#session/${ticket.session_id}" class="ticket-card-session-link">
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id} <i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
@@ -292,12 +300,12 @@ export class ProjectBoardSection extends LightElement {
${isCompleted ? html` ${isCompleted ? html`
<button class="btn btn-sm btn-outline-secondary ticket-card-btn" <button class="btn btn-sm btn-outline-secondary ticket-card-btn"
@click=${() => this._resetTicket(ticket)}> @click=${() => this._resetTicket(ticket)}>
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset <i class="bi bi-arrow-counterclockwise me-1"></i>${t('project_board.ticket.reset')}
</button> </button>
<button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}" <button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
@click=${() => this._toggleExpand(ticket.id)}> @click=${() => this._toggleExpand(ticket.id)}>
<i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i> <i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i>
${isDone ? 'Result' : 'Error'} ${isDone ? t('project_board.ticket.result') : t('project_board.ticket.error')}
</button> </button>
${ticket.session_id != null ? html` ${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}" <a href="#session/${ticket.session_id}"
@@ -312,9 +320,9 @@ export class ProjectBoardSection extends LightElement {
<div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}"> <div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}">
${isDone ${isDone
? html`<div class="ticket-result-markdown copilot-markdown"> ? html`<div class="ticket-result-markdown copilot-markdown">
${unsafeHTML(renderMarkdown(ticket.result ?? '(no output)'))} ${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))}
</div>` </div>`
: html`<pre class="ticket-result-error">${ticket.error ?? '(no error message)'}</pre>`} : html`<pre class="ticket-result-error">${ticket.error ?? t('project_board.ticket.no_error')}</pre>`}
</div> </div>
` : nothing} ` : nothing}
</div> </div>
@@ -341,7 +349,7 @@ export class ProjectBoardSection extends LightElement {
<button <button
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}" class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
@click=${() => { this._activeTab = 'tickets'; }}> @click=${() => { this._activeTab = 'tickets'; }}>
<i class="bi bi-card-list me-1"></i>Tickets <i class="bi bi-card-list me-1"></i>${t('project_board.tab.tickets')}
</button> </button>
</div> </div>
`; `;
@@ -351,9 +359,9 @@ export class ProjectBoardSection extends LightElement {
const { running, todo, completed } = this._groupTickets(); const { running, todo, completed } = this._groupTickets();
return html` return html`
<div class="ticket-list"> <div class="ticket-list">
${this._renderSection('Running', 'activity', 'ticket-section-header--running', running, 'No tickets running')} ${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))}
${this._renderSection('Todo', 'circle', '', todo, 'No tickets to do')} ${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))}
${this._renderSection('Completed', 'check-circle', 'ticket-section-header--completed', completed, 'No completed tickets')} ${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))}
</div> </div>
`; `;
} }
@@ -364,7 +372,7 @@ export class ProjectBoardSection extends LightElement {
<div class="agent-dialog agent-dialog--ticket"> <div class="agent-dialog agent-dialog--ticket">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi bi-card-text"></i> <i class="bi bi-card-text"></i>
<span style="font-weight:600">New Ticket</span> <span style="font-weight:600">${t('project_board.modal.title')}</span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem" <button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._modal = null}> @click=${() => this._modal = null}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
@@ -377,21 +385,21 @@ export class ProjectBoardSection extends LightElement {
<form @submit=${e => this._createTicket(e)}> <form @submit=${e => this._createTicket(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Title</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.title_label')}</label>
<input type="text" class="form-control form-control-sm" required <input type="text" class="form-control form-control-sm" required
placeholder="What needs to be done" placeholder=${t('project_board.modal.title_ph')}
.value=${this._form.title} .value=${this._form.title}
@input=${e => this._form = { ...this._form, title: e.target.value }} /> @input=${e => this._form = { ...this._form, title: e.target.value }} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description / Prompt</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.desc_label')}</label>
<textarea class="form-control form-control-sm" rows="4" <textarea class="form-control form-control-sm" rows="4"
placeholder="Detailed instructions for the agent…" placeholder=${t('project_board.modal.desc_ph')}
.value=${this._form.description} .value=${this._form.description}
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea> @input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.agent')}</label>
<select class="form-select form-select-sm" <select class="form-select form-select-sm"
.value=${this._form.agent_id} .value=${this._form.agent_id}
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}> @change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
@@ -401,11 +409,11 @@ export class ProjectBoardSection extends LightElement {
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Security Group</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.security_group')}</label>
<select class="form-select form-select-sm" <select class="form-select form-select-sm"
.value=${this._form.security_group} .value=${this._form.security_group}
@change=${e => this._form = { ...this._form, security_group: e.target.value }}> @change=${e => this._form = { ...this._form, security_group: e.target.value }}>
<option value=""> inherit from project </option> <option value="">${t('project_board.modal.inherit')}</option>
${this._groups.map(g => html` ${this._groups.map(g => html`
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option> <option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
`)} `)}
@@ -413,11 +421,11 @@ export class ProjectBoardSection extends LightElement {
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem"> <div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
@click=${() => this._modal = null}>Cancel</button> @click=${() => this._modal = null}>${t('project_board.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('project_board.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>Create`} : html`<i class="bi bi-check-lg me-1"></i>${t('project_board.modal.create')}`}
</button> </button>
</div> </div>
</form> </form>
@@ -440,7 +448,7 @@ export class ProjectBoardSection extends LightElement {
<div class="project-page-header"> <div class="project-page-header">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left me-1"></i>Projects <i class="bi bi-arrow-left me-1"></i>${t('project_board.back')}
</button> </button>
<h2 class="project-page-title"> <h2 class="project-page-title">
<i class="bi bi-folder2"></i>${this._project.name} <i class="bi bi-folder2"></i>${this._project.name}
@@ -448,11 +456,11 @@ export class ProjectBoardSection extends LightElement {
</div> </div>
<div style="display:flex;gap:0.5rem"> <div style="display:flex;gap:0.5rem">
<button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}> <button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}>
<i class="bi bi-chat-dots me-1"></i>Open Chat <i class="bi bi-chat-dots me-1"></i>${t('project_board.open_chat')}
</button> </button>
<button class="btn btn-sm btn-primary" <button class="btn btn-sm btn-primary"
@click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}> @click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}>
<i class="bi bi-plus-lg me-1"></i>New Ticket <i class="bi bi-plus-lg me-1"></i>${t('project_board.new_ticket')}
</button> </button>
</div> </div>
</div> </div>
+29 -17
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js'; import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { formatDate } from '../tasks/utils.js'; import { formatDate } from '../tasks/utils.js';
export class ProjectListSection extends LightElement { export class ProjectListSection extends LightElement {
@@ -20,6 +21,17 @@ export class ProjectListSection extends LightElement {
this._error = null; 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();
}
_emptyForm() { _emptyForm() {
return { name: '', path: '', description: '' }; return { name: '', path: '', description: '' };
} }
@@ -76,7 +88,7 @@ export class ProjectListSection extends LightElement {
} }
async _delete(project) { async _delete(project) {
if (!confirm(`Delete project "${project.name}"?\nAll tickets will also be deleted.`)) return; if (!confirm(t('projects.confirm.delete', { name: project.name }))) return;
try { try {
const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' }); const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -104,7 +116,7 @@ export class ProjectListSection extends LightElement {
<div class="agent-dialog"> <div class="agent-dialog">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi bi-kanban"></i> <i class="bi bi-kanban"></i>
<span style="font-weight:600">${isEdit ? 'Edit Project' : 'New Project'}</span> <span style="font-weight:600">${isEdit ? t('projects.modal.title_edit') : t('projects.modal.title_new')}</span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem" <button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._closeModal()}> @click=${() => this._closeModal()}>
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
@@ -117,33 +129,33 @@ export class ProjectListSection extends LightElement {
<form @submit=${e => this._submit(e)}> <form @submit=${e => this._submit(e)}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.name')}</label>
<input type="text" class="form-control form-control-sm" required <input type="text" class="form-control form-control-sm" required
placeholder="My Project" placeholder=${t('projects.modal.name_ph')}
.value=${this._form.name} .value=${this._form.name}
@input=${e => this._setField('name', e.target.value)} /> @input=${e => this._setField('name', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.path')}</label>
<input type="text" class="form-control form-control-sm" required <input type="text" class="form-control form-control-sm" required
placeholder="/path/to/project" placeholder=${t('projects.modal.path_ph')}
.value=${this._form.path} .value=${this._form.path}
@input=${e => this._setField('path', e.target.value)} /> @input=${e => this._setField('path', e.target.value)} />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description</label> <label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.desc')}</label>
<textarea class="form-control form-control-sm" rows="2" <textarea class="form-control form-control-sm" rows="2"
placeholder="What this project is about" placeholder=${t('projects.modal.desc_ph')}
.value=${this._form.description} .value=${this._form.description}
@input=${e => this._setField('description', e.target.value)}></textarea> @input=${e => this._setField('description', e.target.value)}></textarea>
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem"> <div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
@click=${() => this._closeModal()}>Cancel</button> @click=${() => this._closeModal()}>${t('projects.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}> <button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('projects.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save' : 'Create'}`} : html`<i class="bi bi-check-lg me-1"></i>${isEdit ? t('projects.modal.save') : t('projects.modal.create')}`}
</button> </button>
</div> </div>
</form> </form>
@@ -158,11 +170,11 @@ export class ProjectListSection extends LightElement {
<div class="project-card-header"> <div class="project-card-header">
<div class="project-card-title">${project.name}</div> <div class="project-card-title">${project.name}</div>
<div class="project-card-actions" @click=${e => e.stopPropagation()}> <div class="project-card-actions" @click=${e => e.stopPropagation()}>
<button class="project-card-icon-btn" title="Edit" <button class="project-card-icon-btn" title=${t('projects.action.edit')}
@click=${() => this._openEdit(project)}> @click=${() => this._openEdit(project)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="project-card-icon-btn project-card-icon-btn--danger" title="Delete" <button class="project-card-icon-btn project-card-icon-btn--danger" title=${t('projects.action.delete')}
@click=${() => this._delete(project)}> @click=${() => this._delete(project)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@@ -172,7 +184,7 @@ export class ProjectListSection extends LightElement {
${project.description ${project.description
? html`<div class="project-card-desc">${project.description}</div>` ? html`<div class="project-card-desc">${project.description}</div>`
: nothing} : nothing}
<div class="project-card-meta">Updated ${formatDate(project.updated_at)}</div> <div class="project-card-meta">${t('projects.card.updated')} ${formatDate(project.updated_at)}</div>
</div> </div>
`; `;
} }
@@ -181,9 +193,9 @@ export class ProjectListSection extends LightElement {
return html` return html`
<div class="project-page"> <div class="project-page">
<div class="project-page-header"> <div class="project-page-header">
<h2 class="project-page-title"><i class="bi bi-kanban"></i> Projects</h2> <h2 class="project-page-title"><i class="bi bi-kanban"></i> ${t('projects.title')}</h2>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}> <button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>New Project <i class="bi bi-plus-lg me-1"></i>${t('projects.btn.new')}
</button> </button>
</div> </div>
@@ -194,7 +206,7 @@ export class ProjectListSection extends LightElement {
${this._projects.length === 0 ? html` ${this._projects.length === 0 ? html`
<div class="task-empty"> <div class="task-empty">
<i class="bi bi-kanban"></i> <i class="bi bi-kanban"></i>
<p>No projects yet. Create one to get started.</p> <p>${t('projects.empty')}</p>
</div> </div>
` : html` ` : html`
<div class="project-grid"> <div class="project-grid">
+65 -29
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const ADMIN_ID = 'admin'; const ADMIN_ID = 'admin';
@@ -26,6 +28,8 @@ export class RolesPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'roles'; this._open = e.detail.page === 'roles';
this.style.display = this._open ? 'flex' : 'none'; 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() { async _load() {
this._error = null; this._error = null;
try { try {
@@ -40,8 +49,8 @@ export class RolesPage extends LightElement {
fetch('/api/roles'), fetch('/api/roles'),
fetch('/api/tool-permission-groups'), fetch('/api/tool-permission-groups'),
]); ]);
if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
this._roles = await rRes.json(); this._roles = await rRes.json();
this._groups = await gRes.json(); this._groups = await gRes.json();
} catch (e) { } catch (e) {
@@ -51,10 +60,25 @@ export class RolesPage extends LightElement {
// ── Modal helpers ──────────────────────────────────────────────────────────── // ── 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() { _openCreate() {
this._modal = { this._modal = {
mode: 'create', 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 = { this._modal = {
mode: 'edit', mode: 'edit',
role, 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; this._error = null;
if (mode === 'create') { 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 { try {
const res = await fetch('/api/roles', { const res = await fetch('/api/roles', {
method: 'POST', method: 'POST',
@@ -88,7 +112,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(), id: form.id.trim(),
label: form.label.trim(), label: form.label.trim(),
permission_group: form.permission_group, 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()); if (!res.ok) throw new Error(await res.text());
@@ -97,7 +121,7 @@ export class RolesPage extends LightElement {
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
} else { } else {
const { role } = this._modal; 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 { try {
const res = await fetch(`/api/roles/${role.id}`, { const res = await fetch(`/api/roles/${role.id}`, {
method: 'PUT', method: 'PUT',
@@ -105,7 +129,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({ body: JSON.stringify({
label: form.label.trim(), label: form.label.trim(),
permission_group: form.permission_group, 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()); if (!res.ok) throw new Error(await res.text());
@@ -116,7 +140,7 @@ export class RolesPage extends LightElement {
} }
async _delete(role) { async _delete(role) {
if (!confirm(`Delete role "${role.label}"?`)) return; if (!confirm(t('roles.confirm.delete', { name: role.label }))) return;
try { try {
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' }); const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -133,7 +157,7 @@ export class RolesPage extends LightElement {
_renderModal() { _renderModal() {
if (!this._modal) return nothing; if (!this._modal) return nothing;
const { mode, form, role } = this._modal; 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` return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> <div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
@@ -148,33 +172,41 @@ export class RolesPage extends LightElement {
${mode === 'create' ? html` ${mode === 'create' ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label">ID <span class="text-muted">(slug)</span></label> <label class="form-label">${t('roles.form.id')} <span class="text-muted">${t('roles.form.id_hint')}</span></label>
<input class="form-control font-monospace" placeholder="e.g. editor" .value=${form.id} <input class="form-control font-monospace" placeholder=${t('roles.form.id_ph')} .value=${form.id}
@input=${e => this._patch('id', e.target.value)} /> @input=${e => this._patch('id', e.target.value)} />
<div class="form-text" style="font-size:.75rem">Lowercase, no spaces. Cannot be changed later.</div> <div class="form-text" style="font-size:.75rem">${t('roles.form.id_desc')}</div>
</div> </div>
` : nothing} ` : nothing}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Label</label> <label class="form-label">${t('roles.form.label')}</label>
<input class="form-control" .value=${form.label} @input=${e => this._patch('label', e.target.value)} /> <input class="form-control" .value=${form.label} @input=${e => this._patch('label', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Permission group</label> <label class="form-label">${t('roles.form.group')}</label>
<select class="form-select" @change=${e => this._patch('permission_group', e.target.value)}> <select class="form-select" @change=${e => this._patch('permission_group', e.target.value)}>
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)} ${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Attrs <span class="text-muted">(JSON, optional)</span></label> <label class="form-label">${t('roles.form.interface')}</label>
<input class="form-control font-monospace" placeholder="{}" .value=${form.attrs} <select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
<option value="full" ?selected=${form.ui_mode === 'full'}>${t('roles.form.interface_full')}</option>
<option value="simple" ?selected=${form.ui_mode === 'simple'}>${t('roles.form.interface_simple')}</option>
</select>
<div class="form-text" style="font-size:.75rem">${unsafeHTML(t('roles.form.interface_hint'))}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@input=${e => this._patch('attrs', e.target.value)} /> @input=${e => this._patch('attrs', e.target.value)} />
</div> </div>
</div> </div>
<div class="um-modal-footer"> <div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('roles.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}> <button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : 'Save'} <i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('roles.form.create') : t('roles.form.save')}
</button> </button>
</div> </div>
</div> </div>
@@ -190,11 +222,11 @@ export class RolesPage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-tags me-2"></i>Roles</h2> <h2 class="um-title"><i class="bi bi-tags me-2"></i>${t('roles.title')}</h2>
<div class="um-header-right"> <div class="um-header-right">
<span class="um-header-count">${roles.length} role${roles.length === 1 ? '' : 's'}</span> <span class="um-header-count">${roles.length === 1 ? t('roles.count', { n: roles.length }) : t('roles.count_plural', { n: roles.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}> <button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New role <i class="bi bi-plus-lg me-1"></i>${t('roles.new_role')}
</button> </button>
</div> </div>
</div> </div>
@@ -204,15 +236,16 @@ export class RolesPage extends LightElement {
` : nothing} ` : nothing}
<div class="um-table-wrap"> <div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : roles.length === 0 ? html` ${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('roles.loading')}</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div> <div class="um-empty"><i class="bi bi-tags"></i><p>${t('roles.empty')}</p></div>
` : html` ` : html`
<table class="um-table"> <table class="um-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>${t('roles.col.id')}</th>
<th>Label</th> <th>${t('roles.col.label')}</th>
<th>Permission group</th> <th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -224,14 +257,17 @@ export class RolesPage extends LightElement {
<td><code>${r.id}</code></td> <td><code>${r.id}</code></td>
<td><strong>${r.label}</strong></td> <td><strong>${r.label}</strong></td>
<td>${this._groupLabel(r.permission_group)}</td> <td>${this._groupLabel(r.permission_group)}</td>
<td>${this._attrsUiMode(r.attrs) === 'simple'
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td> <td>
<div class="um-actions"> <div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Edit'} <button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
?disabled=${isAdmin} ?disabled=${isAdmin}
@click=${() => !isAdmin && this._openEdit(r)}> @click=${() => !isAdmin && this._openEdit(r)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Delete'} <button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.delete')}
?disabled=${isAdmin} ?disabled=${isAdmin}
@click=${() => !isAdmin && this._delete(r)}> @click=${() => !isAdmin && this._delete(r)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
+32 -22
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'session'; const PAGE_ID = 'session';
@@ -57,6 +59,8 @@ export class SessionDetailPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
@@ -68,6 +72,12 @@ export class SessionDetailPage extends LightElement {
}); });
} }
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
this._closeWs();
}
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._closeWs(); this._closeWs();
@@ -250,15 +260,15 @@ export class SessionDetailPage extends LightElement {
<div class="sd-session-header"> <div class="sd-session-header">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}> <button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back <i class="bi bi-arrow-left"></i> ${t('session.back')}
</button> </button>
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span> <span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span> <span class="fw-semibold font-monospace">${t('session.agent')} ${session.agent_id}</span>
<span class="text-secondary small">id: ${session.id}</span> <span class="text-secondary small">${t('session.id')} ${session.id}</span>
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing} ${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">${t('session.ephemeral')}</span>` : nothing}
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing} ${!session.is_interactive ? html`<span class="badge bg-light text-dark border">${t('session.automated')}</span>` : nothing}
${this._live ${this._live
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>` ? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>${t('session.live')}</span>`
: nothing} : nothing}
</div> </div>
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div> <div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
@@ -272,13 +282,13 @@ export class SessionDetailPage extends LightElement {
<div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}"> <div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}">
<div class="sd-msg-role"> <div class="sd-msg-role">
${item.is_synthetic ${item.is_synthetic
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">synthetic</span>` ? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">${t('session.synthetic')}</span>`
: nothing} : nothing}
<span>User</span> <span>${t('session.user_role')}</span>
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing} ${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
</div> </div>
<div class="sd-msg-content">${item.content}</div> <div class="sd-msg-content">${item.content}</div>
${item.failed ? html`<div class="sd-msg-failed">failed</div>` : nothing} ${item.failed ? html`<div class="sd-msg-failed">${t('session.failed')}</div>` : nothing}
</div> </div>
`; `;
} }
@@ -291,14 +301,14 @@ export class SessionDetailPage extends LightElement {
return html` return html`
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}"> <div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
<div class="sd-msg-role"> <div class="sd-msg-role">
Assistant ${t('session.assistant_role')}
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing} ${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing} ${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing}
</div> </div>
${hasReasoning ? html` ${hasReasoning ? html`
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}> <div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
<i class="bi bi-brain me-1"></i> <i class="bi bi-brain me-1"></i>
Reasoning ${t('session.reasoning_label')}
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i> <i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
</div> </div>
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing} ${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
@@ -316,14 +326,14 @@ export class SessionDetailPage extends LightElement {
return html` return html`
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}"> <div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
<div class="sd-msg-role"> <div class="sd-msg-role">
<i class="bi bi-lightning-charge me-1"></i>Thinking <i class="bi bi-lightning-charge me-1"></i>${t('session.thinking_role')}
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing} ${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing} ${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing}
</div> </div>
${hasReasoning ? html` ${hasReasoning ? html`
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}> <div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
<i class="bi bi-brain me-1"></i> <i class="bi bi-brain me-1"></i>
Reasoning ${t('session.reasoning_label')}
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i> <i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
</div> </div>
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing} ${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
@@ -351,10 +361,10 @@ export class SessionDetailPage extends LightElement {
${item.label_full && item.label_full !== item.label_short ${item.label_full && item.label_full !== item.label_short
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>` ? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
: nothing} : nothing}
<div class="sd-tool-section-label">Arguments</div> <div class="sd-tool-section-label">${t('session.tool_args')}</div>
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre> <pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
<div class="sd-tool-section-label mt-2"> <div class="sd-tool-section-label mt-2">
${item.status === 'error' ? 'Error' : 'Result'} ${item.status === 'error' ? t('session.tool_error') : t('session.tool_result')}
</div> </div>
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${ <pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
item.result ?? item.error ?? '—' item.result ?? item.error ?? '—'
@@ -369,14 +379,14 @@ export class SessionDetailPage extends LightElement {
return html` return html`
<div class="sd-agent-frame-start"> <div class="sd-agent-frame-start">
<i class="bi bi-robot me-1"></i> <i class="bi bi-robot me-1"></i>
<span>Sub-agent: <strong>${item.agent_id}</strong></span> <span>${t('session.sub_agent')} <strong>${item.agent_id}</strong></span>
<span class="text-secondary small ms-2">depth ${item.depth}</span> <span class="text-secondary small ms-2">${t('session.depth', { n: item.depth })}</span>
</div> </div>
`; `;
} }
_renderAgentFrameEnd(item) { _renderAgentFrameEnd(item) {
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`; return html`<div class="sd-agent-frame-end">${t('session.end_of')} ${item.agent_id}</div>`;
} }
_renderMessage(item, idx) { _renderMessage(item, idx) {
@@ -576,18 +586,18 @@ export class SessionDetailPage extends LightElement {
<div class="sd-container"> <div class="sd-container">
${this._loading ? html` ${this._loading ? html`
<div class="text-center text-secondary py-5"> <div class="text-center text-secondary py-5">
<div class="spinner-border spinner-border-sm me-2"></div>Loading session <div class="spinner-border spinner-border-sm me-2"></div>${t('session.loading')}
</div> </div>
` : this._error ? html` ` : this._error ? html`
<div class="alert alert-danger">${this._error}</div> <div class="alert alert-danger">${this._error}</div>
` : !this._data ? html` ` : !this._data ? html`
<div class="text-secondary text-center py-5">No session loaded.<br> <div class="text-secondary text-center py-5">${t('session.no_session')}<br>
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span> <span class="small">${unsafeHTML(t('session.no_session_hint'))}</span>
</div> </div>
` : html` ` : html`
${this._renderSessionHeader(this._data.session)} ${this._renderSessionHeader(this._data.session)}
${this._data.messages.length === 0 ${this._data.messages.length === 0
? html`<div class="text-secondary text-center py-4">No messages in this session.</div>` ? html`<div class="text-secondary text-center py-4">${t('session.empty')}</div>`
: this._data.messages.map((m, i) => this._renderMessage(m, i)) : this._data.messages.map((m, i) => this._renderMessage(m, i))
} }
`} `}
+31 -16
View File
@@ -1,7 +1,8 @@
import { html } from 'lit'; import { html } from 'lit';
import { LightElement } from '../lib/base.js'; 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() { static get properties() {
return { return {
@@ -9,6 +10,7 @@ export class SetupPage extends LightElement {
_password: { state: true }, _password: { state: true },
_confirm: { state: true }, _confirm: { state: true },
_encrypted: { state: true }, _encrypted: { state: true },
_locale: { state: true },
_error: { state: true }, _error: { state: true },
_busy: { state: true }, _busy: { state: true },
}; };
@@ -20,6 +22,7 @@ export class SetupPage extends LightElement {
this._password = ''; this._password = '';
this._confirm = ''; this._confirm = '';
this._encrypted = true; this._encrypted = true;
this._locale = getLocale();
this._error = null; this._error = null;
this._busy = false; this._busy = false;
} }
@@ -31,15 +34,15 @@ export class SetupPage extends LightElement {
this._error = null; this._error = null;
if (!this._username.trim()) { if (!this._username.trim()) {
this._error = 'Choose a username.'; this._error = t('setup.username');
return; return;
} }
if (this._password.length < 4) { if (this._password.length < 4) {
this._error = 'Password must be at least 4 characters.'; this._error = t('setup.pw.short');
return; return;
} }
if (this._password !== this._confirm) { if (this._password !== this._confirm) {
this._error = 'The two passwords do not match.'; this._error = t('setup.pw.mismatch');
return; return;
} }
@@ -56,6 +59,7 @@ export class SetupPage extends LightElement {
username: this._username.trim(), username: this._username.trim(),
password: this._password, password: this._password,
encrypted: this._encrypted, encrypted: this._encrypted,
locale: this._locale,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -66,7 +70,7 @@ export class SetupPage extends LightElement {
// First user created — reload into the app. // First user created — reload into the app.
window.location.reload(); window.location.reload();
} catch { } catch {
this._error = 'Network error — please try again.'; this._error = t('setup.network');
} finally { } finally {
this._busy = false; this._busy = false;
} }
@@ -74,8 +78,8 @@ export class SetupPage extends LightElement {
render() { render() {
const btnLabel = this._busy const btnLabel = this._busy
? html`<span class="setup-spinner"></span>Creating…` ? html`<span class="setup-spinner"></span>${t('setup.creating')}`
: 'Create account'; : t('setup.submit');
return html` return html`
<div class="setup-page"> <div class="setup-page">
@@ -83,13 +87,13 @@ export class SetupPage extends LightElement {
<div class="setup-logo"> <div class="setup-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" /> <img src="/assets/icons/icon-192.png" alt="Skald" />
</div> </div>
<h1 class="setup-title">Welcome to Skald</h1> <h1 class="setup-title">${t('setup.title')}</h1>
<p class="setup-subtitle">Create the admin account to get started.</p> <p class="setup-subtitle">${t('setup.subtitle')}</p>
${this._error ? html`<div class="setup-error">${this._error}</div>` : null} ${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">${t('login.username')}</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
@@ -100,7 +104,7 @@ export class SetupPage extends LightElement {
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">${t('login.password')}</label>
<input <input
type="password" type="password"
class="form-control" class="form-control"
@@ -111,7 +115,7 @@ export class SetupPage extends LightElement {
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Confirm password</label> <label class="form-label">${t('setup.confirm')}</label>
<input <input
type="password" type="password"
class="form-control" class="form-control"
@@ -121,6 +125,19 @@ export class SetupPage extends LightElement {
required /> required />
</div> </div>
<div class="mb-3">
<label class="form-label">${t('setup.language')}</label>
<select
class="form-select"
.value=${this._locale}
@change=${e => { this._locale = e.target.value; setLocale(this._locale); }}
?disabled=${this._busy}>
${LOCALES.map(l => html`
<option value=${l.id} ?selected=${this._locale === l.id}>${l.label}</option>
`)}
</select>
</div>
<div class="form-check"> <div class="form-check">
<input <input
class="form-check-input" class="form-check-input"
@@ -130,15 +147,13 @@ export class SetupPage extends LightElement {
@change=${e => this._encrypted = e.target.checked} @change=${e => this._encrypted = e.target.checked}
?disabled=${this._busy} /> ?disabled=${this._busy} />
<label class="form-check-label" for="encrypt-chk"> <label class="form-check-label" for="encrypt-chk">
Encrypt my conversation history ${t('setup.encrypt')}
</label> </label>
</div> </div>
${this._encrypted ? html` ${this._encrypted ? html`
<div class="setup-warn"> <div class="setup-warn">
<strong>Warning:</strong> your password derives the encryption key. <strong>${t('setup.warn.strong')}</strong> ${t('setup.warn')}
If you forget it, <strong>your entire conversation history will be
permanently lost</strong> there is no recovery.
</div> </div>
` : null} ` : null}
+356
View File
@@ -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`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-folder-symlink me-2"></i>${t('sf.title')}</h2>
<div class="um-header-right">
<span class="um-header-count">
${folders.length === 1 ? t('sf.count', { n: folders.length }) : t('sf.count_plural', { n: folders.length })}
</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>${t('sf.new')}
</button>
</div>
</div>
${this._error && !this._modal ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
<div class="text-muted mb-3" style="font-size:.78rem">
<i class="bi bi-info-circle me-1"></i>${t('sf.note.propagation')}
</div>
${loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('sf.loading')}</div>`
: folders.length === 0
? html`
<div class="um-empty">
<i class="bi bi-folder-symlink"></i>
<p>${t('sf.empty')}</p>
<p style="font-size:.8rem;opacity:.7">${t('sf.empty_hint')}</p>
</div>`
: html`<div class="d-flex flex-column gap-3">${folders.map(f => this._renderFolder(f))}</div>`}
</div>
${this._renderModal()}
</div>`;
}
_renderFolder(f) {
const draft = this._draft(f.id);
const candidates = this._candidates(f);
return html`
<div class="connector-card" style="cursor:default">
<div class="d-flex align-items-start justify-content-between">
<div style="min-width:0">
<div style="font-weight:600;font-size:.95rem">
<i class="bi bi-folder2 me-1" style="opacity:.6"></i>${f.folder_name}
</div>
<code class="text-muted" style="font-size:.7rem">shared/${f.folder_name}</code>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary" title=${t('sf.edit_desc')} @click=${() => this._openEditDesc(f)}>
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" title=${t('sf.delete')} @click=${() => this._delete(f)}>
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="mt-2 mb-3" style="font-size:.82rem">
${f.description
? html`<span>${f.description}</span>`
: html`<span class="text-muted fst-italic">${t('sf.no_desc')}</span>`}
</div>
<div style="border-top:1px solid var(--bs-border-color,#333);padding-top:.75rem">
<div class="text-muted mb-2" style="font-size:.72rem;text-transform:uppercase;letter-spacing:.04em">
${t('sf.members')}
</div>
${f.members.length === 0
? html`<div class="text-muted mb-2" style="font-size:.8rem">${t('sf.no_members')}</div>`
: html`<div class="d-flex flex-column gap-1 mb-2">${f.members.map(m => this._renderMember(f, m))}</div>`}
${candidates.length > 0 ? html`
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm" style="max-width:16rem"
@change=${(e) => this._setAdd(f.id, { user_id: e.target.value })}>
<option value="" ?selected=${!draft.user_id}>${t('sf.choose_user')}</option>
${candidates.map(u => html`
<option value=${u.id} ?selected=${draft.user_id === u.id}>${u.display_name || u.username}</option>`)}
</select>
<select class="form-select form-select-sm" style="max-width:11rem"
@change=${(e) => this._setAdd(f.id, { can_write: e.target.value === 'write' })}>
<option value="read" ?selected=${!draft.can_write}>${t('sf.access.readonly')}</option>
<option value="write" ?selected=${draft.can_write}>${t('sf.access.readwrite')}</option>
</select>
<button class="btn btn-sm btn-primary" ?disabled=${!draft.user_id} @click=${() => this._addMember(f)}>
<i class="bi bi-plus-lg me-1"></i>${t('sf.add')}
</button>
</div>`
: html`<div class="text-muted" style="font-size:.78rem">${t('sf.all_added')}</div>`}
</div>
</div>`;
}
_renderMember(f, m) {
return html`
<div class="d-flex align-items-center justify-content-between p-2 rounded"
style="border:1px solid var(--bs-border-color,#333)">
<div style="min-width:0;font-size:.85rem">
<i class="bi bi-person-circle me-1" style="opacity:.6"></i>${this._userLabel(m.user_id)}
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" aria-label=${t('sf.access.label')}>
<button class="btn ${!m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
title=${t('sf.access.readonly')}
@click=${() => m.can_write && this._setAccess(f, m.user_id, false)}>
<i class="bi bi-eye me-1"></i>${t('sf.access.read')}
</button>
<button class="btn ${m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
title=${t('sf.access.readwrite')}
@click=${() => !m.can_write && this._setAccess(f, m.user_id, true)}>
<i class="bi bi-pencil me-1"></i>${t('sf.access.write')}
</button>
</div>
<button class="um-btn-icon" title=${t('sf.remove')} @click=${() => this._removeMember(f, m.user_id)}>
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>`;
}
_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`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi ${mode === 'create' ? 'bi-folder-plus' : 'bi-pencil-square'}"></i>
<span>${title}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">${t('sf.form.name')} <span class="text-muted">${t('sf.form.name_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('sf.form.name_ph')} .value=${form.folder_name}
@input=${e => this._patch('folder_name', e.target.value)} />
<div class="form-text" style="font-size:.75rem">${t('sf.form.name_desc')}</div>
</div>
` : html`
<div class="mb-3">
<label class="form-label">${t('sf.form.name')}</label>
<div><code>shared/${folder.folder_name}</code></div>
</div>
`}
<div class="mb-3">
<label class="form-label">${t('sf.form.desc')}</label>
<textarea class="form-control" rows="4" placeholder=${t('sf.form.desc_ph')} .value=${form.description}
@input=${e => this._patch('description', e.target.value)}></textarea>
<div class="form-text" style="font-size:.75rem">${t('sf.form.desc_desc')}</div>
</div>
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('sf.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('sf.form.create') : t('sf.form.save')}
</button>
</div>
</div>
</div>`;
}
}
+12 -11
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js'; import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from '../copilot-render.js'; import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
export class ChatPage extends ChatSession { export class ChatPage extends ChatSession {
@@ -105,17 +106,17 @@ export class ChatPage extends ChatSession {
<div class="mobile-section-header"> <div class="mobile-section-header">
<span class="mobile-section-title"> <span class="mobile-section-title">
${this._inProject ? html` ${this._inProject ? html`
<button class="chat-page-back" title="Back to General" <button class="chat-page-back" title=${t('chat.mobile.back_general')}
@click=${() => this._exitProject()}> @click=${() => this._exitProject()}>
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</button> </button>
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'} <i class="bi bi-folder2-open"></i> ${this.label || t('chat.mobile.project')}
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`} ` : html`<i class="bi bi-chat-dots-fill"></i> ${t('chat.mobile.chat')}`}
</span> </span>
<div class="chat-page-header-actions"> <div class="chat-page-header-actions">
<button <button
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
title="New conversation" title=${t('chat.new_session')}
@click=${() => this._startNewSession()} @click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button> ><i class="bi bi-trash"></i></button>
</div> </div>
@@ -125,14 +126,14 @@ export class ChatPage extends ChatSession {
${this._messages.length === 0 ? html` ${this._messages.length === 0 ? html`
<div class="chat-page-empty"> <div class="chat-page-empty">
<i class="bi bi-stars"></i> <i class="bi bi-stars"></i>
<p>Ask me anything</p> <p>${t('chat.mobile.ask')}</p>
</div> </div>
` : this._messages.map(m => renderMsg(this, m))} ` : this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html` ${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking"> <div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span> <span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking ${t('chat.thinking')}
</div> </div>
` : nothing} ` : nothing}
</div> </div>
@@ -152,7 +153,7 @@ export class ChatPage extends ChatSession {
<textarea <textarea
class="chat-page-textarea" class="chat-page-textarea"
rows="1" rows="1"
placeholder="Type a message…" placeholder=${t('chat.mobile.placeholder')}
@input=${(e) => this._autoResize(e.target)} @input=${(e) => this._autoResize(e.target)}
@paste=${(e) => this._onPaste(e)} @paste=${(e) => this._onPaste(e)}
></textarea> ></textarea>
@@ -160,7 +161,7 @@ export class ChatPage extends ChatSession {
<div class="chat-page-toolbar-left"> <div class="chat-page-toolbar-left">
<button <button
class="btn btn-sm btn-outline-secondary chat-page-attach-btn" class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
title="Attach files" title=${t('chat.attach')}
@click=${() => this.querySelector('.chat-page-file-input')?.click()} @click=${() => this.querySelector('.chat-page-file-input')?.click()}
><i class="bi bi-paperclip"></i></button> ><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html` ${this._providers.length > 1 ? html`
@@ -179,7 +180,7 @@ export class ChatPage extends ChatSession {
${this._hasTranscribe ? html` ${this._hasTranscribe ? html`
<button <button
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}" class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice'}" title=${this._recording ? t('chat.mobile.stop_record') : t('chat.mobile.record_voice')}
@click=${() => this._toggleRecording()} @click=${() => this._toggleRecording()}
> >
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i> <i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
@@ -189,13 +190,13 @@ export class ChatPage extends ChatSession {
? html`<button ? html`<button
class="chat-page-send chat-page-send--stop" class="chat-page-send chat-page-send--stop"
@click=${() => this._cancel()} @click=${() => this._cancel()}
title="Stop" title=${t('chat.stop')}
><i class="bi bi-stop-fill"></i></button>` ><i class="bi bi-stop-fill"></i></button>`
: nothing} : nothing}
<button <button
class="chat-page-send" class="chat-page-send"
@click=${() => this._send()} @click=${() => this._send()}
title="Send" title=${t('chat.send')}
><i class="bi bi-send-fill"></i></button> ><i class="bi bi-send-fill"></i></button>
</div> </div>
</div> </div>
+19 -6
View File
@@ -1,3 +1,5 @@
import { t } from '../../lib/i18n.js';
// Shared vocabulary for the Connectors list and a connector's own page. // 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 // 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 /// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents
/// in `web/css/connectors.css`. /// in `web/css/connectors.css`.
export const STATUS_LABEL = { export const STATUS_LABEL = {
active: { text: 'active', tone: 'ok' }, active: { tone: 'ok' },
pending: { text: 'needs fix', tone: 'script' }, pending: { tone: 'script' },
needs_login: { text: 'needs sign-in', tone: 'script' }, needs_login: { tone: 'script' },
enabled: { text: 'enabled', tone: 'scope' }, enabled: { tone: 'scope' },
off: { text: 'off', tone: '' }, off: { tone: '' },
available: { text: 'available', 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 /// The one place that decides what a connector's state *is*, from whichever runtime
/// rows exist for it. /// rows exist for it.
/// ///
+4 -3
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js'; import { LightElement, renderMarkdown } from '../../lib/base.js';
import { fileWatcher } from '../../lib/file-watcher.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 / * 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'; const showingSource = this._htmlMode === 'source';
return html`<button return html`<button
class=${btnClass} class=${btnClass}
title=${showingSource ? 'Show preview' : 'Show source'} title=${showingSource ? t('fv.mode_preview') : t('fv.mode_source')}
@click=${() => this._toggleHtmlMode()}> @click=${() => this._toggleHtmlMode()}>
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i> <i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
</button>`; </button>`;
@@ -389,7 +390,7 @@ export class FileViewerBase extends LightElement {
if (this._kind === 'binary') { if (this._kind === 'binary') {
return html`<div class="fv-state text-muted"> return html`<div class="fv-state text-muted">
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i> <i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>
Preview not available for this file type. ${t('fv.binary_unavailable')}
</div>`; </div>`;
} }
if (this._kind === 'html') { if (this._kind === 'html') {
@@ -417,7 +418,7 @@ export class FileViewerBase extends LightElement {
return html` return html`
${this._compileError ${this._compileError
? html`<details class="fv-compile-error"> ? html`<details class="fv-compile-error">
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;LaTeX compilation failed showing source instead</summary> <summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;${t('fv.latex_failed')}</summary>
<pre>${this._compileError}</pre> <pre>${this._compileError}</pre>
</details>` </details>`
: nothing} : nothing}
+3 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
import { FileViewerBase } from './file-viewer-base.js'; import { FileViewerBase } from './file-viewer-base.js';
/** /**
@@ -43,14 +44,14 @@ export class MobileFileViewerPage extends FileViewerBase {
<div class="mobile-file-viewer"> <div class="mobile-file-viewer">
<div class="mobile-section-header"> <div class="mobile-section-header">
<span class="mobile-section-title"> <span class="mobile-section-title">
<button class="chat-page-back" title="Back" @click=${() => this._back()}> <button class="chat-page-back" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left"></i>
</button> </button>
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span> <span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
</span> </span>
<span class="fv-header-actions"> <span class="fv-header-actions">
${this._renderModeToggle('chat-page-back')} ${this._renderModeToggle('chat-page-back')}
<button class="chat-page-back" title="Download" @click=${() => this._download()}> <button class="chat-page-back" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i> <i class="bi bi-download"></i>
</button> </button>
</span> </span>
+44 -24
View File
@@ -1,8 +1,9 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; 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 = { static properties = {
_activePage: { state: true }, _activePage: { state: true },
_tasksSection: { state: true }, _tasksSection: { state: true },
@@ -120,7 +121,7 @@ export class AppSidebar extends LightElement {
const match = hash.match(/^([^/?]+)/); const match = hash.match(/^([^/?]+)/);
const segment = match ? match[1] : ''; const segment = match ? match[1] : '';
// `connector` (singular) is the per-connector detail page, `connectors` the list. // `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() { _tasksSectionFromHash() {
@@ -183,7 +184,7 @@ export class AppSidebar extends LightElement {
class="sidebar-link ${active ? 'active' : ''}" class="sidebar-link ${active ? 'active' : ''}"
@click=${(e) => this._openTaskManager(e)}> @click=${(e) => this._openTaskManager(e)}>
<i class="bi bi-lightning-charge"></i> <i class="bi bi-lightning-charge"></i>
<span class="sidebar-link-name">Task Manager</span> <span class="sidebar-link-name">${t('nav.tasks')}</span>
<i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i> <i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i>
</a> </a>
${active ? html` ${active ? html`
@@ -191,22 +192,22 @@ export class AppSidebar extends LightElement {
<a href="#tasks/running" <a href="#tasks/running"
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}" class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('running', e)}> @click=${(e) => this._navigateTasksSection('running', e)}>
<i class="bi bi-activity"></i> Running Tasks <i class="bi bi-activity"></i> ${t('nav.tasks.running')}
</a> </a>
<a href="#tasks/cron" <a href="#tasks/cron"
class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}" class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('cron', e)}> @click=${(e) => this._navigateTasksSection('cron', e)}>
<i class="bi bi-repeat"></i> Cron Jobs <i class="bi bi-repeat"></i> ${t('nav.tasks.cron')}
</a> </a>
<a href="#tasks/scheduled" <a href="#tasks/scheduled"
class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}" class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('scheduled', e)}> @click=${(e) => this._navigateTasksSection('scheduled', e)}>
<i class="bi bi-clock"></i> Scheduled Tasks <i class="bi bi-clock"></i> ${t('nav.tasks.scheduled')}
</a> </a>
<a href="#tasks/history" <a href="#tasks/history"
class="sidebar-sublink ${sec === 'history' ? 'active' : ''}" class="sidebar-sublink ${sec === 'history' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('history', e)}> @click=${(e) => this._navigateTasksSection('history', e)}>
<i class="bi bi-journal-text"></i> History <i class="bi bi-journal-text"></i> ${t('nav.tasks.history')}
</a> </a>
</div> </div>
` : nothing} ` : nothing}
@@ -223,7 +224,7 @@ export class AppSidebar extends LightElement {
<i class="bi bi-folder2" style="font-size:0.78rem;opacity:0.65;flex-shrink:0"></i> <i class="bi bi-folder2" style="font-size:0.78rem;opacity:0.65;flex-shrink:0"></i>
<span class="sidebar-project-name">${p.name}</span> <span class="sidebar-project-name">${p.name}</span>
<button class="sidebar-project-chat-btn" <button class="sidebar-project-chat-btn"
title="Open chat" title=${t('topbar.open_chat')}
@click=${(e) => this._openProjectChat(p.id, p.name, e)}> @click=${(e) => this._openProjectChat(p.id, p.name, e)}>
<i class="bi bi-chat-dots"></i> <i class="bi bi-chat-dots"></i>
</button> </button>
@@ -234,10 +235,14 @@ export class AppSidebar extends LightElement {
} }
render() { 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` return html`
<div class="sidebar-brand"> <div class="sidebar-brand">
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" /> <img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
<span>Skald</span> <span>${t('topbar.brand')}</span>
</div> </div>
<hr class="sidebar-divider" /> <hr class="sidebar-divider" />
@@ -245,26 +250,34 @@ export class AppSidebar extends LightElement {
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}"
@click=${(e) => this._togglePage('home', e)}> @click=${(e) => this._togglePage('home', e)}>
<i class="bi bi-house-door"></i> <i class="bi bi-chat-dots"></i>
<span class="sidebar-link-name">Home</span> <span class="sidebar-link-name">${t('nav.chat')}</span>
</a> </a>
<a href="#inbox" class="sidebar-link ${this._activePage === 'inbox' ? 'active' : ''}" <a href="#inbox" class="sidebar-link ${this._activePage === 'inbox' ? 'active' : ''}"
@click=${(e) => this._togglePage('inbox', e)}> @click=${(e) => this._togglePage('inbox', e)}>
<i class="bi bi-inbox"></i> <i class="bi bi-inbox"></i>
<span class="sidebar-link-name"> <span class="sidebar-link-name">
Inbox ${t('nav.inbox')}
${this._inboxCount > 0 ${this._inboxCount > 0
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>` ? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
: ''} : ''}
</span> </span>
</a> </a>
${simple ? nothing : html`
<a href="#dashboard"
class="sidebar-link ${this._activePage === 'dashboard' ? 'active' : ''}"
@click=${(e) => this._togglePage('dashboard', e)}>
<i class="bi bi-speedometer2"></i>
<span class="sidebar-link-name">${t('nav.dashboard')}</span>
</a>
<a href="#projects" <a href="#projects"
class="sidebar-link ${this._activePage === 'projects' ? 'active' : ''}" class="sidebar-link ${this._activePage === 'projects' ? 'active' : ''}"
@click=${(e) => this._togglePage('projects', e)}> @click=${(e) => this._togglePage('projects', e)}>
<i class="bi bi-kanban"></i> <i class="bi bi-kanban"></i>
<span class="sidebar-link-name">Projects</span> <span class="sidebar-link-name">${t('nav.projects')}</span>
</a> </a>
${this._renderRecentProjects()} ${this._renderRecentProjects()}
@@ -273,48 +286,54 @@ export class AppSidebar extends LightElement {
<a href="#" class="sidebar-link ${this._activePage === 'models' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'models' ? 'active' : ''}"
@click=${(e) => this._togglePage('models', e)}> @click=${(e) => this._togglePage('models', e)}>
<i class="bi bi-cpu"></i> <i class="bi bi-cpu"></i>
<span class="sidebar-link-name">Models</span> <span class="sidebar-link-name">${t('nav.models')}</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'providers' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'providers' ? 'active' : ''}"
@click=${(e) => this._togglePage('providers', e)}> @click=${(e) => this._togglePage('providers', e)}>
<i class="bi bi-plug"></i> <i class="bi bi-plug"></i>
<span class="sidebar-link-name">Providers</span> <span class="sidebar-link-name">${t('nav.providers')}</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'approval' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'approval' ? 'active' : ''}"
@click=${(e) => this._togglePage('approval', e)}> @click=${(e) => this._togglePage('approval', e)}>
<i class="bi bi-shield-check"></i> <i class="bi bi-shield-check"></i>
<span class="sidebar-link-name">Security</span> <span class="sidebar-link-name">${t('nav.security')}</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'agents' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'agents' ? 'active' : ''}"
@click=${(e) => this._togglePage('agents', e)}> @click=${(e) => this._togglePage('agents', e)}>
<i class="bi bi-people"></i> <i class="bi bi-people"></i>
<span class="sidebar-link-name">Agents</span> <span class="sidebar-link-name">${t('nav.agents')}</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}"
@click=${(e) => this._togglePage('users', e)}> @click=${(e) => this._togglePage('users', e)}>
<i class="bi bi-person-badge"></i> <i class="bi bi-person-badge"></i>
<span class="sidebar-link-name">Users</span> <span class="sidebar-link-name">${t('nav.users')}</span>
</a> </a>
<a href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}"
@click=${(e) => this._togglePage('roles', e)}> @click=${(e) => this._togglePage('roles', e)}>
<i class="bi bi-tags"></i> <i class="bi bi-tags"></i>
<span class="sidebar-link-name">Roles</span> <span class="sidebar-link-name">${t('nav.roles')}</span>
</a> </a>
${this._me?.role_id === 'admin' ? html`
<a href="#" class="sidebar-link ${this._activePage === 'shared-folders' ? 'active' : ''}"
@click=${(e) => this._togglePage('shared-folders', e)}>
<i class="bi bi-folder-symlink"></i>
<span class="sidebar-link-name">${t('nav.shared_folders')}</span>
</a>` : nothing}
<a href="#" class="sidebar-link ${this._activePage === 'connectors' || this._activePage === 'connector' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'connectors' || this._activePage === 'connector' ? 'active' : ''}"
@click=${(e) => this._togglePage('connectors', e)}> @click=${(e) => this._togglePage('connectors', e)}>
<i class="bi bi-plug"></i> <i class="bi bi-plug"></i>
<span class="sidebar-link-name">Connectors</span> <span class="sidebar-link-name">${t('nav.connectors')}</span>
</a> </a>
${this._me?.role_id === 'admin' ? html` ${this._me?.role_id === 'admin' ? html`
<a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}"
@click=${(e) => this._togglePage('catalog', e)}> @click=${(e) => this._togglePage('catalog', e)}>
<i class="bi bi-journal-text"></i> <i class="bi bi-journal-text"></i>
<span class="sidebar-link-name">Catalog</span> <span class="sidebar-link-name">${t('nav.catalog')}</span>
</a>` : nothing} </a>` : nothing}
<a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}" <a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
@click=${(e) => this._togglePage('config', e)}> @click=${(e) => this._togglePage('config', e)}>
<i class="bi bi-gear"></i> <i class="bi bi-gear"></i>
<span class="sidebar-link-name">Config</span> <span class="sidebar-link-name">${t('nav.config')}</span>
</a> </a>
${this._debugMode ? html` ${this._debugMode ? html`
@@ -323,15 +342,16 @@ export class AppSidebar extends LightElement {
class="sidebar-link ${this._activePage === 'llm-requests' ? 'active' : ''}" class="sidebar-link ${this._activePage === 'llm-requests' ? 'active' : ''}"
@click=${(e) => this._togglePage('llm-requests', e)}> @click=${(e) => this._togglePage('llm-requests', e)}>
<i class="bi bi-journal-code"></i> <i class="bi bi-journal-code"></i>
<span class="sidebar-link-name">LLM Requests</span> <span class="sidebar-link-name">${t('nav.llm_requests')}</span>
</a> </a>
<a href="#tic" <a href="#tic"
class="sidebar-link ${this._activePage === 'tic' ? 'active' : ''}" class="sidebar-link ${this._activePage === 'tic' ? 'active' : ''}"
@click=${(e) => this._togglePage('tic', e)}> @click=${(e) => this._togglePage('tic', e)}>
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
<span class="sidebar-link-name">TIC Sessions</span> <span class="sidebar-link-name">${t('nav.tic')}</span>
</a> </a>
` : nothing} ` : nothing}
`}
</nav> </nav>
`; `;
+25 -12
View File
@@ -1,7 +1,9 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../../lib/base.js'; import { LightElement } from '../../lib/base.js';
import { toString as cronToString } from 'cronstrue'; import { toString as cronToString } from 'cronstrue';
import { formatDate } from './utils.js'; import { formatDate } from './utils.js';
import { t } from '../../lib/i18n.js';
export class CronJobsSection extends LightElement { export class CronJobsSection extends LightElement {
static properties = { static properties = {
@@ -15,6 +17,17 @@ export class CronJobsSection extends LightElement {
this._error = null; 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() { async load() {
this._error = null; this._error = null;
try { try {
@@ -28,7 +41,7 @@ export class CronJobsSection extends LightElement {
} }
async _delete(job) { async _delete(job) {
if (!confirm(`Delete job "${job.title}"?`)) return; if (!confirm(t('cron.confirm.delete', { title: job.title }))) return;
try { try {
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' }); const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -50,10 +63,10 @@ export class CronJobsSection extends LightElement {
_statusBadge(job) { _statusBadge(job) {
if (job.running_session_id != null) if (job.running_session_id != null)
return html`<span class="task-badge task-badge--running">running</span>`; return html`<span class="task-badge task-badge--running">${t('cron.badge.running')}</span>`;
if (!job.enabled) if (!job.enabled)
return html`<span class="task-badge task-badge--disabled">disabled</span>`; return html`<span class="task-badge task-badge--disabled">${t('cron.badge.disabled')}</span>`;
return html`<span class="task-badge task-badge--idle">idle</span>`; return html`<span class="task-badge task-badge--idle">${t('cron.badge.idle')}</span>`;
} }
_renderCard(job) { _renderCard(job) {
@@ -64,7 +77,7 @@ export class CronJobsSection extends LightElement {
<span class="task-card-title">${job.title}</span> <span class="task-card-title">${job.title}</span>
${this._statusBadge(job)} ${this._statusBadge(job)}
</div> </div>
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}> <button class="task-card-delete" title=${t('cron.action.delete')} @click=${() => this._delete(job)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -81,15 +94,15 @@ export class CronJobsSection extends LightElement {
<div class="task-card-meta"> <div class="task-card-meta">
<div class="task-card-meta-item"> <div class="task-card-meta-item">
<span class="task-card-meta-label">Agent</span> <span class="task-card-meta-label">${t('cron.card.label_agent')}</span>
<span class="task-card-meta-value">${job.agent_id}</span> <span class="task-card-meta-value">${job.agent_id}</span>
</div> </div>
<div class="task-card-meta-item"> <div class="task-card-meta-item">
<span class="task-card-meta-label">Last run</span> <span class="task-card-meta-label">${t('cron.card.label_last_run')}</span>
<span class="task-card-meta-value">${formatDate(job.last_run_at)}</span> <span class="task-card-meta-value">${formatDate(job.last_run_at)}</span>
</div> </div>
<div class="task-card-meta-item"> <div class="task-card-meta-item">
<span class="task-card-meta-label">Next run</span> <span class="task-card-meta-label">${t('cron.card.label_next_run')}</span>
<span class="task-card-meta-value">${formatDate(job.next_run_at)}</span> <span class="task-card-meta-value">${formatDate(job.next_run_at)}</span>
</div> </div>
</div> </div>
@@ -99,7 +112,7 @@ export class CronJobsSection extends LightElement {
<input class="form-check-input" type="checkbox" role="switch" <input class="form-check-input" type="checkbox" role="switch"
.checked=${job.enabled} .checked=${job.enabled}
@change=${() => this._toggle(job)} /> @change=${() => this._toggle(job)} />
<span class="task-card-toggle-label">${job.enabled ? 'Enabled' : 'Disabled'}</span> <span class="task-card-toggle-label">${job.enabled ? t('cron.card.enabled') : t('cron.card.disabled')}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -110,9 +123,9 @@ export class CronJobsSection extends LightElement {
return html` return html`
<div class="task-page"> <div class="task-page">
<div class="task-page-header"> <div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-repeat"></i> Cron Jobs</h2> <h2 class="task-page-title"><i class="bi bi-repeat"></i> ${t('cron.title')}</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)"> <div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''} ${t(this._jobs.length === 1 ? 'cron.count_one' : 'cron.count_other', { n: this._jobs.length })}
</div> </div>
</div> </div>
@@ -123,7 +136,7 @@ export class CronJobsSection extends LightElement {
${this._jobs.length === 0 ? html` ${this._jobs.length === 0 ? html`
<div class="task-empty"> <div class="task-empty">
<i class="bi bi-repeat"></i> <i class="bi bi-repeat"></i>
<p>No recurring cron jobs. Ask the agent to create one with <code>execute_task</code>.</p> <p>${t('cron.empty.title')} ${unsafeHTML(t('cron.empty.hint'))}</p>
</div> </div>
` : html` ` : html`
<div class="task-grid"> <div class="task-grid">
+18 -10
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'tic'; const PAGE_ID = 'tic';
const PER_PAGE = 20; const PER_PAGE = 20;
@@ -42,6 +43,8 @@ export class TicSessionsPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; 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) { async _fetch(page) {
this._loading = true; this._loading = true;
this._error = null; this._error = null;
@@ -77,7 +85,7 @@ export class TicSessionsPage extends LightElement {
if (this._loading) return html` if (this._loading) return html`
<div class="tic-state"> <div class="tic-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div> <div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading</span> <span>${t('tic.loading')}</span>
</div> </div>
`; `;
if (this._error) return html` if (this._error) return html`
@@ -89,7 +97,7 @@ export class TicSessionsPage extends LightElement {
if (this._items.length === 0) return html` if (this._items.length === 0) return html`
<div class="tic-state"> <div class="tic-state">
<i class="bi bi-inbox"></i> <i class="bi bi-inbox"></i>
<span>No TIC sessions found.</span> <span>${t('tic.empty')}</span>
</div> </div>
`; `;
@@ -99,10 +107,10 @@ export class TicSessionsPage extends LightElement {
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Agent</th> <th>${t('tic.table.agent')}</th>
<th>Started</th> <th>${t('tic.table.started')}</th>
<th class="text-end">Messages</th> <th class="text-end">${t('tic.table.messages')}</th>
<th>Last activity</th> <th>${t('tic.table.last_activity')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -131,7 +139,7 @@ export class TicSessionsPage extends LightElement {
@click=${() => this._fetch(cur - 1)}> @click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</button> </button>
<span class="tic-page-info">Page ${cur} of ${pages} &mdash; ${this._total} sessions</span> <span class="tic-page-info">${t('tic.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages} <button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}> @click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
@@ -231,12 +239,12 @@ export class TicSessionsPage extends LightElement {
<div class="tic-page"> <div class="tic-page">
<div class="tic-header"> <div class="tic-header">
<h2 class="tic-title"><i class="bi bi-bell"></i> TIC Sessions</h2> <h2 class="tic-title"><i class="bi bi-bell"></i> ${t('tic.title')}</h2>
<span class="tic-total-badge">${this._total} total</span> <span class="tic-total-badge">${t('tic.total', { n: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary tic-refresh-btn" <button class="btn btn-sm btn-outline-secondary tic-refresh-btn"
?disabled=${this._loading} ?disabled=${this._loading}
@click=${() => this._fetch(this._page)}> @click=${() => this._fetch(this._page)}>
<i class="bi bi-arrow-clockwise"></i> Refresh <i class="bi bi-arrow-clockwise"></i> ${t('tic.refresh')}
</button> </button>
</div> </div>
${this._renderTable()} ${this._renderTable()}
+20 -7
View File
@@ -1,7 +1,15 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; 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 = { static properties = {
_theme: { state: true }, _theme: { state: true },
_copilotCollapsed: { state: true }, _copilotCollapsed: { state: true },
@@ -65,23 +73,28 @@ export class AppTopbar extends LightElement {
return name.charAt(0).toUpperCase(); return name.charAt(0).toUpperCase();
} }
get _avatarColor() {
const name = this._me?.username || '';
return name ? avatarColor(name) : 'var(--accent)';
}
render() { render() {
const isDark = this._theme === 'dark'; const isDark = this._theme === 'dark';
return html` return html`
<span class="topbar-title">Skald</span> <span class="topbar-title">${t('topbar.brand')}</span>
<span class="topbar-spacer"></span> <span class="topbar-spacer"></span>
${this._copilotCollapsed ? html` ${this._copilotCollapsed ? html`
<button class="topbar-copilot-btn" title="Open copilot" <button class="topbar-copilot-btn" title=${t('topbar.open_chat')}
@click=${() => window.dispatchEvent(new CustomEvent('copilot-open'))}> @click=${() => window.dispatchEvent(new CustomEvent('copilot-open'))}>
<i class="bi bi-stars"></i> <i class="bi bi-stars"></i>
</button> </button>
` : ''} ` : ''}
<button class="topbar-theme-btn" title="${isDark ? 'Switch to light mode' : 'Switch to dark mode'}" <button class="topbar-theme-btn" title="${isDark ? t('topbar.to_light') : t('topbar.to_dark')}"
@click=${() => this._toggleTheme()}> @click=${() => this._toggleTheme()}>
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i> <i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
</button> </button>
<div class="topbar-profile-wrapper"> <div class="topbar-profile-wrapper">
<button class="topbar-avatar" title="Account" @click=${(e) => this._toggleMenu(e)}> <button class="topbar-avatar" style="background:${this._avatarColor}" title=${t('topbar.account')} @click=${(e) => this._toggleMenu(e)}>
${this._initial} ${this._initial}
</button> </button>
${this._menuOpen ? html` ${this._menuOpen ? html`
@@ -91,10 +104,10 @@ export class AppTopbar extends LightElement {
<div class="topbar-dropdown-sub">@${this._me?.username || ''}</div> <div class="topbar-dropdown-sub">@${this._me?.username || ''}</div>
</div> </div>
<button class="topbar-dropdown-item" @click=${() => this._goProfile()}> <button class="topbar-dropdown-item" @click=${() => this._goProfile()}>
<i class="bi bi-person"></i> Profile <i class="bi bi-person"></i> ${t('topbar.profile')}
</button> </button>
<button class="topbar-dropdown-item topbar-dropdown-logout" @click=${() => this._logout()}> <button class="topbar-dropdown-item topbar-dropdown-logout" @click=${() => this._logout()}>
<i class="bi bi-box-arrow-right"></i> Logout <i class="bi bi-box-arrow-right"></i> ${t('topbar.logout')}
</button> </button>
</div> </div>
` : nothing} ` : nothing}
+47 -41
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class UsersPage extends LightElement { export class UsersPage extends LightElement {
@@ -24,6 +26,8 @@ export class UsersPage extends LightElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'users'; this._open = e.detail.page === 'users';
this.style.display = this._open ? 'flex' : 'none'; 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() { async _load() {
this._error = null; this._error = null;
try { try {
@@ -81,7 +90,7 @@ export class UsersPage extends LightElement {
this._error = null; this._error = null;
if (mode === 'create') { 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 { try {
const res = await fetch('/api/users', { const res = await fetch('/api/users', {
method: 'POST', method: 'POST',
@@ -100,7 +109,7 @@ export class UsersPage extends LightElement {
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
} else if (mode === 'edit') { } else if (mode === 'edit') {
const { user } = this._modal; 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 { try {
const res = await fetch(`/api/users/${user.id}`, { const res = await fetch(`/api/users/${user.id}`, {
method: 'PUT', method: 'PUT',
@@ -118,7 +127,7 @@ export class UsersPage extends LightElement {
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
} else if (mode === 'password') { } else if (mode === 'password') {
const { user } = this._modal; 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 { try {
const res = await fetch(`/api/users/${user.id}/password`, { const res = await fetch(`/api/users/${user.id}/password`, {
method: 'POST', method: 'POST',
@@ -132,7 +141,7 @@ export class UsersPage extends LightElement {
} }
async _delete(user) { 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 { try {
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' }); const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -149,9 +158,9 @@ export class UsersPage extends LightElement {
_renderModal() { _renderModal() {
if (!this._modal) return nothing; if (!this._modal) return nothing;
const { mode, form, user } = this._modal; const { mode, form, user } = this._modal;
const title = mode === 'create' ? 'New user' const title = mode === 'create' ? t('users.modal.create_title')
: mode === 'edit' ? `Edit ${user.username}` : mode === 'edit' ? t('users.modal.edit_title', { username: user.username })
: `Reset password — ${user.username}`; : t('users.modal.reset_title', { username: user.username });
return html` return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> <div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
@@ -166,45 +175,43 @@ export class UsersPage extends LightElement {
${mode === 'create' ? html` ${mode === 'create' ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">${t('users.modal.username')}</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} /> <input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label> <label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} /> <input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">${t('users.modal.role')}</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}> <select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)} ${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">${t('users.modal.password')}</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} /> <input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="um-enc" <input class="form-check-input" type="checkbox" id="um-enc"
.checked=${form.encrypted} .checked=${form.encrypted}
@change=${e => this._patch('encrypted', e.target.checked)} /> @change=${e => this._patch('encrypted', e.target.checked)} />
<label class="form-check-label" for="um-enc">Encrypt conversation history</label> <label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
</div> </div>
${form.encrypted ? html` ${form.encrypted ? html`
<div class="setup-warn mt-2"> <div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
<strong>Warning:</strong> if the password is lost, the conversation history is permanently unrecoverable.
</div>
` : nothing} ` : nothing}
` : mode === 'edit' ? html` ` : mode === 'edit' ? html`
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">${t('users.modal.username')}</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} /> <input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label> <label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} /> <input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">${t('users.modal.role')}</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}> <select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)} ${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select> </select>
@@ -213,23 +220,22 @@ export class UsersPage extends LightElement {
<input class="form-check-input" type="checkbox" id="um-active" <input class="form-check-input" type="checkbox" id="um-active"
.checked=${form.active} .checked=${form.active}
@change=${e => this._patch('active', e.target.checked)} /> @change=${e => this._patch('active', e.target.checked)} />
<label class="form-check-label" for="um-active">Active</label> <label class="form-check-label" for="um-active">${t('users.modal.active')}</label>
</div> </div>
` : html` ` : html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.82rem"> <div class="alert alert-warning py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
Only works for cleartext (non-encrypted) users.
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">New password</label> <label class="form-label">${t('users.modal.new_password')}</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} /> <input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div> </div>
`} `}
</div> </div>
<div class="um-modal-footer"> <div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}> <button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : mode === 'edit' ? 'Save' : 'Reset'} <i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('users.modal.create_btn') : mode === 'edit' ? t('users.modal.save_btn') : t('users.modal.reset_btn')}
</button> </button>
</div> </div>
</div> </div>
@@ -245,11 +251,11 @@ export class UsersPage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>Users</h2> <h2 class="um-title"><i class="bi bi-people-fill me-2"></i>${t('users.title')}</h2>
<div class="um-header-right"> <div class="um-header-right">
<span class="um-header-count">${users.length} user${users.length === 1 ? '' : 's'}</span> <span class="um-header-count">${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}> <button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New user <i class="bi bi-plus-lg me-1"></i>${t('users.btn.new')}
</button> </button>
</div> </div>
</div> </div>
@@ -259,17 +265,17 @@ export class UsersPage extends LightElement {
` : nothing} ` : nothing}
<div class="um-table-wrap"> <div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : users.length === 0 ? html` ${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>` : users.length === 0 ? html`
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</p></div> <div class="um-empty"><i class="bi bi-people"></i><p>${t('users.empty')}</p></div>
` : html` ` : html`
<table class="um-table"> <table class="um-table">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>${t('users.table.username')}</th>
<th>Display name</th> <th>${t('users.table.display_name')}</th>
<th>Role</th> <th>${t('users.table.role')}</th>
<th>DB</th> <th>${t('users.table.db')}</th>
<th>Status</th> <th>${t('users.table.status')}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -280,20 +286,20 @@ export class UsersPage extends LightElement {
<td>${u.display_name ?? '—'}</td> <td>${u.display_name ?? '—'}</td>
<td>${this._roleLabel(u.role_id)}</td> <td>${this._roleLabel(u.role_id)}</td>
<td>${u.encrypted <td>${u.encrypted
? html`<span class="um-badge um-badge-encrypted">Encrypted</span>` ? html`<span class="um-badge um-badge-encrypted">${t('users.badge.encrypted')}</span>`
: html`<span class="um-badge um-badge-clear">Cleartext</span>`}</td> : html`<span class="um-badge um-badge-clear">${t('users.badge.cleartext')}</span>`}</td>
<td>${u.active <td>${u.active
? html`<span class="um-badge um-badge-active">Active</span>` ? html`<span class="um-badge um-badge-active">${t('users.badge.active')}</span>`
: html`<span class="um-badge um-badge-inactive">Inactive</span>`}</td> : html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}</td>
<td> <td>
<div class="um-actions"> <div class="um-actions">
<button class="um-btn-icon" title="Reset password" @click=${() => this._openPassword(u)}> <button class="um-btn-icon" title=${t('users.action.reset_pw')} @click=${() => this._openPassword(u)}>
<i class="bi bi-key"></i> <i class="bi bi-key"></i>
</button> </button>
<button class="um-btn-icon" title="Edit" @click=${() => this._openEdit(u)}> <button class="um-btn-icon" title=${t('users.action.edit')} @click=${() => this._openEdit(u)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="um-btn-icon" title="Delete" @click=${() => this._delete(u)}> <button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
+18 -18
View File
@@ -17,8 +17,8 @@
} }
.copilot-composer:focus-within { .copilot-composer:focus-within {
border-color: #6366f1; 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);
} }
.copilot-textarea { .copilot-textarea {
@@ -26,9 +26,9 @@
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
font-size: 0.85rem; font-size: 0.95rem;
line-height: 1.55; line-height: 1.6;
padding: 0.6rem 0.75rem 0.4rem; padding: 0.65rem 0.85rem 0.45rem;
min-height: 2.6rem; min-height: 2.6rem;
max-height: 14rem; max-height: 14rem;
overflow-y: auto; overflow-y: auto;
@@ -59,11 +59,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.8rem; width: 2rem;
height: 1.8rem; height: 2rem;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 0.4rem; border-radius: 0.5rem;
background: transparent; background: transparent;
color: var(--placeholder-color); color: var(--placeholder-color);
font-size: 0.85rem; font-size: 0.85rem;
@@ -99,14 +99,14 @@
.copilot-model-pill:hover, .copilot-model-pill:hover,
.copilot-model-pill.open { .copilot-model-pill.open {
background: rgba(99, 102, 241, 0.08); background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(99, 102, 241, 0.3); border-color: rgba(var(--accent-rgb), 0.3);
color: #6366f1; color: var(--accent);
} }
.copilot-model-pill i:first-child { .copilot-model-pill i:first-child {
font-size: 0.75rem; font-size: 0.75rem;
color: #6366f1; color: var(--accent);
} }
.copilot-model-overlay { .copilot-model-overlay {
@@ -141,8 +141,8 @@
transition: background 0.1s; transition: background 0.1s;
} }
.copilot-model-item:hover { background: rgba(99, 102, 241, 0.07); } .copilot-model-item:hover { background: rgba(var(--accent-rgb), 0.07); }
.copilot-model-item.active { color: #6366f1; font-weight: 600; } .copilot-model-item.active { color: var(--accent); font-weight: 600; }
/* ── Slash-command autocomplete ─────────────────────────────────────────────── */ /* ── Slash-command autocomplete ─────────────────────────────────────────────── */
@@ -177,9 +177,9 @@
} }
.copilot-cmd-item:hover, .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 { .copilot-cmd-desc {
color: var(--text-muted, #888); color: var(--text-muted, #888);
@@ -199,7 +199,7 @@
padding: 0; padding: 0;
border: none; border: none;
border-radius: 0.45rem; border-radius: 0.45rem;
background: #6366f1; background: var(--accent);
color: #fff; color: #fff;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
@@ -207,7 +207,7 @@
flex-shrink: 0; 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 { background: #dc2626; }
.copilot-send-btn--stop:hover { background: #b91c1c; } .copilot-send-btn--stop:hover { background: #b91c1c; }
.copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; } .copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; }
+20 -20
View File
@@ -3,19 +3,19 @@
.copilot-messages { .copilot-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.7rem;
} }
/* ── Message bubbles ───────────────────────────────────────────────────────── */ /* ── Message bubbles ───────────────────────────────────────────────────────── */
.copilot-msg { .copilot-msg {
padding: 0.6rem 0.9rem; padding: 0.65rem 1rem;
border-radius: 0.75rem; border-radius: 1rem;
font-size: 0.85rem; font-size: 0.95rem;
line-height: 1.55; line-height: 1.6;
max-width: 88%; max-width: 88%;
} }
@@ -159,17 +159,17 @@
.copilot-tool-path { .copilot-tool-path {
font-family: var(--bs-font-monospace); font-family: var(--bs-font-monospace);
font-size: inherit; font-size: inherit;
color: #6366f1; color: var(--accent);
cursor: pointer; cursor: pointer;
border-radius: 0.2rem; border-radius: 0.2rem;
padding: 0 0.25em; padding: 0 0.25em;
background: rgba(99,102,241,0.10); background: rgba(var(--accent-rgb), 0.10);
text-decoration: none; text-decoration: none;
} }
.copilot-tool-path:hover { .copilot-tool-path:hover {
text-decoration: underline; text-decoration: underline;
background: rgba(99,102,241,0.18); background: rgba(var(--accent-rgb), 0.18);
} }
.copilot-tool-body { .copilot-tool-body {
@@ -285,7 +285,7 @@
.copilot-markdown pre code { background: none; padding: 0; font-size: inherit; } .copilot-markdown pre code { background: none; padding: 0; font-size: inherit; }
.copilot-markdown blockquote { .copilot-markdown blockquote {
border-left: 3px solid #6366f1; border-left: 3px solid var(--accent);
margin: 0.5rem 0; margin: 0.5rem 0;
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
color: var(--placeholder-color); color: var(--placeholder-color);
@@ -298,7 +298,7 @@
margin: 0.6rem 0; 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 strong { font-weight: 700; }
.copilot-markdown em { font-style: italic; } .copilot-markdown em { font-style: italic; }
@@ -343,7 +343,7 @@
.copilot-approval-path { .copilot-approval-path {
font-family: var(--bs-font-monospace); font-family: var(--bs-font-monospace);
font-size: 0.75rem; font-size: 0.75rem;
color: #6366f1; color: var(--accent);
} }
.copilot-approval-actions { .copilot-approval-actions {
@@ -450,7 +450,7 @@
.copilot-agent, .copilot-agent,
.copilot-agent-end { .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; border-radius: 0.5rem;
font-size: 0.78rem; font-size: 0.78rem;
overflow: clip; overflow: clip;
@@ -462,18 +462,18 @@
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
padding: 0.35rem 0.65rem; padding: 0.35rem 0.65rem;
background: rgba(99, 102, 241, 0.12); background: rgba(var(--accent-rgb), 0.12);
color: var(--msg-assistant-text); color: var(--msg-assistant-text);
} }
.copilot-agent-header i { .copilot-agent-header i {
font-size: 0.85rem; font-size: 0.85rem;
flex-shrink: 0; flex-shrink: 0;
color: #6366f1; color: var(--accent);
} }
.copilot-agent-header strong { .copilot-agent-header strong {
color: #6366f1; color: var(--accent);
font-weight: 600; font-weight: 600;
} }
@@ -505,10 +505,10 @@
margin: 0; margin: 0;
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
color: var(--placeholder-color); 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; max-height: 120px;
overflow-y: auto; overflow-y: auto;
background: rgba(99, 102, 241, 0.05); background: rgba(var(--accent-rgb), 0.05);
} }
.copilot-agent-preview--result { .copilot-agent-preview--result {
@@ -519,7 +519,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.copilot-agent-badge.running { background: rgba(234, 179, 8, 0.2); color: #fbbf24; } .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-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) ──────────────────── */ /* ── Attachment chips (composer pending + sent user bubble) ──────────────────── */
@@ -550,7 +550,7 @@
} }
.attach-chip--clickable { cursor: pointer; } .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--uploading { opacity: 0.7; }
.attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; } .attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; }
+118 -5
View File
@@ -11,13 +11,126 @@ app-copilot {
flex-shrink: 0; flex-shrink: 0;
} }
app-copilot.collapsed { app-copilot.collapsed:not([mode="full"]) {
width: 0; width: 0;
min-width: 0; min-width: 0;
border: none; border: none;
overflow: hidden; 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 { .copilot-resize-handle {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -31,7 +144,7 @@ app-copilot.collapsed {
.copilot-resize-handle:hover, .copilot-resize-handle:hover,
.copilot-resize-handle:active { .copilot-resize-handle:active {
background: rgba(99, 102, 241, 0.35); background: rgba(var(--accent-rgb), 0.35);
} }
.copilot-expand-btn { .copilot-expand-btn {
@@ -43,7 +156,7 @@ app-copilot.collapsed {
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: #6366f1; color: var(--accent);
font-size: 1.1rem; font-size: 1.1rem;
writing-mode: vertical-rl; writing-mode: vertical-rl;
padding: 1rem 0; padding: 1rem 0;
@@ -72,7 +185,7 @@ app-copilot.collapsed {
.copilot-header i { .copilot-header i {
font-size: 1rem; font-size: 1rem;
color: #6366f1; color: var(--accent);
} }
/* ── Tabs (General + project chats) ─────────────────────────────────────────── */ /* ── Tabs (General + project chats) ─────────────────────────────────────────── */
@@ -105,7 +218,7 @@ app-copilot.collapsed {
.copilot-tab--active { .copilot-tab--active {
color: var(--bs-body-color, inherit); color: var(--bs-body-color, inherit);
border-bottom-color: #6366f1; border-bottom-color: var(--accent);
font-weight: 600; font-weight: 600;
} }
+1 -1
View File
@@ -102,7 +102,7 @@
} }
.fv-md hr { border: none; border-top: 1px solid var(--bs-border-color); margin: 1.25rem 0; } .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 strong { font-weight: 700; }
.fv-md em { font-style: italic; } .fv-md em { font-style: italic; }
+2 -30
View File
@@ -1,6 +1,6 @@
/* ── Home page ─────────────────────────────────────────────────────────────── */ /* ── Dashboard page ────────────────────────────────────────────────────────── */
home-page { dashboard-page {
display: none; display: none;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
@@ -13,34 +13,6 @@ home-page {
box-sizing: border-box; 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 ──────────────────────────────────────────────────────────────────── */ /* ── Hero ──────────────────────────────────────────────────────────────────── */
.home-hero { .home-hero {
+4 -4
View File
@@ -371,7 +371,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
.chat-page-composer:focus-within { .chat-page-composer:focus-within {
border-color: var(--accent); 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 { .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:focus,
.chat-page-model-pill:hover { .chat-page-model-pill:hover {
border-color: rgba(99, 102, 241, 0.3); border-color: rgba(var(--accent-rgb), 0.3);
} }
/* ── Mic + send buttons ────────────────────────────────────────────────────── */ /* ── Mic + send buttons ────────────────────────────────────────────────────── */
@@ -559,8 +559,8 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
justify-content: center; justify-content: center;
font-size: 1.3rem; font-size: 1.3rem;
color: #fff; color: #fff;
background: linear-gradient(135deg, var(--accent, #6366f1), var(--accent-hover, #4f46e5)); background: linear-gradient(135deg, var(--accent, var(--accent)), var(--accent-hover, var(--accent-hover)));
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35);
} }
.project-card-main { .project-card-main {
+2 -2
View File
@@ -164,7 +164,7 @@
padding: 0.15em 0.5em; padding: 0.15em 0.5em;
border-radius: 4px; border-radius: 4px;
background: var(--bs-primary-bg-subtle, #eef2ff); background: var(--bs-primary-bg-subtle, #eef2ff);
color: var(--bs-primary, #4f46e5); color: var(--bs-primary, var(--accent-hover));
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
@@ -261,7 +261,7 @@
} }
.llm-params-pill { .llm-params-pill {
background: #6366f1 !important; background: var(--accent) !important;
color: #fff !important; color: #fff !important;
} }
+1
View File
@@ -73,6 +73,7 @@ file-viewer-page {
users-page, users-page,
roles-page, roles-page,
shared-folders-page,
connectors-page, connectors-page,
connector-detail-page, connector-detail-page,
marketplace-page, marketplace-page,
+10 -10
View File
@@ -17,8 +17,8 @@ app-sidebar {
gap: 0.55rem; gap: 0.55rem;
padding: 0 1rem 1.25rem; padding: 0 1rem 1.25rem;
color: var(--sidebar-brand-color); color: var(--sidebar-brand-color);
font-size: 0.95rem; font-size: 1.05rem;
font-weight: 600; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
@@ -89,11 +89,11 @@ app-sidebar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
padding: 0.20rem 0.30rem; padding: 0.4rem 0.55rem;
color: var(--sidebar-text); color: var(--sidebar-text);
text-decoration: none; text-decoration: none;
font-size: 0.875rem; font-size: 0.92rem;
border-radius: 0.25rem; border-radius: 0.5rem;
transition: background 0.12s, color 0.12s; transition: background 0.12s, color 0.12s;
} }
@@ -119,8 +119,8 @@ app-sidebar {
.sidebar-link.active { .sidebar-link.active {
background: var(--sidebar-active-bg); background: var(--sidebar-active-bg);
color: var(--sidebar-text-active); color: var(--sidebar-text-active);
font-weight: 500; font-weight: 600;
border-radius: 0.25rem; border-radius: 0.5rem;
} }
.sidebar-link.active i { .sidebar-link.active i {
@@ -183,11 +183,11 @@ app-sidebar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
padding: 0.18rem 0.40rem; padding: 0.32rem 0.55rem;
color: var(--sidebar-text); color: var(--sidebar-text);
text-decoration: none; text-decoration: none;
font-size: 0.84rem; font-size: 0.86rem;
border-radius: 0.25rem; border-radius: 0.5rem;
transition: background 0.12s, color 0.12s; transition: background 0.12s, color 0.12s;
} }
+4 -4
View File
@@ -130,13 +130,13 @@
} }
.task-badge--cron { .task-badge--cron {
background: rgba(99, 102, 241, 0.12); background: rgba(var(--accent-rgb), 0.12);
color: var(--bs-primary, #6366f1); color: var(--bs-primary, var(--accent));
} }
.task-badge--sync { .task-badge--sync {
background: rgba(99, 102, 241, 0.12); background: rgba(var(--accent-rgb), 0.12);
color: var(--bs-primary, #6366f1); color: var(--bs-primary, var(--accent));
} }
.task-badge--async { .task-badge--async {
+14 -14
View File
@@ -12,8 +12,8 @@ app-topbar {
} }
.topbar-title { .topbar-title {
font-size: 0.8rem; font-size: 0.9rem;
font-weight: 600; font-weight: 700;
color: var(--sidebar-brand-color); color: var(--sidebar-brand-color);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
@@ -26,10 +26,10 @@ app-topbar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 30px;
height: 24px; height: 30px;
border: none; border: none;
border-radius: 5px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--sidebar-text); color: var(--sidebar-text);
cursor: pointer; cursor: pointer;
@@ -50,12 +50,12 @@ app-topbar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 30px;
height: 24px; height: 30px;
border: none; border: none;
border-radius: 5px; border-radius: 8px;
background: transparent; background: transparent;
color: #6366f1; color: var(--accent);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
padding: 0; padding: 0;
@@ -81,13 +81,13 @@ app-topbar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 30px;
height: 26px; height: 30px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
font-size: 0.72rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: background 0.15s, transform 0.1s; transition: background 0.15s, transform 0.1s;
@@ -95,7 +95,7 @@ app-topbar {
} }
.topbar-avatar:hover { .topbar-avatar:hover {
background: var(--accent-hover); filter: brightness(0.9);
} }
.topbar-dropdown { .topbar-dropdown {
@@ -105,7 +105,7 @@ app-topbar {
min-width: 200px; min-width: 200px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 8px; border-radius: var(--radius-md);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
padding: 6px; padding: 6px;
z-index: 100; z-index: 100;

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