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
@@ -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` |
|
||||
| `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, the Tauri `desktop/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new` |
|
||||
| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking whether to encrypt, default yes). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) |
|
||||
| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) |
|
||||
| `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types |
|
||||
|
||||
Two rules keep the boundary real, and both are enforced by the compiler:
|
||||
@@ -113,7 +113,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
|
||||
|
||||
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration.
|
||||
|
||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`).
|
||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven UI conventions live in the free-form `roles.attrs` JSON (e.g. `ui_mode`, see the frontend section) — never new columns per attribute.
|
||||
|
||||
## Filesystem & containers (blueprint §6)
|
||||
|
||||
@@ -254,14 +254,22 @@ To add a Python dependency: add it to `requirements.txt`. It will be installed o
|
||||
|
||||
All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/chat-session.js`) is the shared base for WS-connected chat UIs.
|
||||
|
||||
**The chat is the home page.** `<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 |
|
||||
| ---- | ------- | ----- |
|
||||
| `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'`) |
|
||||
| `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 |
|
||||
| `topbar.js` | `<app-topbar>` | Top nav bar |
|
||||
| `home-page.js` | `<home-page>` | Landing / dashboard |
|
||||
| `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; per-user avatar color hashed from the username |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -5644,6 +5644,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"rpassword",
|
||||
"skald-core",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> ⚠️ **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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -4,48 +4,41 @@ Each agent in the `agents/` directory can have an icon/avatar declared in the `"
|
||||
|
||||
## Visual style
|
||||
|
||||
Icons were generated with **xAI Grok Imagine** in a **concept art / character design** style:
|
||||
Icons are generated with **Vector Paintings** LoRA via ComfyUI in a warm, family-friendly style:
|
||||
|
||||
- **Style**: illustrated, not photorealistic, not flat vector, not anime
|
||||
- **Technique**: bold brushstrokes, rich colours, depth, video game concept art quality (Overwatch / Arcane / Hades)
|
||||
- **Format**: portrait (vertical rectangle)
|
||||
- **Background**: medium-bright, not dark, no neon
|
||||
- **Subject**: a character / living being representing the agent's role, with contextual elements (tools, holograms, symbols)
|
||||
- **Palette**: varies per agent, generally warm with one dominant colour
|
||||
- **Style**: painterly vector — bold shapes fused with expressive brushstrokes
|
||||
- **Technique**: vivid colours, emotion, motion, warm lighting
|
||||
- **Format**: square (1024×1024), rendered as a character portrait
|
||||
- **Background**: warm, cozy, medium-bright (no dark/no neon)
|
||||
- **Subject**: a warm animal character representing the agent's role, with contextual elements (tools, symbols, objects)
|
||||
- **Palette**: terracotta, amber, warm gold, coral, soft teal — warm and inviting
|
||||
- **Trigger word**: `VectorPaintDaal` must be included at the start of the prompt
|
||||
|
||||
## Base prompt template
|
||||
## Prompt template
|
||||
|
||||
```
|
||||
Stylized character portrait of an AI agent called "{NAME}".
|
||||
Concept art style with bold brushstrokes and rich colors.
|
||||
{character description and surrounding visual elements}
|
||||
{dominant colours}
|
||||
Illustrated character design, not photorealistic, not flat vector, not anime.
|
||||
Video game concept art quality.
|
||||
Portrait format, vertical.
|
||||
High detail, expressive.
|
||||
VectorPaintDaal. A warm friendly {ANIMAL} character with a gentle smile, wearing {CLOTHING/ACCESSORIES}. It holds {OBJECT} and around it float {SYMBOLS}. Warm golden light, cozy atmosphere. {DOMINANT_COLOURS} palette. Expressive bold brushstrokes, painterly vector style. Family-friendly illustration, portrait of a kind {ROLE}.
|
||||
```
|
||||
|
||||
## Per-agent reference
|
||||
|
||||
| Agent | Subject | Palette |
|
||||
|-------|---------|---------|
|
||||
| **Architect** | Visionary with floating architectural blueprints and geometry | Blue & teal |
|
||||
| **Engineer** | Technician/cyborg with holographic tools, gears, circuits | Amber & steel blue |
|
||||
| **Explorer** | Curious analyst with magnifying glass, floating code and data trails | Deep blue & gold |
|
||||
| **Researcher** | Scientist with smart glasses, floating documents, magnifier | Purple & teal |
|
||||
| **Main Assistant** | Central charismatic leader with luminous geometric shapes | Purple & gold |
|
||||
| **TIC** | Mysterious figure with multiple eyes, radar, data nodes | Dark purple & cyan |
|
||||
| **Tinker** | Clever craftsperson with multitool, gears, repair tools | Orange & steel grey |
|
||||
| **Worker** | Practical person with futuristic toolbelt and mechanical elements | Orange & steel grey |
|
||||
| **Blueprint** | Scholarly figure with floating scrolls and glowing quills writing words in mid-air, luminous documents orbiting | Deep indigo & burnished gold |
|
||||
| **Tech Lead** | Confident strategist at a holographic kanban board, task cards floating mid-air, sub-agents visible in the background | Warm amber & deep teal |
|
||||
| **Project Coordinator** | Central orchestrator with glowing connected nodes, satellite sub-agents orbiting, holographic project maps and branching task flows | Teal & warm gold |
|
||||
|
||||
| Agent | Animal | Role | Elements | Palette |
|
||||
|-------|--------|------|----------|---------|
|
||||
| **Main Assistant** 🦊 | Fox | General assistant | Glowing threads connecting a heart, star, house | Terracotta, amber, gold |
|
||||
| **Project Coordinator** 🦡 | Badger | Family coordinator | Floating threads linking heart, star, house, smiling face; cozy kitchen table | Terracotta, amber, gold, coral |
|
||||
| **Researcher** 🐿️ | Squirrel | Curious researcher | Glowing book, magnifying glass, compass, scrolls, stars | Terracotta, amber, soft teal, coral |
|
||||
| **Generalist** 🦫 | Beaver | Handy executor | Glowing multitool, wrench, paintbrush, trowel, cooking pot | Terracotta, orange, amber, timber |
|
||||
| **Code Explorer** 🕵️ | Meerkat | Curious analyst | Magnifying glass, data trails, sparkling code symbols | Terracotta, amber, deep blue, gold |
|
||||
| **Software Architect** 🏗️ | Heron | Thoughtful planner | Floating blueprints, geometric shapes, building blocks | Terracotta, soft teal, amber, pale gold |
|
||||
| **Software Engineer** 🔧 | Bear | Focused builder | Glowing wrench, gears, circuit board, hammer, sparks | Terracotta, orange, amber, steel grey |
|
||||
| **Spec Writer** 📝 | Owl | Wise scribe | Glowing quill, scrolls, open books, words floating mid-air | Deep indigo, burnished gold, amber, cream |
|
||||
| **Tech Lead** 👑 | Stag | Confident strategist | Holographic kanban board, task cards, sub-agent symbols | Warm amber, deep teal, gold, coral |
|
||||
| **TIC** 👁️ | Cat | Watchful guardian | Sensor nodes, radar arcs, notification symbols (bell, letter, calendar) | Dark purple, amber, soft cyan, warm grey |
|
||||
| **Business Analyst** 💼 | Magpie | Thoughtful evaluator | Glowing clipboard, floating documents, abacus, data points | Deep indigo, gold, soft teal, amber |
|
||||
|
||||
## Adding a new agent icon
|
||||
|
||||
1. Generate the image using the prompt template above
|
||||
1. Generate the image using the Vector Paintings prompt template above (include `VectorPaintDaal` at the start)
|
||||
2. Save it as `agents/{agent_id}/icon.png`
|
||||
3. Add `"icon": "icon.png"` to the agent's `meta.json`
|
||||
3. Add `"icon": "icon.png"` to the agent's `meta.json` (if not already present)
|
||||
4. No code changes needed — the backend serves whatever file path is declared in the manifest
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -5,5 +5,6 @@
|
||||
"instructions": "Pass the idea, the draft business plan, and any market/competitor evidence you have. Specify an output path/dir for the critique report. The more evidence you provide, the sharper the critique — missing evidence is flagged as open questions, not guessed.",
|
||||
"type": "task",
|
||||
"scope": "reasoning",
|
||||
"strength": "high"
|
||||
"strength": "high",
|
||||
"icon": "icon.png"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 482 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -18,6 +18,7 @@
|
||||
//! fs-tools *before* reaching here; `UserFs` only ever sees physical paths.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// One shared folder mounted into a user's container.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -128,6 +129,40 @@ fn strip_home_prefix(path: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hot-swappable handle to a [`UserFs`] snapshot, shared by every holder that
|
||||
/// must observe a membership change without being rebuilt (blueprint §6 remount).
|
||||
///
|
||||
/// Cloning shares the *same* cell. `store` replaces the snapshot for all clones at
|
||||
/// once; each `load` returns the current `Arc<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.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -39,6 +40,9 @@ pub const HOMES_DIR: &str = "homes";
|
||||
pub const SHARED_DIR: &str = "shared";
|
||||
/// Home mount point inside the container.
|
||||
pub const CONTAINER_HOME: &str = "/root";
|
||||
/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL)
|
||||
/// before force-killing — enough for a shell or MCP `docker exec` child to exit.
|
||||
const STOP_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The deterministic container name for a user — derivable without any manager,
|
||||
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly.
|
||||
@@ -203,6 +207,38 @@ impl ContainerManager {
|
||||
let _ = docker(&["rm", "-f", &name]).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shuts down a user's container: `docker stop` sends SIGTERM to the
|
||||
/// in-container processes and waits up to `STOP_GRACE` before SIGKILL, so an
|
||||
/// in-flight `execute_cmd` shell (and any per-user MCP `docker exec` child) gets
|
||||
/// a window to exit cleanly instead of vanishing mid-write. Best-effort: a
|
||||
/// missing or already-stopped container is fine.
|
||||
pub async fn stop(&self, user_id: &str) -> Result<()> {
|
||||
let name = container_name(user_id);
|
||||
let secs = STOP_GRACE.as_secs().to_string();
|
||||
if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
|
||||
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleanly recreates a user's container so it picks up a changed mount topology
|
||||
/// — e.g. a shared-folder membership change (§6), whose mounts are fixed at
|
||||
/// `docker create` time and cannot be altered on a live container. Graceful
|
||||
/// [`stop`](Self::stop) → remove → [`ensure`](Self::ensure) (which rebuilds the
|
||||
/// mount set from the current memberships and recreates the host dirs). The
|
||||
/// container holds no durable state — everything lives in the bind mounts — so a
|
||||
/// recreate is safe by construction. A no-op-safe `rm` (the container is already
|
||||
/// stopped) precedes `ensure`, which then finds it absent and creates it fresh.
|
||||
///
|
||||
/// Caveat (caller's concern, not this method's): the per-user MCP runtime and a
|
||||
/// logged-in user's `UserFs` snapshot are both bound to the old container/
|
||||
/// membership and are NOT refreshed here — see the shared-folders remount wiring.
|
||||
pub async fn recreate(&self, user_id: &str) -> Result<()> {
|
||||
self.stop(user_id).await?;
|
||||
let _ = docker(&["rm", &container_name(user_id)]).await;
|
||||
self.ensure(user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── docker CLI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -400,6 +400,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
database_password BLOB,
|
||||
password_hash BLOB,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
locale TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
@@ -410,6 +411,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Per-user UI locale override is additive — reaches an existing DB in place.
|
||||
ensure_column(pool, "users", "locale", "TEXT").await?;
|
||||
|
||||
// Shared on-disk folders (blueprint §6/§0.1): a named directory
|
||||
// `{WD}/shared/{folder_name}` bind-mounted into the container of each member.
|
||||
@@ -422,11 +425,16 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
"CREATE TABLE IF NOT EXISTS shared_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// The folder's description is injected into the agent's system context so it
|
||||
// knows what each shared folder holds and when to read/write it. Additive —
|
||||
// reaches an existing DB in place (a no-op on the fresh CREATE above).
|
||||
ensure_column(pool, "shared_folders", "description", "TEXT NOT NULL DEFAULT ''").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS shared_folder_members (
|
||||
|
||||
@@ -25,6 +25,12 @@ pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script";
|
||||
/// Curate the connector catalog (admin only).
|
||||
pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
|
||||
/// Manage shared on-disk folders — create/describe/delete and grant membership
|
||||
/// (blueprint §6). Admin-only for now; not in [`DEFAULT_USER_CAPABILITIES`], so
|
||||
/// `admin` holds it implicitly (via [`has`]) and opening it to another role later
|
||||
/// is a single [`grant`], no code change.
|
||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ use sqlx::SqlitePool;
|
||||
pub struct SharedFolder {
|
||||
pub id: i64,
|
||||
pub folder_name: String,
|
||||
/// What the folder holds — injected into the agent's system context so it
|
||||
/// knows what to store here and when to read it. Admin-authored (§6).
|
||||
pub description: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -59,25 +62,50 @@ pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Share
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name",
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders ORDER BY folder_name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at })
|
||||
.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, folder_id: i64) -> Result<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>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
let row = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
)
|
||||
.bind(folder_name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at }))
|
||||
Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The members of a folder — the set of users whose containers mount it.
|
||||
@@ -98,15 +126,27 @@ pub async fn members(pool: &SqlitePool, folder_id: i64) -> Result<Vec<FolderMemb
|
||||
|
||||
/// Creates a folder, returning its id. `folder_name` must already be validated as
|
||||
/// a safe path component (see [`is_valid_folder_name`]).
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)")
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str, description: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name, description) VALUES (?, ?)")
|
||||
.bind(folder_name)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Updates a folder's description — the agent-facing text. No-op if `folder_id`
|
||||
/// no longer exists.
|
||||
pub async fn set_description(pool: &SqlitePool, folder_id: i64, description: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE shared_folders SET description = ? WHERE id = ?")
|
||||
.bind(description)
|
||||
.bind(folder_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds (or updates the capability of) a member. Idempotent on the PK.
|
||||
pub async fn add_member(
|
||||
pool: &SqlitePool,
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct User {
|
||||
pub role_id: String,
|
||||
pub credentials: Credentials,
|
||||
pub active: bool,
|
||||
/// UI locale override (NULL = follow the instance default).
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -78,6 +80,7 @@ pub struct UserSummary {
|
||||
pub role_id: String,
|
||||
pub encrypted: bool,
|
||||
pub active: bool,
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -95,6 +98,7 @@ impl User {
|
||||
role_id: self.role_id.clone(),
|
||||
encrypted: self.is_encrypted(),
|
||||
active: self.active,
|
||||
locale: self.locale.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
}
|
||||
@@ -140,6 +144,7 @@ struct Row {
|
||||
database_password: Option<Vec<u8>>,
|
||||
password_hash: Option<Vec<u8>>,
|
||||
active: bool,
|
||||
locale: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -150,7 +155,7 @@ macro_rules! select {
|
||||
($tail:literal) => {
|
||||
concat!(
|
||||
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ",
|
||||
"database_password, password_hash, active, created_at, updated_at FROM users ",
|
||||
"database_password, password_hash, active, locale, created_at, updated_at FROM users ",
|
||||
$tail
|
||||
)
|
||||
};
|
||||
@@ -185,6 +190,7 @@ impl TryFrom<Row> for User {
|
||||
role_id: r.role_id,
|
||||
credentials,
|
||||
active: r.active,
|
||||
locale: r.locale,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
@@ -370,6 +376,22 @@ pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: O
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets (or clears, with `None`) the user's UI locale override.
|
||||
pub async fn set_locale(pool: &SqlitePool, id: &str, locale: Option<&str>) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET locale = ?2, updated_at = datetime('now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(locale)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the directory row only. The caller still owns `database/{id}.db`:
|
||||
/// erasing a user means deleting that file too.
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -556,6 +578,7 @@ mod tests {
|
||||
role_id: "admin".into(),
|
||||
credentials: encrypted(),
|
||||
active: true,
|
||||
locale: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod cron;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod image_generate;
|
||||
pub mod i18n;
|
||||
pub mod inbox;
|
||||
pub mod latex;
|
||||
pub mod llm;
|
||||
|
||||
@@ -268,6 +268,17 @@ impl McpManager {
|
||||
self.descriptions.write().unwrap().remove(name);
|
||||
}
|
||||
|
||||
/// Stops **every** running server (each dropped client → `kill_on_drop` kills
|
||||
/// its child process) and forgets them. Used when a per-user container is
|
||||
/// recreated (§6 remount): the old `docker exec -i` children are bound to the
|
||||
/// now-gone container, so they must be torn down before reconnecting against
|
||||
/// the fresh one via [`connect_all`](Self::connect_all).
|
||||
pub fn stop_all(&self) {
|
||||
self.servers.write().unwrap().clear();
|
||||
self.errors.write().unwrap().clear();
|
||||
self.descriptions.write().unwrap().clear();
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<McpTool> {
|
||||
self.servers.read().unwrap().values()
|
||||
.flat_map(|s| s.tools().iter().cloned())
|
||||
|
||||
@@ -431,7 +431,9 @@ impl ChatSessionHandler {
|
||||
let ctx = ToolContext {
|
||||
session_id: self.session_id,
|
||||
pool: Arc::clone(&self.db),
|
||||
fs: Arc::clone(&self.fs),
|
||||
// Snapshot the fs cell for the duration of this tool call — a concurrent
|
||||
// shared-folder remount swaps the cell, the next call picks it up (§6).
|
||||
fs: self.fs.load(),
|
||||
};
|
||||
self.tools.run(name, &ctx, args)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::config::DatetimeConfig;
|
||||
use crate::db::{chat_history, chat_sessions_stack};
|
||||
use crate::events::ServerEvent;
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::SharedFs;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
@@ -272,8 +272,11 @@ pub struct ChatSessionHandler {
|
||||
pub(super) user_id: String,
|
||||
/// The owner's filesystem view (home + shared folders + container), threaded
|
||||
/// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and
|
||||
/// `execute_cmd` execs into the owner's container (blueprint §6).
|
||||
pub(super) fs: Arc<UserFs>,
|
||||
/// `execute_cmd` execs into the owner's container (blueprint §6). A **shared
|
||||
/// swappable cell** (not a snapshot): a shared-folder membership change is
|
||||
/// applied in place (§6 remount), so a live session picks it up on its next
|
||||
/// tool call without being rebuilt — see [`SharedFs`].
|
||||
pub(super) fs: SharedFs,
|
||||
pub(super) llm_manager: Arc<LlmManager>,
|
||||
pub(super) max_history_messages: usize,
|
||||
pub(super) max_tool_rounds: usize,
|
||||
@@ -343,7 +346,7 @@ impl ChatSessionHandler {
|
||||
db: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
fs: Arc<UserFs>,
|
||||
fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::{SharedFs, UserFs};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -29,8 +29,9 @@ pub struct ChatSessionManager {
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
/// The owner's filesystem view, threaded to each handler and on into every
|
||||
/// `ToolContext` (blueprint §6).
|
||||
user_fs: Arc<UserFs>,
|
||||
/// `ToolContext` (blueprint §6). A shared swappable cell so a shared-folder
|
||||
/// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions.
|
||||
user_fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
@@ -60,7 +61,7 @@ impl ChatSessionManager {
|
||||
db: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
user_fs: Arc<UserFs>,
|
||||
user_fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
@@ -171,7 +172,7 @@ impl ChatSessionManager {
|
||||
self.db.clone(),
|
||||
self.shared_pool.clone(),
|
||||
self.user_id.clone(),
|
||||
Arc::clone(&self.user_fs),
|
||||
self.user_fs.clone(),
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.max_history_messages,
|
||||
self.max_tool_rounds,
|
||||
@@ -197,4 +198,12 @@ impl ChatSessionManager {
|
||||
self.active.lock().await.insert(session_id, handler.clone());
|
||||
Ok(handler)
|
||||
}
|
||||
|
||||
/// Swaps in a refreshed filesystem view for this owner (blueprint §6 remount).
|
||||
/// Every live session's handler shares the same [`SharedFs`] cell, so the new
|
||||
/// membership reaches each on its next tool call — no handler eviction, no
|
||||
/// cross-session race.
|
||||
pub fn refresh_fs(&self, fs: UserFs) {
|
||||
self.user_fs.store(fs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,55 @@ impl Skald {
|
||||
}
|
||||
|
||||
fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts }
|
||||
|
||||
/// The user's runtime context IF it is already live (built), **without**
|
||||
/// building one — used to refresh a logged-in user in place. A user who never
|
||||
/// logged in has no snapshot to refresh; their next login builds a fresh one.
|
||||
pub async fn user_context_if_live(&self, user_id: &str) -> Option<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 config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
|
||||
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
|
||||
|
||||
@@ -346,7 +346,7 @@ impl Conversation {
|
||||
|
||||
// The ownerless manager is inert (no loops, no consumers — see §19): it takes
|
||||
// a placeholder UserFs purely to satisfy the type, never used to resolve a path.
|
||||
let ownerless_fs = Arc::new(core_api::user_fs::UserFs::new(
|
||||
let ownerless_fs = core_api::user_fs::SharedFs::new(core_api::user_fs::UserFs::new(
|
||||
String::new(),
|
||||
std::path::PathBuf::from("homes"),
|
||||
"skald-ownerless",
|
||||
|
||||
@@ -63,7 +63,7 @@ impl Runtime {
|
||||
users,
|
||||
sessions,
|
||||
config,
|
||||
config_properties: vec![crate::tic::config_set()],
|
||||
config_properties: vec![crate::i18n::config_set(), crate::tic::config_set()],
|
||||
system_bus,
|
||||
event_bus,
|
||||
global_tx,
|
||||
|
||||
@@ -35,7 +35,7 @@ use core_api::events::GlobalEvent;
|
||||
use core_api::inbox::InboxApi;
|
||||
use core_api::system_bus::SystemEventBus;
|
||||
use core_api::user_channel::UserChannelHandle;
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::SharedFs;
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::chat_event_bus::ChatEventBus;
|
||||
@@ -66,8 +66,10 @@ pub struct UserContext {
|
||||
pub user_id: String,
|
||||
pub pool: Arc<SqlitePool>,
|
||||
/// The owner's filesystem view (home + shared folders + container, §6),
|
||||
/// threaded into every `ToolContext` this user's sessions produce.
|
||||
pub fs: Arc<UserFs>,
|
||||
/// threaded into every `ToolContext` this user's sessions produce. A shared
|
||||
/// swappable cell so a shared-folder membership change is applied in place
|
||||
/// (§6 remount) rather than requiring a fresh login — see [`SharedFs`].
|
||||
pub fs: SharedFs,
|
||||
pub event_bus: Arc<ChatEventBus>,
|
||||
pub sessions: Arc<ChatSessionManager>,
|
||||
pub chat_hub: Arc<ChatHub>,
|
||||
@@ -151,8 +153,9 @@ impl UserContextFactory {
|
||||
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
|
||||
let pool = Arc::new(pool);
|
||||
// The owner's filesystem view: private home + shared folders + container.
|
||||
// Snapshotted at login; a membership change takes effect on next login (v1).
|
||||
let fs = Arc::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?);
|
||||
// A shared swappable cell — a shared-folder membership change is applied in
|
||||
// place while the user is live (§6 remount), not deferred to next login.
|
||||
let fs = SharedFs::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?);
|
||||
let event_bus = Arc::new(ChatEventBus::new());
|
||||
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
|
||||
|
||||
@@ -238,7 +241,7 @@ impl UserContextFactory {
|
||||
Arc::clone(&pool),
|
||||
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
|
||||
user_id.to_string(),
|
||||
Arc::clone(&fs),
|
||||
fs.clone(),
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.max_history_messages,
|
||||
self.max_tool_rounds,
|
||||
@@ -340,6 +343,13 @@ impl UserContextRegistry {
|
||||
guard.insert(user_id.to_string(), Arc::clone(&ctx));
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// The user's context IF already built (live), **without** building one. A user
|
||||
/// who has not logged in has no live snapshot to refresh (blueprint §6 remount):
|
||||
/// their next login builds a fresh context that already reflects the change.
|
||||
pub(super) async fn peek(&self, user_id: &str) -> Option<Arc<UserContext>> {
|
||||
self.contexts.lock().await.get(user_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
// ── UserChannelHandle impl ────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,5 +17,6 @@ path = "src/main.rs"
|
||||
skald-core = { path = "../skald-core" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
anyhow = "1"
|
||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||
# Reads a password without echoing it to the terminal.
|
||||
rpassword = "7"
|
||||
|
||||
@@ -94,10 +94,12 @@ fn usage() -> String {
|
||||
async fn run(mode: Mode) -> Result<std::process::ExitCode> {
|
||||
// Opening the pool creates `database/system.db` and its schema if absent —
|
||||
// the same call the server makes, so setup and server agree on the layout.
|
||||
let pool = db::init_system_pool(SYSTEM_DB_PATH)
|
||||
.await
|
||||
.context("opening the system database")?;
|
||||
let users = UserManager::new(std::sync::Arc::new(pool));
|
||||
let pool = std::sync::Arc::new(
|
||||
db::init_system_pool(SYSTEM_DB_PATH)
|
||||
.await
|
||||
.context("opening the system database")?,
|
||||
);
|
||||
let users = UserManager::new(std::sync::Arc::clone(&pool));
|
||||
|
||||
let has_admin = users.count().await.context("counting users")? > 0;
|
||||
|
||||
@@ -113,13 +115,13 @@ async fn run(mode: Mode) -> Result<std::process::ExitCode> {
|
||||
// Each is idempotent: it decides for itself whether there is work to do.
|
||||
// Today there is one. Provider and model setup will be added here as further
|
||||
// steps, in order, each skipping itself when already configured.
|
||||
step_first_user(&users, has_admin).await?;
|
||||
step_first_user(&users, &pool, has_admin).await?;
|
||||
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Create the first admin, or do nothing if one already exists.
|
||||
async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin: bool) -> Result<()> {
|
||||
if has_admin {
|
||||
// Idempotent re-run, or a second binary got there first.
|
||||
return Ok(());
|
||||
@@ -142,6 +144,7 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
let display_name = display_name.trim();
|
||||
let display_name = (!display_name.is_empty()).then_some(display_name);
|
||||
|
||||
let locale = prompt_locale()?;
|
||||
let encrypt = prompt_encrypt()?;
|
||||
let password = prompt_new_password()?;
|
||||
|
||||
@@ -150,6 +153,12 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
.await
|
||||
.context("creating the admin user")?;
|
||||
|
||||
// The first-run language choice is instance-wide: the registry config
|
||||
// default every user follows until they override it on their profile.
|
||||
skald_core::i18n::set_default_locale(pool, &locale)
|
||||
.await
|
||||
.context("saving the default language")?;
|
||||
|
||||
println!("\n✓ Admin user '{username}' created (id {id}).");
|
||||
if encrypt {
|
||||
println!(" Their private database is encrypted. There is no recovery if the password is lost.");
|
||||
@@ -177,6 +186,34 @@ fn prompt_username() -> Result<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 —
|
||||
/// 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 668 B After Width: | Height: | Size: 7.7 KiB |
@@ -58,9 +58,19 @@ pub async fn login(
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role_id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<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.
|
||||
@@ -84,14 +94,44 @@ pub async fn me(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
|
||||
let ui_mode = resolve_ui_mode(&skald, &user.role_id).await;
|
||||
let default_locale = skald
|
||||
.config()
|
||||
.get(skald_core::i18n::DEFAULT_LOCALE_KEY)
|
||||
.await?
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "en".into());
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role_id: user.role_id,
|
||||
ui_mode,
|
||||
locale: user.locale,
|
||||
encrypted: user.encrypted,
|
||||
default_locale,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key
|
||||
/// resolves to "full" — the simplified UI is strictly opt-in.
|
||||
async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String {
|
||||
if role_id == skald_core::db::roles::ADMIN_ROLE_ID {
|
||||
return "full".into();
|
||||
}
|
||||
let attrs = skald_core::db::roles::get(skald.db(), role_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|r| r.attrs);
|
||||
attrs
|
||||
.and_then(|a| serde_json::from_str::<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 ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn logout(
|
||||
@@ -127,11 +167,16 @@ fn extract_session_token(headers: &HeaderMap) -> Option<String> {
|
||||
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)]
|
||||
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(
|
||||
@@ -145,13 +190,27 @@ pub async fn update_profile(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("user not found"))?;
|
||||
|
||||
skald_core::db::users::rename(
|
||||
skald.db(),
|
||||
&auth.user_id,
|
||||
&user.username,
|
||||
body.display_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if let Some(display_name) = body.display_name {
|
||||
skald_core::db::users::rename(
|
||||
skald.db(),
|
||||
&auth.user_id,
|
||||
&user.username,
|
||||
display_name.as_deref().filter(|s| !s.trim().is_empty()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(locale) = body.locale {
|
||||
match locale.as_deref().map(str::trim) {
|
||||
None | Some("") => {
|
||||
skald_core::db::users::set_locale(skald.db(), &auth.user_id, None).await?;
|
||||
}
|
||||
Some(l) if skald_core::i18n::is_supported(l) => {
|
||||
skald_core::db::users::set_locale(skald.db(), &auth.user_id, Some(l)).await?;
|
||||
}
|
||||
Some(_) => return Err(ApiError::bad_request("unsupported locale")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod roles;
|
||||
pub mod run_context;
|
||||
pub mod sessions;
|
||||
pub mod setup;
|
||||
pub mod shared_folders;
|
||||
pub mod transcribe_audio;
|
||||
pub mod transcribe_models;
|
||||
pub mod tts_models;
|
||||
@@ -176,6 +177,12 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/users", get(users_mgmt::list).post(users_mgmt::create))
|
||||
.route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete))
|
||||
.route("/users/{id}/password", post(users_mgmt::reset_password))
|
||||
|
||||
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
|
||||
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
|
||||
.route("/shared-folders/{id}", patch(shared_folders::update_description).delete(shared_folders::delete))
|
||||
.route("/shared-folders/{id}/members", post(shared_folders::add_member))
|
||||
.route("/shared-folders/{id}/members/{user_id}", delete(shared_folders::remove_member))
|
||||
// Images (generated by image_generate tool)
|
||||
.route("/images/{task_id}", get(images::get_image))
|
||||
// MCP tool-result media (images/audio/files returned by MCP servers)
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct CreateUserBody {
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub encrypted: bool,
|
||||
/// Chosen interface language — becomes the instance default (`ui_locale`).
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -54,11 +57,23 @@ pub async fn create_user(
|
||||
if body.password.is_empty() {
|
||||
return Err(ApiError::bad_request("password must not be empty"));
|
||||
}
|
||||
let locale = body.locale.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
if let Some(l) = locale {
|
||||
if !skald_core::i18n::is_supported(l) {
|
||||
return Err(ApiError::bad_request("unsupported locale"));
|
||||
}
|
||||
}
|
||||
|
||||
let id = skald
|
||||
.users()
|
||||
.register_user(username, None, "admin", Some(&body.password), body.encrypted)
|
||||
.await?;
|
||||
|
||||
// The first-run language choice is instance-wide: it lands in the registry
|
||||
// config as the default every user follows until they override it.
|
||||
if let Some(l) = locale {
|
||||
skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?;
|
||||
}
|
||||
|
||||
Ok(Json(CreateUserResult { user_id: id }))
|
||||
}
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { TasksPage } from './components/tasks/index.js';
|
||||
import { AgentsPage } from './components/agents.js';
|
||||
import { UsersPage } from './components/users-page.js';
|
||||
import { RolesPage } from './components/roles-page.js';
|
||||
import { SharedFoldersPage } from './components/shared-folders.js';
|
||||
import { ConnectorsPage } from './components/connectors.js';
|
||||
import { ConnectorDetailPage } from './components/connector-detail.js';
|
||||
import { MarketplacePage } from './components/marketplace.js';
|
||||
@@ -18,9 +19,9 @@ import { CatalogPage } from './components/catalog.js';
|
||||
import { ProfilePage } from './components/profile-page.js';
|
||||
import { ApprovalGroupsPage } from './components/approval-groups.js';
|
||||
import { ApprovalRulesPage } from './components/approval-rules.js';
|
||||
import { ConfigPage } from './components/config-page.js';
|
||||
import { AgentInboxPage } from './components/agent-inbox.js';
|
||||
import { HomePage } from './components/home-page.js';
|
||||
import { ConfigPage } from './components/config-page.js';
|
||||
import { DashboardPage } from './components/dashboard-page.js';
|
||||
import { AgentInboxPage } from './components/agent-inbox.js';
|
||||
import { LlmRequestsPage } from './components/llm-requests.js';
|
||||
import { LlmRequestDetail } from './components/llm-request-detail.js';
|
||||
import { SessionDetailPage } from './components/session-detail.js';
|
||||
@@ -32,6 +33,7 @@ import { LoginPage } from './components/login-page.js';
|
||||
|
||||
// Register the global `openFile(path)` helper (window.openFile → location.hash).
|
||||
import './lib/open-file.js';
|
||||
import { initI18n } from './lib/i18n.js';
|
||||
|
||||
customElements.define('app-topbar', AppTopbar);
|
||||
customElements.define('app-sidebar', AppSidebar);
|
||||
@@ -46,6 +48,7 @@ customElements.define('tasks-page', TasksPage);
|
||||
customElements.define('agents-page', AgentsPage);
|
||||
customElements.define('users-page', UsersPage);
|
||||
customElements.define('roles-page', RolesPage);
|
||||
customElements.define('shared-folders-page', SharedFoldersPage);
|
||||
customElements.define('connectors-page', ConnectorsPage);
|
||||
customElements.define('connector-detail-page', ConnectorDetailPage);
|
||||
customElements.define('marketplace-page', MarketplacePage);
|
||||
@@ -53,9 +56,9 @@ customElements.define('catalog-page', CatalogPage);
|
||||
customElements.define('profile-page', ProfilePage);
|
||||
customElements.define('approval-groups-page', ApprovalGroupsPage);
|
||||
customElements.define('approval-rules-page', ApprovalRulesPage);
|
||||
customElements.define('config-page', ConfigPage);
|
||||
customElements.define('agent-inbox-page', AgentInboxPage);
|
||||
customElements.define('home-page', HomePage);
|
||||
customElements.define('config-page', ConfigPage);
|
||||
customElements.define('dashboard-page', DashboardPage);
|
||||
customElements.define('agent-inbox-page', AgentInboxPage);
|
||||
customElements.define('llm-requests-page', LlmRequestsPage);
|
||||
customElements.define('llm-request-detail', LlmRequestDetail);
|
||||
customElements.define('session-detail-page', SessionDetailPage);
|
||||
@@ -98,5 +101,7 @@ window.addEventListener('llm-page-change', (e) => {
|
||||
if (login) login.style.display = '';
|
||||
return;
|
||||
}
|
||||
// Logged in: resolve the effective locale (user pref → instance default).
|
||||
initI18n();
|
||||
} catch { /* show app by default */ }
|
||||
})();
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -1,8 +1,9 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { InboxMixin } from '../lib/inbox-mixin.js';
|
||||
import { t, I18nMixin } from '../lib/i18n.js';
|
||||
|
||||
export class AgentInboxPage extends InboxMixin(LightElement) {
|
||||
export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -58,7 +59,7 @@ export class AgentInboxPage extends InboxMixin(LightElement) {
|
||||
<div class="page-panel">
|
||||
<div class="page-panel-header">
|
||||
<h5 class="mb-0">
|
||||
Agent Inbox
|
||||
${t('nav.inbox')}
|
||||
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
|
||||
</h5>
|
||||
<button class="inbox-refresh-btn" title="Refresh" @click=${() => this._loadInbox()}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
@@ -38,6 +39,8 @@ export class AgentsPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'agents';
|
||||
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() {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
@@ -81,12 +89,16 @@ export class AgentsPage extends LightElement {
|
||||
|
||||
// ── 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') {
|
||||
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`
|
||||
<span class="agent-strength-dot"
|
||||
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`
|
||||
<span class="agent-meta-item">
|
||||
${this._strengthDot(agent.strength)}
|
||||
<span>${STRENGTH_LABELS[agent.strength] ?? agent.strength}</span>
|
||||
<span>${this._strengthLabel(agent.strength)}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${agent.scope ? html`${this._scopePill(agent.scope)}` : ''}
|
||||
@@ -142,18 +154,16 @@ export class AgentsPage extends LightElement {
|
||||
}
|
||||
|
||||
_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._agents.length === 0) return html`<p class="text-muted">No agents found.</p>`;
|
||||
// Group by role: chat entry-points, dispatchable task executors, and
|
||||
// runtime-internal system agents (e.g. tic).
|
||||
if (this._agents.length === 0) return html`<p class="text-muted">${t('agents.empty')}</p>`;
|
||||
const chat = this._agents.filter(a => a.type === 'chat');
|
||||
const task = this._agents.filter(a => a.type === 'task');
|
||||
const system = this._agents.filter(a => a.type === 'system');
|
||||
return html`
|
||||
${this._renderSection('Chat', chat)}
|
||||
${this._renderSection('Task Executors', task)}
|
||||
${this._renderSection('System', system)}
|
||||
${this._renderSection(t('agents.section.chat'), chat)}
|
||||
${this._renderSection(t('agents.section.task'), task)}
|
||||
${this._renderSection(t('agents.section.system'), system)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -167,7 +177,7 @@ export class AgentsPage extends LightElement {
|
||||
<td>${this._strengthDot(m.strength)}</td>
|
||||
<td>
|
||||
<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 class="text-muted agent-model-id">${m.model_id}</td>
|
||||
<td>
|
||||
@@ -178,17 +188,16 @@ export class AgentsPage extends LightElement {
|
||||
}
|
||||
|
||||
_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 '';
|
||||
|
||||
const { meta, prompt, models } = this._detail;
|
||||
|
||||
return html`
|
||||
<div class="agent-detail">
|
||||
<!-- Header -->
|
||||
<div class="agent-detail-header">
|
||||
<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>
|
||||
<div class="agent-detail-title-row">
|
||||
${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>` : ''}
|
||||
|
||||
<div class="agent-detail-body">
|
||||
<!-- Meta -->
|
||||
<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">
|
||||
<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`
|
||||
<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">
|
||||
${this._strengthDot(meta.strength)}
|
||||
${STRENGTH_LABELS[meta.strength] ?? meta.strength}
|
||||
${this._strengthLabel(meta.strength)}
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${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`
|
||||
<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`
|
||||
<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>
|
||||
</tr>
|
||||
` : ''}
|
||||
@@ -233,25 +241,23 @@ export class AgentsPage extends LightElement {
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Model resolution order -->
|
||||
<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">
|
||||
Models sorted by how well they match this agent's requirements.
|
||||
The system uses the first available model from the top.
|
||||
${t('agents.detail.model_order_desc')}
|
||||
</p>
|
||||
${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`
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm agent-model-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Strength</th>
|
||||
<th>Name</th>
|
||||
<th>Model ID</th>
|
||||
<th>Scope</th>
|
||||
<th>${t('agents.table.rank')}</th>
|
||||
<th>${t('agents.table.strength')}</th>
|
||||
<th>${t('agents.table.name')}</th>
|
||||
<th>${t('agents.table.model_id')}</th>
|
||||
<th>${t('agents.table.scope')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -263,9 +269,8 @@ export class AgentsPage extends LightElement {
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- System prompt -->
|
||||
<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">
|
||||
${unsafeHTML(renderMarkdown(prompt))}
|
||||
</div>
|
||||
@@ -284,17 +289,14 @@ export class AgentsPage extends LightElement {
|
||||
? this._renderDetail()
|
||||
: html`
|
||||
<div class="agents-page-header">
|
||||
<h2 class="llm-page-title">Agents</h2>
|
||||
<h2 class="llm-page-title">${t('agents.title')}</h2>
|
||||
</div>
|
||||
|
||||
<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-body">
|
||||
<p class="mb-1"><strong>Read-only view.</strong> Agents are defined by files in <code>agents/</code>
|
||||
— to add, remove, or modify an agent, edit the corresponding <code>AGENT.md</code> file in that
|
||||
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>
|
||||
<p class="mb-1">${unsafeHTML(t('agents.banner.title'))}</p>
|
||||
<p class="mb-0">${unsafeHTML(t('agents.banner.text'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
export class ApprovalGroupsPage extends LightElement {
|
||||
static properties = {
|
||||
@@ -31,6 +33,8 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', async (e) => {
|
||||
this._open = e.detail.page === 'approval';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -58,6 +62,11 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -65,8 +74,8 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
fetch('/api/tool-permission-groups'),
|
||||
fetch('/api/approval/rules'),
|
||||
]);
|
||||
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
|
||||
if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`);
|
||||
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
|
||||
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
|
||||
const groups = await gRes.json();
|
||||
this._groups = groups.sort((a, b) => {
|
||||
if (a.id === 'default') return -1;
|
||||
@@ -114,8 +123,8 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
|
||||
async _saveGroup() {
|
||||
const isNew = this._groupEditId === 'new';
|
||||
if (!this._groupForm.name.trim()) { this._error = 'Group name is required.'; return; }
|
||||
if (isNew && !this._groupForm.id.trim()) { this._error = 'Group ID is required.'; return; }
|
||||
if (!this._groupForm.name.trim()) { this._error = t('security.error.group_name_required'); return; }
|
||||
if (isNew && !this._groupForm.id.trim()) { this._error = t('security.error.group_id_required'); return; }
|
||||
this._groupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -141,8 +150,8 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
async _deleteGroup(group) {
|
||||
const count = this._rulesForGroup(group.id).length;
|
||||
const msg = count > 0
|
||||
? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?`
|
||||
: `Delete group "${group.name}"?`;
|
||||
? t('security.confirm.delete_with_rules', { name: group.name, n: count, s: count === 1 ? '' : 's' })
|
||||
: t('security.confirm.delete', { name: group.name });
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' });
|
||||
@@ -159,7 +168,7 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
this._duplicateOf = group;
|
||||
this._dupForm = {
|
||||
id: `${group.id}_copy`,
|
||||
name: `Copy of ${group.name}`,
|
||||
name: `${t('security.duplicate')} ${group.name}`,
|
||||
};
|
||||
this._groupEditId = null; // close any open create/rename form
|
||||
}
|
||||
@@ -167,8 +176,8 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
_cancelDuplicate() { this._duplicateOf = null; }
|
||||
|
||||
async _saveDuplicate() {
|
||||
if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; }
|
||||
if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; }
|
||||
if (!this._dupForm.name.trim()) { this._error = t('security.error.name_required'); return; }
|
||||
if (!this._dupForm.id.trim()) { this._error = t('security.error.id_required'); return; }
|
||||
this._dupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -196,7 +205,7 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<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()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
@@ -205,41 +214,41 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
<div class="row g-3">
|
||||
${isNew ? html`
|
||||
<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
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. cron_strict"
|
||||
placeholder=${t('security.form.id_ph')}
|
||||
.value=${f.id}
|
||||
@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>
|
||||
` : nothing}
|
||||
<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
|
||||
class="form-control form-control-sm"
|
||||
placeholder="e.g. Cron strict"
|
||||
placeholder=${t('security.form.name_ph')}
|
||||
.value=${f.name}
|
||||
@input=${(e) => this._patchGroup('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Short description…"
|
||||
placeholder=${t('security.form.description_ph')}
|
||||
.value=${f.description}
|
||||
@input=${(e) => this._patchGroup('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
${this._groupSaving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>Save`}
|
||||
? 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>${t('security.form.save')}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +265,7 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<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()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
@@ -264,7 +273,7 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
<div class="apr-form-body">
|
||||
<div class="row g-3">
|
||||
<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
|
||||
class="form-control form-control-sm"
|
||||
.value=${f.name}
|
||||
@@ -272,27 +281,27 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
class="form-control form-control-sm font-monospace"
|
||||
.value=${f.id}
|
||||
@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 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">
|
||||
<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 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}>
|
||||
${this._dupSaving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Duplicating…`
|
||||
: html`<i class="bi bi-copy me-1"></i>Duplicate`}
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('security.duplicating')}`
|
||||
: html`<i class="bi bi-copy me-1"></i>${t('security.duplicate')}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,24 +317,24 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
return html`
|
||||
<div class="apr-card apr-group-card" @click=${() => this._navigateTo(group)}>
|
||||
<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-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>
|
||||
${count}
|
||||
</span>
|
||||
<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); }}>
|
||||
<i class="bi bi-copy"></i>
|
||||
</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); }}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
@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-header">
|
||||
<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>
|
||||
<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()}>
|
||||
<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>
|
||||
</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-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-1">
|
||||
<strong>Permission groups</strong> are named sets of approval rules.
|
||||
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>
|
||||
<p class="mb-1">${unsafeHTML(t('security.banner.text1'))}</p>
|
||||
<p class="mb-0">${unsafeHTML(t('security.banner.text2'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -385,9 +387,9 @@ export class ApprovalGroupsPage extends LightElement {
|
||||
${this._groups.length === 0 ? html`
|
||||
<div class="apr-empty">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
` : this._groups.map(g => this._renderGroupCard(g))}
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const DEFAULT_PRIORITY = 999999;
|
||||
|
||||
const ACTIONS = ['require', 'allow', 'deny'];
|
||||
|
||||
const ACTION_STYLE = {
|
||||
require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
|
||||
allow: { icon: 'bi-check-circle', label: 'Allow', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
|
||||
deny: { icon: 'bi-slash-circle', label: 'Deny', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
|
||||
require: { icon: 'bi-person-check', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
|
||||
allow: { icon: 'bi-check-circle', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
|
||||
deny: { icon: 'bi-slash-circle', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
filesystem: 'File System',
|
||||
shell: 'Shell',
|
||||
subagent: 'Agents',
|
||||
introspection: 'Introspection',
|
||||
config: 'Config',
|
||||
// Tools injected dynamically outside the ToolRegistry (interface/plugin/
|
||||
// provider tools), surfaced via runtime discovery — see docs/approval.
|
||||
dynamic: 'Dynamic',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'File System', 'Shell', 'Agents', 'Introspection', 'Config', 'Dynamic',
|
||||
];
|
||||
const CATEGORY_ORDER = ['filesystem', 'shell', 'subagent', 'introspection', 'config', 'dynamic'];
|
||||
|
||||
// File System permission model. Each path row maps to exactly one approval rule via a
|
||||
// synthetic `@fs_*` tool_pattern token (understood by the backend matcher). A single
|
||||
// selector collapses the (access-class × action) axes into the mental model from the
|
||||
// mockup: Allow read / Allow write / Deny / Require.
|
||||
const FS_ACCESS = {
|
||||
allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' },
|
||||
allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' },
|
||||
deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' },
|
||||
require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' },
|
||||
allow_read: { tool_pattern: '@fs_read', action: 'allow' },
|
||||
allow_write: { tool_pattern: '@fs_any', action: 'allow' },
|
||||
deny: { tool_pattern: '@fs_any', action: 'deny' },
|
||||
require: { tool_pattern: '@fs_any', action: 'require' },
|
||||
};
|
||||
// Priority band for the settable "Default" row (below specific fs path rules, above the
|
||||
// global `*` catch-all at 999999).
|
||||
@@ -91,6 +80,8 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
if (e.detail.page !== 'approval') {
|
||||
this._open = false;
|
||||
@@ -118,6 +109,11 @@ export class ApprovalRulesPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -125,8 +121,8 @@ export class ApprovalRulesPage extends LightElement {
|
||||
fetch('/api/approval/rules'),
|
||||
fetch('/api/approval/tools'),
|
||||
]);
|
||||
if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`);
|
||||
if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`);
|
||||
if (!rulesRes.ok) throw new Error(`HTTP ${rulesRes.status}`);
|
||||
if (!toolsRes.ok) throw new Error(`HTTP ${toolsRes.status}`);
|
||||
this._rules = await rulesRes.json();
|
||||
this._tools = await toolsRes.json();
|
||||
} catch (e) {
|
||||
@@ -331,7 +327,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
async _addFsRule() {
|
||||
const clean = this._normalizeFsPath(this._fsNewPath);
|
||||
if (!clean) { this._error = 'Enter a directory path.'; return; }
|
||||
if (!clean) { this._error = t('approval.error.enter_path'); return; }
|
||||
const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read;
|
||||
this._fsSaving = new Set([...this._fsSaving, 'new']);
|
||||
this._error = null;
|
||||
@@ -386,7 +382,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
}
|
||||
|
||||
async _deleteFsRule(rule) {
|
||||
if (!confirm(`Remove File System rule for "${this._fsDisplayPath(rule)}"?`)) return;
|
||||
if (!confirm(t('approval.confirm.delete_fs', { path: this._fsDisplayPath(rule) }))) return;
|
||||
this._fsSaving = new Set([...this._fsSaving, rule.id]);
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -443,15 +439,25 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
// ── Tool grouping ─────────────────────────────────────────────────────────────
|
||||
|
||||
_catLabel(key) {
|
||||
return {
|
||||
filesystem: t('approval.category.filesystem'),
|
||||
shell: t('approval.category.shell'),
|
||||
subagent: t('approval.category.subagent'),
|
||||
introspection: t('approval.category.introspection'),
|
||||
config: t('approval.category.config'),
|
||||
dynamic: t('approval.category.dynamic'),
|
||||
}[key] ?? key;
|
||||
}
|
||||
|
||||
_groupedTools() {
|
||||
if (!this._tools) return [];
|
||||
const map = new Map();
|
||||
const metaMap = new Map(); // category key → { description }
|
||||
const metaMap = new Map();
|
||||
|
||||
for (const t of this._tools.built_in) {
|
||||
// Filesystem tools are gated by path in the File System panel, not per-tool here.
|
||||
if (t.category === 'filesystem') continue;
|
||||
const cat = t.category ? (CATEGORY_LABELS[t.category] ?? t.category) : 'Other';
|
||||
const cat = t.category || 'other';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat).push(t);
|
||||
}
|
||||
@@ -459,7 +465,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
for (const t of this._tools.mcp) {
|
||||
const serverId = t.server ?? t.name;
|
||||
const meta = servers[serverId] ?? {};
|
||||
const key = `MCP · ${meta.friendly_name ?? serverId}`;
|
||||
const key = `mcp:${serverId}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
if (meta.description) metaMap.set(key, meta.description);
|
||||
@@ -472,9 +478,9 @@ export class ApprovalRulesPage extends LightElement {
|
||||
if (map.has(cat)) result.push([cat, map.get(cat), null]);
|
||||
}
|
||||
for (const [key, tools] of map.entries()) {
|
||||
if (!CATEGORY_ORDER.includes(key) && key !== 'Other') result.push([key, tools, metaMap.get(key) ?? null]);
|
||||
if (!CATEGORY_ORDER.includes(key) && key !== 'other') result.push([key, tools, metaMap.get(key) ?? null]);
|
||||
}
|
||||
if (map.has('Other')) result.push(['Other', map.get('Other'), null]);
|
||||
if (map.has('other')) result.push(['other', map.get('other'), null]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -513,14 +519,14 @@ export class ApprovalRulesPage extends LightElement {
|
||||
_selectTool(name) { this._form = { ...this._form, tool_pattern: name }; }
|
||||
|
||||
async _save() {
|
||||
if (!this._form.tool_pattern.trim()) { this._error = 'Tool pattern is required.'; return; }
|
||||
if (!this._form.tool_pattern.trim()) { this._error = t('approval.error.tool_required'); return; }
|
||||
|
||||
const p = Number(this._form.priority);
|
||||
if (this._formMode === 'override' && p >= 0) {
|
||||
this._error = 'Override rules must have priority < 0.'; return;
|
||||
this._error = t('approval.error.override_prio'); return;
|
||||
}
|
||||
if (this._formMode === 'lowprio' && (p <= 0 || p >= DEFAULT_PRIORITY)) {
|
||||
this._error = `Low priority rules must have priority between 1 and ${DEFAULT_PRIORITY - 1}.`; return;
|
||||
this._error = t('approval.error.lowprio_range', { max: DEFAULT_PRIORITY - 1 }); return;
|
||||
}
|
||||
|
||||
this._saving = true;
|
||||
@@ -555,7 +561,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(rule) {
|
||||
if (!confirm(`Delete rule for "${rule.tool_pattern}"?`)) return;
|
||||
if (!confirm(t('approval.confirm.delete_rule', { pattern: rule.tool_pattern }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -582,8 +588,8 @@ export class ApprovalRulesPage extends LightElement {
|
||||
const current = this._form.tool_pattern;
|
||||
|
||||
const allTools = [
|
||||
{ name: '*', description: 'Any tool', source: 'glob', server: null },
|
||||
{ name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null },
|
||||
{ name: '*', description: t('approval.tool.any'), source: 'glob', server: null },
|
||||
{ name: 'mcp__*', description: t('approval.tool.any_mcp'), source: 'glob', server: null },
|
||||
...this._tools.built_in,
|
||||
...this._tools.mcp,
|
||||
];
|
||||
@@ -596,17 +602,21 @@ export class ApprovalRulesPage extends LightElement {
|
||||
);
|
||||
|
||||
const groups = {};
|
||||
for (const t of filtered) {
|
||||
const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob';
|
||||
for (const tool of filtered) {
|
||||
const key = tool.source === 'mcp'
|
||||
? t('approval.tool.group_mcp', { server: tool.server })
|
||||
: tool.source === 'built-in'
|
||||
? t('approval.tool.group_builtin')
|
||||
: t('approval.tool.group_glob');
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(t);
|
||||
groups[key].push(tool);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="apr-tool-picker">
|
||||
<input
|
||||
class="form-control form-control-sm mb-2"
|
||||
placeholder="Search tools…"
|
||||
placeholder=${t('approval.tool.search')}
|
||||
.value=${this._toolFilter}
|
||||
@input=${(e) => { this._toolFilter = e.target.value; }}
|
||||
/>
|
||||
@@ -624,7 +634,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
</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>
|
||||
`;
|
||||
@@ -640,8 +650,8 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<div class="apr-form-header">
|
||||
<i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i>
|
||||
<span>${this._editingId === 'new'
|
||||
? (isOverride ? 'New override rule' : 'New low priority rule')
|
||||
: 'Edit rule'}</span>
|
||||
? (isOverride ? t('approval.form.new_override') : t('approval.form.new_lowprio'))
|
||||
: t('approval.form.edit')}</span>
|
||||
<button class="apr-form-close" @click=${() => this._cancelEdit()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
@@ -649,31 +659,31 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<div class="apr-form-body">
|
||||
<div class="row g-3">
|
||||
<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
|
||||
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}
|
||||
@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 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()}
|
||||
</div>
|
||||
<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
|
||||
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}
|
||||
@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 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
|
||||
class="form-select form-select-sm"
|
||||
.value=${f.action}
|
||||
@@ -683,7 +693,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
@@ -692,47 +702,47 @@ export class ApprovalRulesPage extends LightElement {
|
||||
/>
|
||||
<div class="form-text" style="font-size:0.75rem">
|
||||
${isOverride
|
||||
? html`Must be <strong>< 0</strong> (e.g. −10)`
|
||||
: html`Must be <strong>1 – ${DEFAULT_PRIORITY - 1}</strong>`}
|
||||
? unsafeHTML(t('approval.form.priority_override_hint'))
|
||||
: unsafeHTML(t('approval.form.priority_lowprio_hint', { max: DEFAULT_PRIORITY - 1 }))}
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
class="form-select form-select-sm"
|
||||
@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`
|
||||
<option value=${s} ?selected=${f.source === s}>${s}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="main (empty = any)"
|
||||
placeholder=${t('approval.form.agent_id_ph')}
|
||||
.value=${f.agent_id}
|
||||
@input=${(e) => this._patch('agent_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Short description…"
|
||||
placeholder=${t('approval.form.note_ph')}
|
||||
.value=${f.note}
|
||||
@input=${(e) => this._patch('note', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>Save`}
|
||||
? 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>${t('approval.form.save')}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -750,18 +760,18 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<div class="apr-card-row1">
|
||||
<span class="apr-action-badge">
|
||||
<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>
|
||||
<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>
|
||||
${rule.priority}
|
||||
</span>
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -784,10 +794,10 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
_renderChipGroup(currentAction, onChange) {
|
||||
const chips = [
|
||||
{ action: null, label: '—' },
|
||||
{ action: 'allow', label: 'Allow' },
|
||||
{ action: 'require', label: 'Req' },
|
||||
{ action: 'deny', label: 'Deny' },
|
||||
{ action: null, label: t('approval.chip.unset') },
|
||||
{ action: 'allow', label: t('approval.action.allow') },
|
||||
{ action: 'require', label: t('approval.chip.req') },
|
||||
{ action: 'deny', label: t('approval.action.deny') },
|
||||
];
|
||||
return html`
|
||||
<div class="apr-chip-group">
|
||||
@@ -827,11 +837,14 @@ export class ApprovalRulesPage extends LightElement {
|
||||
const open = this._openSections.has(key);
|
||||
const groupId = this._selectedGroup.id;
|
||||
const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length;
|
||||
const label = key.startsWith('mcp:')
|
||||
? t('approval.tool.group_mcp', { server: key.slice(4) })
|
||||
: this._catLabel(key);
|
||||
return html`
|
||||
<div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}">
|
||||
<div class="apr-cat-header" @click=${() => this._toggleSection(key)}>
|
||||
<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}
|
||||
<span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}">
|
||||
${configured > 0 ? `${configured}/` : ''}${tools.length}
|
||||
@@ -851,12 +864,12 @@ export class ApprovalRulesPage extends LightElement {
|
||||
return html`
|
||||
<div class="apr-matrix">
|
||||
<div class="apr-matrix-header">
|
||||
<span class="apr-matrix-title">Per-tool</span>
|
||||
<span class="apr-matrix-subtitle">priority = 0 · exact tool name · no path/source filters</span>
|
||||
<span class="apr-matrix-title">${t('approval.matrix.title')}</span>
|
||||
<span class="apr-matrix-subtitle">${t('approval.matrix.subtitle')}</span>
|
||||
</div>
|
||||
<div class="apr-matrix-body">
|
||||
${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))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -865,6 +878,15 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
// ── File System panel ─────────────────────────────────────────────────────────
|
||||
|
||||
_fsAccessLabel(key) {
|
||||
return {
|
||||
allow_read: t('approval.fs.allow_read'),
|
||||
allow_write: t('approval.fs.allow_write'),
|
||||
deny: t('approval.fs.deny'),
|
||||
require: t('approval.fs.require'),
|
||||
}[key] ?? key;
|
||||
}
|
||||
|
||||
_renderFsAccessSelect(value, onChange, allowUnset) {
|
||||
return html`
|
||||
<select
|
||||
@@ -872,10 +894,10 @@ export class ApprovalRulesPage extends LightElement {
|
||||
@change=${(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
${allowUnset
|
||||
? html`<option value="" ?selected=${!value}>Require (system default)</option>`
|
||||
? html`<option value="" ?selected=${!value}>${t('approval.fs.default')}</option>`
|
||||
: nothing}
|
||||
${Object.entries(FS_ACCESS).map(([k, v]) => html`
|
||||
<option value=${k} ?selected=${value === k}>${v.label}</option>
|
||||
${Object.entries(FS_ACCESS).map(([k]) => html`
|
||||
<option value=${k} ?selected=${value === k}>${this._fsAccessLabel(k)}</option>
|
||||
`)}
|
||||
</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`
|
||||
${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>
|
||||
</button>
|
||||
`}
|
||||
@@ -907,7 +929,7 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<i class="bi bi-plus-circle apr-fs-row-icon"></i>
|
||||
<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}
|
||||
@input=${(e) => { this._fsNewPath = e.target.value; }}
|
||||
@keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }}
|
||||
@@ -916,8 +938,8 @@ export class ApprovalRulesPage extends LightElement {
|
||||
class="form-select form-select-sm apr-fs-select"
|
||||
@change=${(e) => { this._fsNewAccess = e.target.value; }}
|
||||
>
|
||||
${Object.entries(FS_ACCESS).map(([k, v]) => html`
|
||||
<option value=${k} ?selected=${this._fsNewAccess === k}>${v.label}</option>
|
||||
${Object.entries(FS_ACCESS).map(([k]) => html`
|
||||
<option value=${k} ?selected=${this._fsNewAccess === k}>${this._fsAccessLabel(k)}</option>
|
||||
`)}
|
||||
</select>
|
||||
<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; }}>
|
||||
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
|
||||
<i class="bi bi-hdd-stack apr-panel-icon"></i>
|
||||
<span class="apr-panel-title">File System</span>
|
||||
<span class="apr-panel-subtitle">path-scoped read / write access</span>
|
||||
<span class="apr-panel-title">${t('approval.fs.title')}</span>
|
||||
<span class="apr-panel-subtitle">${t('approval.fs.subtitle')}</span>
|
||||
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
|
||||
</div>
|
||||
${isOpen ? html`
|
||||
<div class="apr-side-panel-body">
|
||||
${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))}
|
||||
${this._renderFsAddRow()}
|
||||
<div class="apr-fs-row apr-fs-default">
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -976,13 +998,13 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary apr-panel-add-btn"
|
||||
@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>
|
||||
${isOpen ? html`
|
||||
<div class="apr-side-panel-body">
|
||||
${formActive ? this._renderForm() : nothing}
|
||||
${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))}
|
||||
</div>
|
||||
` : nothing}
|
||||
@@ -998,12 +1020,12 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<div class="apr-default-bar">
|
||||
<div class="apr-default-label">
|
||||
<i class="bi bi-skip-end-fill me-1"></i>
|
||||
<strong>Default action</strong>
|
||||
<span class="apr-default-hint">if no rule matches</span>
|
||||
<strong>${t('approval.default_bar.title')}</strong>
|
||||
<span class="apr-default-hint">${t('approval.default_bar.hint')}</span>
|
||||
</div>
|
||||
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))}
|
||||
${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}
|
||||
</div>
|
||||
`;
|
||||
@@ -1029,11 +1051,11 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<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}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
@@ -1044,9 +1066,9 @@ export class ApprovalRulesPage extends LightElement {
|
||||
<div class="apr-rules-body">
|
||||
${this._renderSidePanel(
|
||||
'override',
|
||||
'Overrides',
|
||||
t('approval.sidebar.overrides'),
|
||||
'bi-exclamation-triangle-fill',
|
||||
'priority < 0 · evaluated first',
|
||||
t('approval.sidebar.overrides_sub'),
|
||||
overrides,
|
||||
this._overrideOpen,
|
||||
() => { this._overrideOpen = !this._overrideOpen; },
|
||||
@@ -1059,9 +1081,9 @@ export class ApprovalRulesPage extends LightElement {
|
||||
|
||||
${this._renderSidePanel(
|
||||
'lowprio',
|
||||
'Low Priority',
|
||||
t('approval.sidebar.lowprio'),
|
||||
'bi-arrow-down-circle-fill',
|
||||
'priority 1–999998 · evaluated after per-tool',
|
||||
t('approval.sidebar.lowprio_sub'),
|
||||
lowPrio,
|
||||
this._lowPrioOpen,
|
||||
() => { this._lowPrioOpen = !this._lowPrioOpen; },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
// Connector catalog — blueprint §14/§15. Admin only.
|
||||
//
|
||||
@@ -54,15 +56,21 @@ export class CatalogPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'catalog';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
// Close the chooser when clicking anywhere else.
|
||||
document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
|
||||
|
||||
async _load() {
|
||||
@@ -108,7 +116,7 @@ export class CatalogPage extends LightElement {
|
||||
|
||||
async _saveManual() {
|
||||
const f = this._modal.form;
|
||||
if (!f.name.trim()) { this._error = 'Name is required.'; return; }
|
||||
if (!f.name.trim()) { this._error = t('catalog.error.name'); return; }
|
||||
const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
|
||||
try {
|
||||
await jf('/api/mcp/catalog', {
|
||||
@@ -135,7 +143,7 @@ export class CatalogPage extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(row) {
|
||||
if (!confirm(`Remove "${row.name}" from the catalog?\n\nAnything already activated from it keeps running.`)) return;
|
||||
if (!confirm(t('catalog.confirm.delete', { name: row.name }))) return;
|
||||
try {
|
||||
await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' });
|
||||
await this._load();
|
||||
@@ -152,7 +160,7 @@ export class CatalogPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<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">
|
||||
${this._isAdmin ? this._renderAddButton() : nothing}
|
||||
</div>
|
||||
@@ -165,20 +173,13 @@ export class CatalogPage extends LightElement {
|
||||
${this._me && !this._isAdmin ? html`
|
||||
<div class="um-empty" style="padding:2rem">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
<p>The catalog is managed by the admin.</p>
|
||||
<p style="font-size:.8rem;opacity:.7">
|
||||
What you can activate is on the
|
||||
<a href="#connectors" @click=${(e) => { e.preventDefault(); this._goConnectors(); }}>Connectors</a> page.
|
||||
</p>
|
||||
<p>${t('catalog.not_admin')}</p>
|
||||
<p style="font-size:.8rem;opacity:.7">${unsafeHTML(t('catalog.not_admin_link'))}</p>
|
||||
</div>
|
||||
` : 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`
|
||||
<div class="text-muted mt-3 mb-3" style="font-size:.8rem">
|
||||
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>
|
||||
<div class="text-muted mt-3 mb-3" style="font-size:.8rem">${unsafeHTML(t('catalog.desc'))}</div>
|
||||
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)}
|
||||
`}
|
||||
</div>
|
||||
@@ -194,27 +195,23 @@ export class CatalogPage extends LightElement {
|
||||
return html`
|
||||
<div class="dropdown" style="position:relative" @click=${(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
</button>
|
||||
${this._addOpen ? html`
|
||||
<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()}>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
<i class="bi bi-shop"></i><strong style="font-size:.85rem">From the marketplace</strong>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
|
||||
Vetted connectors, files verified by SHA-256.
|
||||
<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">${t('catalog.dropdown.marketplace_desc')}</div>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" style="white-space:normal" @click=${() => this._openManual()}>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
<i class="bi bi-pencil"></i><strong style="font-size:.85rem">Manually</strong>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
|
||||
You supply the config, and vouch for it yourself.
|
||||
<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">${t('catalog.dropdown.manual_desc')}</div>
|
||||
</button>
|
||||
</div>` : nothing}
|
||||
</div>`;
|
||||
@@ -224,10 +221,10 @@ export class CatalogPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-empty" style="padding:2rem">
|
||||
<i class="bi bi-journal"></i>
|
||||
<p>The catalog is empty.</p>
|
||||
<p style="font-size:.8rem;opacity:.7">Add a connector from the marketplace to get started.</p>
|
||||
<p>${t('catalog.empty.title')}</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()}>
|
||||
<i class="bi bi-shop me-1"></i>Browse the marketplace
|
||||
<i class="bi bi-shop me-1"></i>${t('catalog.empty.action')}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -235,7 +232,7 @@ export class CatalogPage extends LightElement {
|
||||
_renderTable(rows) {
|
||||
return html`
|
||||
<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>
|
||||
${rows.map(r => html`
|
||||
<tr>
|
||||
@@ -247,12 +244,12 @@ export class CatalogPage extends LightElement {
|
||||
text-overflow:ellipsis;white-space:nowrap" title=${r.description}>${r.description}</div>` : nothing}
|
||||
</td>
|
||||
<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">
|
||||
${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><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>
|
||||
</div></td>
|
||||
</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">
|
||||
<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>
|
||||
</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}
|
||||
${isScript ? html`
|
||||
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>A local script runs code on this box.
|
||||
Nothing verifies it — unlike the marketplace path, there is no digest to check.
|
||||
</div>` : nothing}
|
||||
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'slug', mono: true })}
|
||||
${this._select('Scope', f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
|
||||
${this._select('Type', f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
|
||||
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
|
||||
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">${unsafeHTML(t('catalog.modal.script_warn'))}</div>` : nothing}
|
||||
${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })}
|
||||
${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
|
||||
${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
|
||||
${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
|
||||
${isScript
|
||||
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })}
|
||||
${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as <connector>/<file>, under ./connectors', mono: true })}`
|
||||
: this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })}
|
||||
${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })}
|
||||
${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })}
|
||||
${this._select('Auth', f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))}
|
||||
${this._field('Friendly name', f.friendly_name, e => this._patch('friendly_name', e.target.value))}
|
||||
${this._field('Description', f.description, e => this._patch('description', e.target.value),
|
||||
{ hint: 'the LLM reads this when deciding to activate the connector' })}
|
||||
? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })}
|
||||
${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}`
|
||||
: this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })}
|
||||
${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })}
|
||||
${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })}
|
||||
${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))}
|
||||
${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))}
|
||||
${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })}
|
||||
</div>
|
||||
<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()}>
|
||||
<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>`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
export class ConfigPage extends LightElement {
|
||||
static properties = {
|
||||
@@ -9,6 +10,8 @@ export class ConfigPage extends LightElement {
|
||||
_saving: { state: true }, // Set<key>
|
||||
_saved: { state: true }, // Set<key> (brief flash)
|
||||
_error: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_debugLoading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -19,17 +22,55 @@ export class ConfigPage extends LightElement {
|
||||
this._saving = new Set();
|
||||
this._saved = new Set();
|
||||
this._error = null;
|
||||
this._debugMode = false;
|
||||
this._debugLoading = true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'config';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
if (this._open) { this._load(); this._loadDebugMode(); }
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _loadDebugMode() {
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
this._debugMode = data.enabled;
|
||||
} catch {
|
||||
// ignore, keep current value
|
||||
} finally {
|
||||
this._debugLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _toggleDebugMode() {
|
||||
const next = !this._debugMode;
|
||||
this._debugMode = next;
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } }));
|
||||
} catch {
|
||||
this._debugMode = !next;
|
||||
}
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -70,7 +111,7 @@ export class ConfigPage extends LightElement {
|
||||
this._saved = new Set([...this._saved].filter(k => k !== key));
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
alert(`Error saving ${prop.name}: ${e.message}`);
|
||||
alert(t('config.error_save', { name: prop.name, msg: e.message }));
|
||||
} finally {
|
||||
this._saving = new Set([...this._saving].filter(k => k !== key));
|
||||
}
|
||||
@@ -164,18 +205,45 @@ export class ConfigPage extends LightElement {
|
||||
return html`
|
||||
<div class="config-page">
|
||||
<div class="config-page-header">
|
||||
<h2 class="llm-page-title">Config</h2>
|
||||
<h2 class="llm-page-title">${t('config.title')}</h2>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger">${this._error}</div>` : nothing}
|
||||
|
||||
${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">
|
||||
${this._properties.map(s => this._renderSet(s))}
|
||||
</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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import {
|
||||
announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf,
|
||||
} from './shared/connector-common.js';
|
||||
@@ -75,6 +76,8 @@ export class ConnectorDetailPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -85,6 +88,11 @@ export class ConnectorDetailPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
|
||||
get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; }
|
||||
get _status() { return statusOf({ _act: this._act, _glob: this._glob }); }
|
||||
@@ -112,7 +120,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null;
|
||||
|
||||
if (!entry && !glob) {
|
||||
this._error = `No connector named “${this._name}” is available to you.`;
|
||||
this._error = t('connectors.error.no_connector', { name: this._name });
|
||||
return;
|
||||
}
|
||||
this._entry = entry;
|
||||
@@ -193,7 +201,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
});
|
||||
if (res?.auth_state === 'pending') {
|
||||
this._test = res.verify ?? { ok: false, message: 'Verification failed.' };
|
||||
this._error = 'Saved, but the credentials did not check out — fix them and test again.';
|
||||
this._error = t('connectors.detail.test.error_saved');
|
||||
} else if (res?.error) {
|
||||
this._error = res.error;
|
||||
}
|
||||
@@ -204,7 +212,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
}
|
||||
|
||||
async _deactivate() {
|
||||
if (!confirm(`Deactivate “${this._entry?.friendly_name || this._name}”?`)) return;
|
||||
if (!confirm(t('connectors.detail.confirm.deactivate', { name: this._entry?.friendly_name || this._name }))) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' });
|
||||
@@ -273,7 +281,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
});
|
||||
if (res?.verify && !res.verify.ok && !res.verify.skipped) {
|
||||
this._test = res.verify;
|
||||
this._error = 'Verification failed — the connector stays disabled until the credentials are fixed.';
|
||||
this._error = t('connectors.detail.test.error_verify');
|
||||
} else if (res?.error) {
|
||||
this._error = res.error;
|
||||
}
|
||||
@@ -284,7 +292,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
}
|
||||
|
||||
async _disableGlobal() {
|
||||
if (!confirm(`Disable “${this._glob.friendly_name || this._name}”?\n\nIt stops for everyone who can use it.`)) return;
|
||||
if (!confirm(t('connectors.detail.confirm.disable_global', { name: this._glob.friendly_name || this._name }))) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' });
|
||||
@@ -328,7 +336,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
}
|
||||
if (!this._entry && !this._glob) {
|
||||
return html`<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`
|
||||
@@ -349,7 +357,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-header">
|
||||
<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>
|
||||
</button>
|
||||
<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 status = this._status;
|
||||
const desc = e?.description || this._glob?.description;
|
||||
const _statusText = (s) => ({ active: t('connectors.detail.status.active'), pending: t('connectors.detail.status.needs_fix'), needs_login: t('connectors.detail.status.needs_signin') })[s] ?? s;
|
||||
|
||||
return html`
|
||||
<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}
|
||||
<div class="connector-chips">
|
||||
<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>
|
||||
${isScript ? html`
|
||||
<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}
|
||||
${e?.auth_kind && e.auth_kind !== 'none' ? html`
|
||||
<span class="connector-chip"><i class="bi bi-key"></i>${e.auth_kind}</span>` : nothing}
|
||||
${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`
|
||||
<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`
|
||||
<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>
|
||||
${this._isGlobal ? html`
|
||||
<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>`;
|
||||
}
|
||||
@@ -412,8 +421,8 @@ export class ConnectorDetailPage extends LightElement {
|
||||
return html`
|
||||
<div style="margin-top:1.5rem">
|
||||
<div class="um-empty" style="padding:1rem"><i class="bi bi-check2-circle"></i>
|
||||
<p>This connector is managed for you.</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>${t('connectors.detail.managed.title')}</p>
|
||||
<p style="font-size:.8rem;opacity:.7">${t('connectors.detail.managed.desc')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -436,7 +445,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
return html`
|
||||
<div style="margin-top:1.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>
|
||||
${this._renderOauth()}
|
||||
</div>`;
|
||||
@@ -446,20 +455,20 @@ export class ConnectorDetailPage extends LightElement {
|
||||
<div style="margin-top:1.5rem">
|
||||
<div class="um-header" style="padding:0 0 .5rem">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
${active ? html`
|
||||
<div class="text-muted mb-3" style="font-size:.78rem">
|
||||
${this._isGlobal
|
||||
? 'Already enabled. Re-submitting replaces the stored credentials.'
|
||||
: 'Already active. Re-submitting replaces the stored credentials.'}
|
||||
? t('connectors.detail.config.already_global')
|
||||
: t('connectors.detail.config.already_user')}
|
||||
</div>` : nothing}
|
||||
|
||||
${e.auth_kind === 'api_key' && !schemaHasSecret ? html`
|
||||
<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=${(ev) => { this._form = { ...this._form, api_key: ev.target.value }; }} />
|
||||
</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}
|
||||
@click=${() => this._testCreds()}>
|
||||
<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}
|
||||
|
||||
${this._isGlobal
|
||||
? html`
|
||||
<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>
|
||||
${this._glob ? html`
|
||||
<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}`
|
||||
: html`
|
||||
<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>
|
||||
${this._act ? html`
|
||||
<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}`}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -504,40 +513,38 @@ export class ConnectorDetailPage extends LightElement {
|
||||
const scopes = parseJson(this._entry?.oauth_scopes_json, []);
|
||||
|
||||
return html`
|
||||
<div class="text-muted mb-3" style="font-size:.78rem">
|
||||
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>
|
||||
<div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.detail.oauth.desc', { provider: label })}</div>
|
||||
|
||||
${scopes.length ? html`
|
||||
<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>
|
||||
</div>` : nothing}
|
||||
|
||||
${active ? html`
|
||||
<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}
|
||||
|
||||
${!this._oauth ? html`
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<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>
|
||||
${this._act ? html`
|
||||
<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}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="connector-card" style="margin-top:.25rem">
|
||||
<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.
|
||||
<div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">Re-open the sign-in page</a></div>
|
||||
<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">${t('connectors.detail.oauth.step1_link')}</a></div>
|
||||
</div>
|
||||
<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>
|
||||
<input class="form-control font-monospace mb-2" placeholder="4/0A…"
|
||||
.value=${this._oauth.code}
|
||||
@@ -545,10 +552,10 @@ export class ConnectorDetailPage extends LightElement {
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${this._busy || !this._oauth.code.trim()}
|
||||
@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 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>`}
|
||||
`;
|
||||
@@ -574,20 +581,20 @@ export class ConnectorDetailPage extends LightElement {
|
||||
}
|
||||
|
||||
_renderVerifyBox() {
|
||||
const t = this._test;
|
||||
if (t === null) return nothing;
|
||||
if (t === 'running') {
|
||||
const result = this._test;
|
||||
if (result === null) return nothing;
|
||||
if (result === 'running') {
|
||||
return html`<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">
|
||||
<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`
|
||||
<div class="alert alert-${t.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>
|
||||
<strong>${t.ok ? 'OK' : 'Failed'}</strong> — ${t.message}
|
||||
<div class="alert alert-${result.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem">
|
||||
<i class="bi ${result.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
|
||||
<strong>${result.ok ? t('connectors.detail.test.ok_label') : t('connectors.detail.test.fail_label')}</strong> — ${result.message}
|
||||
${t.details ? html`
|
||||
<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}
|
||||
@@ -602,13 +609,11 @@ export class ConnectorDetailPage extends LightElement {
|
||||
return html`
|
||||
<div style="margin-top:1.75rem">
|
||||
<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>
|
||||
</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.
|
||||
<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">${t('connectors.detail.access.desc')}</div>
|
||||
${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`
|
||||
<div class="connector-card">
|
||||
${users.map(u => html`
|
||||
@@ -624,7 +629,7 @@ export class ConnectorDetailPage extends LightElement {
|
||||
</div>`}
|
||||
<button class="btn btn-sm btn-primary mt-2" ?disabled=${this._busy || !this._access}
|
||||
@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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import { connectorIconUrl, statusOf, STATUS_LABEL, statusText } from './shared/connector-common.js';
|
||||
|
||||
// Connectors (MCP) — blueprint §7/§14/§15.
|
||||
//
|
||||
@@ -65,16 +66,21 @@ export class ConnectorsPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'connectors';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
// Coming back from a connector's page must show its new state, not the state
|
||||
// captured before the user activated it.
|
||||
window.addEventListener('connectors-changed', () => { if (this._open) this._load(); });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
|
||||
|
||||
async _load() {
|
||||
@@ -153,11 +159,11 @@ export class ConnectorsPage extends LightElement {
|
||||
async _saveProvider() {
|
||||
const f = this._pForm;
|
||||
if (!f.name.trim() || !f.client_id.trim()) {
|
||||
this._pError = 'Name and client id are required.';
|
||||
this._pError = t('connectors.providers.error.name_client');
|
||||
return;
|
||||
}
|
||||
if (f._isNew && !f.client_secret.trim()) {
|
||||
this._pError = 'A client secret is required for a new provider.';
|
||||
this._pError = t('connectors.providers.error.secret');
|
||||
return;
|
||||
}
|
||||
this._pError = null;
|
||||
@@ -173,7 +179,7 @@ export class ConnectorsPage extends LightElement {
|
||||
}
|
||||
|
||||
async _deleteProvider(name) {
|
||||
if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return;
|
||||
if (!confirm(t('connectors.providers.delete_confirm', { name }))) return;
|
||||
try {
|
||||
await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
this._providers = await jf('/api/mcp/providers');
|
||||
@@ -240,17 +246,17 @@ export class ConnectorsPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<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">
|
||||
${this._isAdmin ? html`
|
||||
<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 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 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}
|
||||
</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}
|
||||
|
||||
${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`
|
||||
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
|
||||
<div class="connector-filters">
|
||||
<div class="connector-search">
|
||||
<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; }} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,15 +289,12 @@ export class ConnectorsPage extends LightElement {
|
||||
@click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
|
||||
<div class="connector-card" style="width:100%;max-width:560px;cursor:default">
|
||||
<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()}>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-muted mb-3" style="font-size:.78rem">
|
||||
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>
|
||||
<div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.providers.desc')}</div>
|
||||
${this._pError ? html`
|
||||
<div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing}
|
||||
${this._pForm ? this._renderProviderForm() : this._renderProviderList()}
|
||||
@@ -304,7 +307,7 @@ export class ConnectorsPage extends LightElement {
|
||||
return html`
|
||||
${list.length === 0 ? html`
|
||||
<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">
|
||||
${list.map(p => html`
|
||||
<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>
|
||||
<div class="text-muted" style="font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
${p.has_client_secret
|
||||
? html`<i class="bi bi-check-circle text-success"></i> secret set`
|
||||
: html`<i class="bi bi-exclamation-triangle text-warning"></i> no secret`}
|
||||
· ${p.client_id || '(no client id)'}
|
||||
? 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> ${t('connectors.providers.no_secret')}`}
|
||||
· ${p.client_id || t('connectors.providers.no_client_id')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
@@ -329,10 +332,10 @@ export class ConnectorsPage extends LightElement {
|
||||
</div>`}
|
||||
<div class="d-flex gap-2">
|
||||
<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 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>
|
||||
</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}
|
||||
</div>`;
|
||||
return html`
|
||||
${field('name', 'Provider id', { req: true, mono: true, ph: 'google',
|
||||
help: 'The slug a connector references (must match the manifest\'s auth.provider).' })}
|
||||
${field('display_name', 'Display name', { ph: 'Google' })}
|
||||
${field('client_id', 'Client id', { req: true, mono: true })}
|
||||
${field('client_secret', 'Client secret', { secret: true, mono: true,
|
||||
help: f._isNew ? 'Required.' : 'Leave blank to keep the stored secret.' })}
|
||||
${field('auth_url', 'Authorization URL', { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })}
|
||||
${field('token_url', 'Token URL', { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
|
||||
${field('redirect_uri', 'Redirect URI', { mono: true,
|
||||
help: 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.' })}
|
||||
${field('extra_params', 'Extra params (JSON)', { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
|
||||
help: 'Merged into the consent URL. Google needs these two to return a refresh token.' })}
|
||||
${field('name', t('connectors.providers.field.name'), { req: true, mono: true, ph: 'google',
|
||||
help: t('connectors.providers.field.name_help') })}
|
||||
${field('display_name', t('connectors.providers.field.display'), { ph: 'Google' })}
|
||||
${field('client_id', t('connectors.providers.field.client_id'), { req: true, mono: true })}
|
||||
${field('client_secret', t('connectors.providers.field.client_secret'), { secret: true, mono: true,
|
||||
help: f._isNew ? t('connectors.providers.field.secret_help_new') : t('connectors.providers.field.secret_help_edit') })}
|
||||
${field('auth_url', t('connectors.providers.field.auth_url'), { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })}
|
||||
${field('token_url', t('connectors.providers.field.token_url'), { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
|
||||
${field('redirect_uri', t('connectors.providers.field.redirect'), { mono: true,
|
||||
help: t('connectors.providers.field.redirect_help') })}
|
||||
${field('extra_params', t('connectors.providers.field.extra'), { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
|
||||
help: t('connectors.providers.field.extra_help') })}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<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 class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = null; this._pError = null; }}>
|
||||
Cancel
|
||||
${t('connectors.providers.cancel')}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -375,14 +378,14 @@ export class ConnectorsPage extends LightElement {
|
||||
_renderEmpty() {
|
||||
if (this._q.trim()) {
|
||||
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`
|
||||
<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
|
||||
? 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">Ask an admin to make one available.</p>`}
|
||||
? html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.install_hint')}</p>`
|
||||
: html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.ask_admin')}</p>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -407,7 +410,7 @@ export class ConnectorsPage extends LightElement {
|
||||
<div class="connector-card-sub">${r.name}</div>
|
||||
</div>
|
||||
<span class=${`connector-chip${STATUS_LABEL[status].tone ? ` connector-chip--${STATUS_LABEL[status].tone}` : ''}`}>
|
||||
${STATUS_LABEL[status].text}
|
||||
${statusText(status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -415,11 +418,11 @@ export class ConnectorsPage extends LightElement {
|
||||
|
||||
<div class="connector-chips">
|
||||
<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>
|
||||
${isScript ? html`
|
||||
<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}
|
||||
${r.auth_kind && r.auth_kind !== 'none' ? html`
|
||||
<span class="connector-chip"><i class="bi bi-key"></i>${r.auth_kind}</span>` : nothing}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { renderMarkdown } from '../lib/base.js';
|
||||
import { openFile } from '../lib/open-file.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,7 +34,7 @@ function renderPath(seg, path) {
|
||||
if (!path || seg !== path) return html`<code>${seg}</code>`;
|
||||
const open = (e) => { e.stopPropagation(); openFile(seg); };
|
||||
return html`<span class="copilot-tool-path" role="button" tabindex="0"
|
||||
title="Open in viewer"
|
||||
title=${t('copilot.open_in_viewer')}
|
||||
@click=${open}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }}
|
||||
>${seg}</span>`;
|
||||
@@ -90,7 +91,7 @@ export function renderDiff(oldText, newText) {
|
||||
result.push(html`<span class="diff-unchanged">${eqBuf.join('\n')}\n</span>`);
|
||||
} else {
|
||||
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>`);
|
||||
}
|
||||
eqBuf = [];
|
||||
@@ -118,15 +119,15 @@ export function renderPendingWrite(host, msg) {
|
||||
<div class="copilot-approval-header">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
<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)}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }}
|
||||
>${msg.path}</span>
|
||||
${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'
|
||||
? html`<span class="badge bg-success ms-auto">Approved</span>`
|
||||
: html`<span class="badge bg-danger ms-auto">Rejected</span>`}
|
||||
? html`<span class="badge bg-success ms-auto">${t('approval.approved')}</span>`
|
||||
: html`<span class="badge bg-danger ms-auto">${t('approval.rejected')}</span>`}
|
||||
</div>
|
||||
|
||||
<pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre>
|
||||
@@ -137,33 +138,33 @@ export function renderPendingWrite(host, msg) {
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Optional: explain why you rejected this (sent to the LLM)"
|
||||
placeholder=${t('approval.reject_hint')}
|
||||
.value=${host._rejectNote}
|
||||
@input=${(e) => { host._rejectNote = e.target.value; }}
|
||||
></textarea>
|
||||
<div class="copilot-approval-btns">
|
||||
<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 class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}>
|
||||
Cancel
|
||||
${t('copilot.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-btns">
|
||||
<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 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 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)}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
<i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
|
||||
</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)}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
@@ -183,13 +184,13 @@ export function renderTool(host, msg) {
|
||||
msg.status === 'running'
|
||||
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
|
||||
: 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'
|
||||
? html`<i class="bi bi-check-circle-fill text-success"></i>`
|
||||
: 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'
|
||||
? 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>`;
|
||||
|
||||
return html`
|
||||
@@ -197,7 +198,7 @@ export function renderTool(host, msg) {
|
||||
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
|
||||
<span class="copilot-tool-status">${statusIcon}</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>
|
||||
</button>
|
||||
${isOpen ? html`
|
||||
@@ -226,7 +227,7 @@ export function renderTool(host, msg) {
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Type your answer…"
|
||||
placeholder=${t('copilot.clarification_ph')}
|
||||
.value=${host._clarificationAnswer}
|
||||
@input=${(e) => { host._clarificationAnswer = e.target.value; }}
|
||||
@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"
|
||||
@click=${() => host._answerQuestion(msg)}
|
||||
?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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,38 +245,38 @@ export function renderTool(host, msg) {
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Reason for rejection (optional, sent to the LLM)"
|
||||
placeholder=${t('approval.reject_hint')}
|
||||
.value=${host._rejectNote}
|
||||
@input=${(e) => { host._rejectNote = e.target.value; }}
|
||||
></textarea>
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-danger"
|
||||
@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 class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => { host._rejectingId = null; }}>
|
||||
Cancel
|
||||
${t('copilot.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-success"
|
||||
@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 class="btn btn-sm btn-outline-danger"
|
||||
@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>
|
||||
${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); }}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
<i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
|
||||
</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); }}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -284,7 +285,7 @@ export function renderTool(host, msg) {
|
||||
`) : msg.status !== 'running' ? (
|
||||
msg.status === 'done' && msg.result_type === 'json' ? html`
|
||||
<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">${
|
||||
truncate(prettyJson(msg.result))
|
||||
}</pre>
|
||||
@@ -292,7 +293,7 @@ export function renderTool(host, msg) {
|
||||
` : html`
|
||||
<div class="copilot-tool-section">
|
||||
<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>
|
||||
<pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${
|
||||
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>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
</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>
|
||||
${msg.prompt_preview ? html`
|
||||
<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>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
</span>
|
||||
<span class="copilot-agent-badge done">finished</span>
|
||||
<span class="copilot-agent-badge done">${t('copilot.agent_finished')}</span>
|
||||
</div>
|
||||
${msg.result_preview ? html`
|
||||
<pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre>
|
||||
@@ -345,7 +346,7 @@ export function renderAgentEnd(msg) {
|
||||
}
|
||||
|
||||
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>
|
||||
</span>`;
|
||||
}
|
||||
@@ -393,7 +394,7 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
|
||||
<span class="attach-chip-name">${att.name}</span>
|
||||
${att.filesize != null ? html`<span class="attach-chip-size">${fmtSize(att.filesize)}</span>` : nothing}
|
||||
${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); }}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>` : nothing}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../lib/chat-session.js';
|
||||
import { t, I18nMixin } from '../lib/i18n.js';
|
||||
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
|
||||
|
||||
// Built-in (server-handled) slash commands shown at the top of the composer
|
||||
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
|
||||
// `/api/commands` and appended below.
|
||||
const SYSTEM_COMMAND_ITEMS = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Start a new conversation' },
|
||||
{ name: 'new', description: 'Alias for /clear' },
|
||||
{ name: 'models', description: 'List available LLM models' },
|
||||
{ name: 'model', description: 'Select the model for this chat' },
|
||||
{ name: 'context', description: "Last turn's token usage" },
|
||||
{ name: 'cost', description: 'Session spend (USD)' },
|
||||
{ name: 'compact', description: 'Force context compaction' },
|
||||
{ name: 'resettools', description: 'Remove activated tool groups' },
|
||||
{ name: 'sethome', description: 'Set web as notification home' },
|
||||
{ name: 'help', description: () => t('copilot.cmd.help') },
|
||||
{ name: 'clear', description: () => t('copilot.cmd.clear') },
|
||||
{ name: 'new', description: () => t('copilot.cmd.new') },
|
||||
{ name: 'models', description: () => t('copilot.cmd.models') },
|
||||
{ name: 'model', description: () => t('copilot.cmd.model') },
|
||||
{ name: 'context', description: () => t('copilot.cmd.context') },
|
||||
{ name: 'cost', description: () => t('copilot.cmd.cost') },
|
||||
{ name: 'compact', description: () => t('copilot.cmd.compact') },
|
||||
{ name: 'resettools', description: () => t('copilot.cmd.resettools') },
|
||||
{ name: 'sethome', description: () => t('copilot.cmd.sethome') },
|
||||
];
|
||||
|
||||
export class AppCopilot extends ChatSession {
|
||||
export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
static properties = {
|
||||
_collapsed: { state: true },
|
||||
_mode: { state: true },
|
||||
_me: { state: true },
|
||||
_modelOpen: { state: true },
|
||||
_tabs: { state: true },
|
||||
_activeSource: { state: true },
|
||||
@@ -31,6 +34,9 @@ export class AppCopilot extends ChatSession {
|
||||
constructor() {
|
||||
super();
|
||||
this._collapsed = false;
|
||||
// 'full' fills the workspace (home route), 'dock' is the side panel.
|
||||
this._mode = 'dock';
|
||||
this._me = null;
|
||||
this._modelOpen = false;
|
||||
this._resizing = false;
|
||||
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
|
||||
@@ -41,23 +47,53 @@ export class AppCopilot extends ChatSession {
|
||||
this._allCommands = null;
|
||||
// Browser-style tabs: 'General' (the default 'web' source) is always present and
|
||||
// not closable; project chats are added on demand and addressed by their source.
|
||||
this._tabs = [{ source: 'web', label: 'General' }];
|
||||
this._tabs = [{ source: 'web', label: t('chat.tab.general') }];
|
||||
this._onResizeMove = this._onResizeMove.bind(this);
|
||||
this._onResizeUp = this._onResizeUp.bind(this);
|
||||
this._onKeydown = this._onKeydown.bind(this);
|
||||
this._onKeyup = this._onKeyup.bind(this);
|
||||
this._onProjectChatOpen = this._onProjectChatOpen.bind(this);
|
||||
this._onCopilotOpen = this._onCopilotOpen.bind(this);
|
||||
this._onPageChange = this._onPageChange.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
this._restoreState();
|
||||
this._loadCommands();
|
||||
this._loadMe();
|
||||
// Same element, two layouts: the chat is the home page ('full') and docks
|
||||
// to the side on every other route — state is never lost, it only resizes.
|
||||
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
|
||||
window.addEventListener('keydown', this._onKeydown);
|
||||
window.addEventListener('keyup', this._onKeyup);
|
||||
window.addEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.addEventListener('copilot-open', this._onCopilotOpen);
|
||||
window.addEventListener('llm-page-change', this._onPageChange);
|
||||
}
|
||||
|
||||
_pageFromHash() {
|
||||
const m = location.hash.slice(1).match(/^([^/?]+)/);
|
||||
const seg = m ? m[1] : '';
|
||||
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'];
|
||||
return known.includes(seg) ? seg : 'home';
|
||||
}
|
||||
|
||||
_onPageChange(e) {
|
||||
this._applyMode(e.detail?.page === 'home' ? 'full' : 'dock');
|
||||
}
|
||||
|
||||
_applyMode(mode) {
|
||||
if (mode === this._mode && this.getAttribute('mode') === mode) return;
|
||||
this._mode = mode;
|
||||
this.setAttribute('mode', mode);
|
||||
}
|
||||
|
||||
async _loadMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) this._me = await res.json();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_restoreState() {
|
||||
@@ -74,6 +110,7 @@ export class AppCopilot extends ChatSession {
|
||||
window.removeEventListener('keyup', this._onKeyup);
|
||||
window.removeEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.removeEventListener('copilot-open', this._onCopilotOpen);
|
||||
window.removeEventListener('llm-page-change', this._onPageChange);
|
||||
}
|
||||
|
||||
_onCopilotOpen() {
|
||||
@@ -254,36 +291,82 @@ export class AppCopilot extends ChatSession {
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_sendSuggestion(text) {
|
||||
const el = this._inputEl();
|
||||
if (!el) return;
|
||||
el.value = text;
|
||||
this._send();
|
||||
}
|
||||
|
||||
_renderEmptyState() {
|
||||
// Dock mode keeps the compact greeting bubble; full mode (home) shows the
|
||||
// welcome hero with a few prompt suggestions to get a conversation going.
|
||||
if (this._mode !== 'full') {
|
||||
return html`<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() {
|
||||
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`
|
||||
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
|
||||
${!full ? html`
|
||||
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
|
||||
` : nothing}
|
||||
|
||||
<div class="copilot-header">
|
||||
<i class="bi bi-stars"></i>
|
||||
<span>Copilot</span>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
|
||||
title="Collapse copilot"
|
||||
@click=${() => { this._setCollapsed(true); }}
|
||||
>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
<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
|
||||
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
|
||||
title=${t('chat.collapse')}
|
||||
@click=${() => { this._setCollapsed(true); }}
|
||||
>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${this._tabs.length > 1 ? html`
|
||||
<div class="copilot-tabs">
|
||||
${this._tabs.map(t => html`
|
||||
${this._tabs.map(tab => html`
|
||||
<div
|
||||
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(t.source)}
|
||||
title=${t.label}
|
||||
class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(tab.source)}
|
||||
title=${tab.label}
|
||||
>
|
||||
<span class="copilot-tab-label">${t.label}</span>
|
||||
${t.source !== 'web' ? html`
|
||||
<button class="copilot-tab-close" title="Close tab"
|
||||
@click=${e => this._closeTab(t.source, e)}>
|
||||
<span class="copilot-tab-label">${tab.label}</span>
|
||||
${tab.source !== 'web' ? html`
|
||||
<button class="copilot-tab-close" title=${t('chat.close_tab')}
|
||||
@click=${e => this._closeTab(tab.source, e)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
@@ -293,16 +376,14 @@ export class AppCopilot extends ChatSession {
|
||||
` : nothing}
|
||||
|
||||
<div class="copilot-messages">
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="copilot-msg assistant">
|
||||
Hello! How can I help you today?
|
||||
</div>
|
||||
` : this._messages.map(m => renderMsg(this, m))}
|
||||
${this._messages.length === 0
|
||||
? this._renderEmptyState()
|
||||
: this._messages.map(m => renderMsg(this, m))}
|
||||
|
||||
${this._waiting ? html`
|
||||
<div class="copilot-msg assistant copilot-thinking">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Thinking…
|
||||
${t('chat.thinking')}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -319,7 +400,7 @@ export class AppCopilot extends ChatSession {
|
||||
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
|
||||
>
|
||||
<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>
|
||||
`)}
|
||||
</div>
|
||||
@@ -335,7 +416,7 @@ export class AppCopilot extends ChatSession {
|
||||
<textarea
|
||||
class="copilot-textarea"
|
||||
rows="1"
|
||||
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)"
|
||||
placeholder=${t('chat.placeholder')}
|
||||
@keydown=${this._composerKeydown}
|
||||
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
@@ -344,7 +425,7 @@ export class AppCopilot extends ChatSession {
|
||||
<div class="copilot-toolbar-left">
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="Attach files"
|
||||
title=${t('chat.attach')}
|
||||
@click=${() => this.querySelector('.copilot-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
@@ -369,7 +450,7 @@ export class AppCopilot extends ChatSession {
|
||||
` : nothing}
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="New session"
|
||||
title=${t('chat.new_session')}
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
@@ -377,18 +458,18 @@ export class AppCopilot extends ChatSession {
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
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()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
${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>
|
||||
</button>`
|
||||
: 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import { InboxMixin } from '../lib/inbox-mixin.js';
|
||||
|
||||
const GUIDE = [
|
||||
{
|
||||
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) {
|
||||
export class DashboardPage extends InboxMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -55,8 +11,6 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
_open: { state: true },
|
||||
_models: { state: true },
|
||||
_plugins: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_debugLoading: { state: true },
|
||||
_stats: { state: true },
|
||||
_statsRange: { state: true },
|
||||
};
|
||||
@@ -68,8 +22,6 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
this._models = null; // null = loading, [] = no models configured
|
||||
this._plugins = null;
|
||||
this._pollTimer = null;
|
||||
this._debugMode = false;
|
||||
this._debugLoading = true;
|
||||
this._stats = null; // null = loading
|
||||
this._statsRange = 'week';
|
||||
this._chartInstances = {};
|
||||
@@ -78,8 +30,10 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'home';
|
||||
this._open = e.detail.page === 'dashboard';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
this._loadAll();
|
||||
@@ -92,6 +46,7 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
this._destroyCharts();
|
||||
@@ -120,39 +75,9 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
this._loadModels(),
|
||||
this._loadPlugins(),
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('/api/llm/models');
|
||||
@@ -195,15 +120,27 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
}
|
||||
|
||||
get _statusInfo() {
|
||||
if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: 'Loading…' };
|
||||
if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'No LLM 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 === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: 'Degraded' };
|
||||
return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'All models offline' };
|
||||
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: t('dashboard.status.no_models') };
|
||||
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: t('dashboard.status.degraded') };
|
||||
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) {
|
||||
const url = page === 'home' ? location.pathname : '#' + page;
|
||||
const url = '#' + page;
|
||||
history.pushState({ page }, '', url);
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
@@ -225,7 +162,7 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -341,15 +278,15 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: isHour ? [
|
||||
lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: 'Input' }),
|
||||
lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: 'Output' }),
|
||||
lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: 'Cached' }),
|
||||
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: t('dashboard.stats.chart.output') }),
|
||||
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)));
|
||||
return [
|
||||
{ label: 'Cached', data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
|
||||
{ label: '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.cached'), data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
|
||||
{ label: t('dashboard.stats.chart.non_cached'), data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', 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;
|
||||
if (!total) return '';
|
||||
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() {
|
||||
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;
|
||||
@@ -421,7 +358,7 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
return html`
|
||||
<div class="home-stats-empty">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>No LLM requests in the selected range.</span>
|
||||
<span>${t('dashboard.stats.empty')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -429,19 +366,19 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
return html`
|
||||
<div class="home-stats-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -459,28 +396,14 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
return html`
|
||||
<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 ── -->
|
||||
<div class="home-hero">
|
||||
<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 class="home-hero-text">
|
||||
<h1 class="home-hero-title">Skald</h1>
|
||||
<p class="home-hero-desc">Your AI command centre — research, code, plan, and orchestrate. All in one place.</p>
|
||||
<h1 class="home-hero-title">${t('chat.title')}</h1>
|
||||
<p class="home-hero-desc">${t('dashboard.hero.subtitle')}</p>
|
||||
<div class="home-hero-status home-hero-status--${st.cls}">
|
||||
${st.dot ? html`<span class="home-hero-dot"></span>` : 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-icon"><i class="bi bi-cpu-fill"></i></div>
|
||||
<div class="home-banner-body">
|
||||
<strong>No LLM models configured.</strong>
|
||||
Start by adding a provider (Anthropic, OpenAI, OpenRouter…), then add at least one model in the Models section.
|
||||
<strong>${t('dashboard.banner.no_models.title')}</strong>
|
||||
${t('dashboard.banner.no_models.desc')}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
|
||||
Add a provider
|
||||
${t('dashboard.banner.no_models.action')}
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
@@ -506,9 +429,9 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
<!-- ── LLM Stats ── -->
|
||||
<div class="home-section-title">
|
||||
<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">
|
||||
${[['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' : ''}"
|
||||
@click=${() => this._setRange(r)}>${label}</button>
|
||||
`)}
|
||||
@@ -519,9 +442,9 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
<!-- ── Pending inbox ── -->
|
||||
<div class="home-section-title">
|
||||
<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}
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -532,8 +455,8 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
<div class="home-tip">
|
||||
<div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div>
|
||||
<div class="home-tip-body">
|
||||
<strong>Enable Honcho</strong>
|
||||
<span>Persistent long-term memory — the agent learns your preferences over time. Ask the Copilot to enable it.</span>
|
||||
<strong>${t('dashboard.tip.honcho.title')}</strong>
|
||||
<span>${t('dashboard.tip.honcho.desc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
@@ -541,10 +464,10 @@ export class HomePage extends InboxMixin(LightElement) {
|
||||
<!-- ── Quick guide ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-map"></i>
|
||||
<span>Quick guide</span>
|
||||
<span>${t('dashboard.section.guide')}</span>
|
||||
</div>
|
||||
<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-icon">
|
||||
<i class="bi ${s.icon}"></i>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import { FileViewerBase } from './shared/file-viewer-base.js';
|
||||
|
||||
const PAGE_ID = 'file_viewer';
|
||||
@@ -58,14 +59,14 @@ export class FileViewerPage extends FileViewerBase {
|
||||
<div class="llm-page fv-page">
|
||||
<div class="llm-page-header">
|
||||
<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>
|
||||
</button>
|
||||
<h2 class="llm-page-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2>
|
||||
</div>
|
||||
<div class="fv-header-actions">
|
||||
${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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
function emptyForm(firstTypeId = '') {
|
||||
return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' };
|
||||
@@ -35,6 +36,8 @@ export class LlmProvidersPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'providers';
|
||||
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() {
|
||||
try {
|
||||
const [typesRes, provRes, modelsRes] = await Promise.all([
|
||||
@@ -49,9 +57,9 @@ export class LlmProvidersPage extends LightElement {
|
||||
fetch('/api/llm/providers'),
|
||||
fetch('/api/llm/models'),
|
||||
]);
|
||||
if (!typesRes.ok) throw new Error(`Provider types: HTTP ${typesRes.status}`);
|
||||
if (!provRes.ok) throw new Error(`Providers: HTTP ${provRes.status}`);
|
||||
if (!modelsRes.ok) throw new Error(`Models: HTTP ${modelsRes.status}`);
|
||||
if (!typesRes.ok) throw new Error(`HTTP ${typesRes.status}`);
|
||||
if (!provRes.ok) throw new Error(`HTTP ${provRes.status}`);
|
||||
if (!modelsRes.ok) throw new Error(`HTTP ${modelsRes.status}`);
|
||||
|
||||
const providerTypes = await typesRes.json();
|
||||
const providers = await provRes.json();
|
||||
@@ -104,7 +112,7 @@ export class LlmProvidersPage extends LightElement {
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' });
|
||||
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-type-badge">${label}</span>
|
||||
${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}
|
||||
</span>
|
||||
` : ''}
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -199,10 +207,10 @@ export class LlmProvidersPage extends LightElement {
|
||||
<div class="pv-card-row3">
|
||||
<span class="pv-card-tag ${hasKey ? 'pv-tag-ok' : 'pv-tag-missing'}">
|
||||
<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>
|
||||
${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>
|
||||
<span class="pv-card-url-text">${p.base_url}</span>
|
||||
</span>
|
||||
@@ -232,7 +240,7 @@ export class LlmProvidersPage extends LightElement {
|
||||
<div class="agent-dialog pv-modal">
|
||||
<div class="pv-modal-header">
|
||||
<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()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
@@ -242,13 +250,13 @@ export class LlmProvidersPage extends LightElement {
|
||||
|
||||
<form @submit=${(e) => this._onSubmit(e)}>
|
||||
<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
|
||||
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 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}
|
||||
@change=${(e) => this._setField('type', e.target.value)}>
|
||||
${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`
|
||||
<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}
|
||||
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)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${needsUrl ? html`
|
||||
<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}
|
||||
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)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<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=${(e) => this._setField('description', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save changes' : 'Add provider'}`}
|
||||
? 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 ? t('providers.modal.save_changes') : t('providers.modal.add_provider')}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -301,13 +309,12 @@ export class LlmProvidersPage extends LightElement {
|
||||
<div class="pv-page">
|
||||
<div class="pv-header">
|
||||
<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>
|
||||
<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()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('providers.add')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,9 +326,9 @@ export class LlmProvidersPage extends LightElement {
|
||||
${this._providers.length === 0 ? html`
|
||||
<div class="pv-empty">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
` : this._providers.map(p => this._renderCard(p))}
|
||||
|
||||
@@ -1,6 +1,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 { t } from '../lib/i18n.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -180,6 +181,17 @@ export class LlmRequestDetail extends LightElement {
|
||||
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) {
|
||||
if (changed.has('detailId') && this.detailId != null) {
|
||||
this._detail = null;
|
||||
@@ -263,20 +275,20 @@ export class LlmRequestDetail extends LightElement {
|
||||
_renderStatBar(d) {
|
||||
return html`
|
||||
<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-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}
|
||||
<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)}
|
||||
</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)}
|
||||
</span>
|
||||
${d.cache_read_tokens > 0 ? html`
|
||||
<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>
|
||||
` : nothing}
|
||||
<span class="llmr-detail-stat">
|
||||
@@ -285,7 +297,7 @@ export class LlmRequestDetail extends LightElement {
|
||||
<span class="llmr-detail-date">${formatDate(d.created_at)}</span>
|
||||
${d.error_text ? html`
|
||||
<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>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -312,7 +324,7 @@ export class LlmRequestDetail extends LightElement {
|
||||
<div class="llmr-reasoning-block">
|
||||
<div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<span>reasoning</span>
|
||||
<span>${t('llmr.detail.reasoning_label')}</span>
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
@@ -346,11 +358,11 @@ export class LlmRequestDetail extends LightElement {
|
||||
</div>
|
||||
${open ? html`
|
||||
<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>
|
||||
${result != null ? html`
|
||||
<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>
|
||||
<pre class="llmr-tool-pre">${result.content}</pre>
|
||||
` : 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-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<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>
|
||||
${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">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
@@ -403,7 +415,7 @@ export class LlmRequestDetail extends LightElement {
|
||||
if (!text) return nothing;
|
||||
return html`
|
||||
<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-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div>
|
||||
</div>
|
||||
@@ -434,12 +446,12 @@ export class LlmRequestDetail extends LightElement {
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-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>
|
||||
</div>
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
<span>${t('llmr.detail.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -448,7 +460,7 @@ export class LlmRequestDetail extends LightElement {
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-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>
|
||||
</div>
|
||||
<div class="llmr-state llmr-state--error">
|
||||
@@ -479,10 +491,10 @@ export class LlmRequestDetail extends LightElement {
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -491,34 +503,34 @@ export class LlmRequestDetail extends LightElement {
|
||||
${payloadMissing ? html`
|
||||
<div class="llmr-purged-banner">
|
||||
<i class="bi bi-hourglass-split"></i>
|
||||
Payload not available — this request has been purged by the retention policy.
|
||||
${t('llmr.detail.purged')}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${hdrs ? this._renderSection('req-headers', 'Request Headers',
|
||||
${hdrs ? this._renderSection('req-headers', t('llmr.detail.section_req_headers'),
|
||||
this._renderKvTable(Object.entries(hdrs))
|
||||
) : nothing}
|
||||
|
||||
${respHdrs ? this._renderSection('resp-headers', 'Response Headers',
|
||||
${respHdrs ? this._renderSection('resp-headers', t('llmr.detail.section_resp_headers'),
|
||||
this._renderKvTable(Object.entries(respHdrs))
|
||||
) : nothing}
|
||||
|
||||
${params.length ? this._renderSection('params', 'Parameters',
|
||||
${params.length ? this._renderSection('params', t('llmr.detail.section_params'),
|
||||
this._renderKvTable(params)
|
||||
) : 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>`
|
||||
) : nothing}
|
||||
|
||||
${msgs.length ? this._renderSection('conversation', 'Conversation',
|
||||
${msgs.length ? this._renderSection('conversation', t('llmr.detail.section_conversation'),
|
||||
html`<div class="llmr-msg-list">
|
||||
${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))}
|
||||
</div>`,
|
||||
msgs.length
|
||||
) : 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">
|
||||
${tools.map((t, i) => {
|
||||
// Anthropic: { name, description, input_schema }
|
||||
@@ -550,7 +562,7 @@ export class LlmRequestDetail extends LightElement {
|
||||
tools.length
|
||||
) : nothing}
|
||||
|
||||
${resp ? this._renderSection('response', 'Response',
|
||||
${resp ? this._renderSection('response', t('llmr.detail.section_response'),
|
||||
html`
|
||||
${respMeta.length ? this._renderKvTable(respMeta) : nothing}
|
||||
<div class="llmr-msg-list llmr-msg-list--resp">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const PAGE_ID = 'llm-requests';
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -25,8 +26,8 @@ function cacheHitPct(item) {
|
||||
|
||||
function cacheTooltip(item) {
|
||||
const parts = [];
|
||||
if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`);
|
||||
if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_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(t('llmr.cache_write', { n: item.cache_creation_tokens.toLocaleString() }));
|
||||
return parts.length ? parts.join(' | ') : '';
|
||||
}
|
||||
|
||||
@@ -64,6 +65,8 @@ export class LlmRequestsPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -75,6 +78,11 @@ export class LlmRequestsPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
_idFromHash() {
|
||||
const parts = location.hash.replace('#', '').split('/');
|
||||
if (parts[0] === PAGE_ID && parts[1]) {
|
||||
@@ -138,29 +146,29 @@ export class LlmRequestsPage extends LightElement {
|
||||
return html`
|
||||
<div class="llmr-filters">
|
||||
<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"
|
||||
placeholder="e.g. main"
|
||||
placeholder=${t('llmr.filter.agent_ph')}
|
||||
.value=${this._agentId}
|
||||
@input=${e => this._agentId = e.target.value}
|
||||
@keydown=${e => e.key === 'Enter' && this._apply()} />
|
||||
</div>
|
||||
<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"
|
||||
placeholder="e.g. web, tic, cron"
|
||||
placeholder=${t('llmr.filter.source_ph')}
|
||||
.value=${this._source}
|
||||
@input=${e => this._source = e.target.value}
|
||||
@keydown=${e => e.key === 'Enter' && this._apply()} />
|
||||
</div>
|
||||
<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"
|
||||
.value=${this._from}
|
||||
@change=${e => this._from = e.target.value} />
|
||||
</div>
|
||||
<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"
|
||||
.value=${this._to}
|
||||
@change=${e => this._to = e.target.value} />
|
||||
@@ -168,11 +176,11 @@ export class LlmRequestsPage extends LightElement {
|
||||
<div class="llmr-filter-actions">
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._apply()}
|
||||
?disabled=${this._loading}>
|
||||
Apply
|
||||
${t('llmr.filter.apply')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()}
|
||||
?disabled=${this._loading}>
|
||||
Reset
|
||||
${t('llmr.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +191,7 @@ export class LlmRequestsPage extends LightElement {
|
||||
if (this._loading) return html`
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
<span>${t('llmr.loading')}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
@@ -195,7 +203,7 @@ export class LlmRequestsPage extends LightElement {
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="llmr-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No requests found.</span>
|
||||
<span>${t('llmr.empty')}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -204,14 +212,14 @@ export class LlmRequestsPage extends LightElement {
|
||||
<table class="table table-sm llmr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Source</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">In tokens</th>
|
||||
<th class="text-end">Out tokens</th>
|
||||
<th class="text-end">Cache hit</th>
|
||||
<th class="text-end">ms</th>
|
||||
<th>${t('llmr.table.agent')}</th>
|
||||
<th>${t('llmr.table.source')}</th>
|
||||
<th>${t('llmr.table.model')}</th>
|
||||
<th>${t('llmr.table.date')}</th>
|
||||
<th class="text-end">${t('llmr.table.in_tokens')}</th>
|
||||
<th class="text-end">${t('llmr.table.out_tokens')}</th>
|
||||
<th class="text-end">${t('llmr.table.cache_hit')}</th>
|
||||
<th class="text-end">${t('llmr.table.ms')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -254,7 +262,7 @@ export class LlmRequestsPage extends LightElement {
|
||||
@click=${() => this._fetch(cur - 1)}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="llmr-page-info">Page ${cur} of ${pages} — ${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}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
@@ -276,8 +284,8 @@ export class LlmRequestsPage extends LightElement {
|
||||
return html`
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-header">
|
||||
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> LLM Requests</h2>
|
||||
<span class="llmr-total-badge">${this._total} rows</span>
|
||||
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> ${t('llmr.title')}</h2>
|
||||
<span class="llmr-total-badge">${t('llmr.total', { n: this._total })}</span>
|
||||
</div>
|
||||
${this._renderFilters()}
|
||||
${this._renderTable()}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html } from 'lit';
|
||||
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() {
|
||||
return {
|
||||
@@ -27,7 +28,7 @@ export class LoginPage extends LightElement {
|
||||
this._error = null;
|
||||
|
||||
if (!this._username.trim() || !this._password) {
|
||||
this._error = 'Enter your username and password.';
|
||||
this._error = t('login.missing');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,13 +47,13 @@ export class LoginPage extends LightElement {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this._error = 'Invalid username or password.';
|
||||
this._error = t('login.error');
|
||||
return;
|
||||
}
|
||||
// Logged in — reload into the app.
|
||||
window.location.reload();
|
||||
} catch {
|
||||
this._error = 'Network error — please try again.';
|
||||
this._error = t('login.network');
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
@@ -60,8 +61,8 @@ export class LoginPage extends LightElement {
|
||||
|
||||
render() {
|
||||
const btnLabel = this._busy
|
||||
? html`<span class="login-spinner"></span>Signing in…`
|
||||
: 'Sign in';
|
||||
? html`<span class="login-spinner"></span>${t('login.signing')}`
|
||||
: t('login.submit');
|
||||
|
||||
return html`
|
||||
<div class="login-page">
|
||||
@@ -69,13 +70,13 @@ export class LoginPage extends LightElement {
|
||||
<div class="login-logo">
|
||||
<img src="/assets/icons/icon-192.png" alt="Skald" />
|
||||
</div>
|
||||
<h1 class="login-title">Welcome back</h1>
|
||||
<p class="login-subtitle">Sign in to your account.</p>
|
||||
<h1 class="login-title">${t('login.title')}</h1>
|
||||
<p class="login-subtitle">${t('login.subtitle')}</p>
|
||||
|
||||
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<label class="form-label">${t('login.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
@@ -86,7 +87,7 @@ export class LoginPage extends LightElement {
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<label class="form-label">${t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
// Connector marketplace — blueprint §14/§15.
|
||||
//
|
||||
@@ -57,6 +59,8 @@ export class MarketplacePage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'marketplace';
|
||||
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; }
|
||||
|
||||
async _load() {
|
||||
@@ -91,9 +100,9 @@ export class MarketplacePage extends LightElement {
|
||||
|
||||
async _install(card) {
|
||||
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._error = null;
|
||||
try {
|
||||
@@ -137,13 +146,13 @@ export class MarketplacePage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<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">
|
||||
<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>
|
||||
${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>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -156,28 +165,23 @@ export class MarketplacePage extends LightElement {
|
||||
${this._me && !this._isAdmin ? html`
|
||||
<div class="um-empty" style="padding:2rem">
|
||||
<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">
|
||||
Connectors the admin has installed appear on the
|
||||
<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>
|
||||
${unsafeHTML(t('marketplace.not_admin_link'))}</p>
|
||||
</div>
|
||||
` : html`
|
||||
<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
|
||||
activate anything — it makes a connector <em>available</em>.
|
||||
${unsafeHTML(t('marketplace.desc'))}
|
||||
</div>
|
||||
|
||||
${this._feedErr ? html`
|
||||
<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}
|
||||
|
||||
${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()}
|
||||
`}
|
||||
</div>
|
||||
@@ -202,13 +206,13 @@ export class MarketplacePage extends LightElement {
|
||||
<div class="connector-filters">
|
||||
<div class="connector-search">
|
||||
<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; }} />
|
||||
</div>
|
||||
${this._segment('Scope', this._scope, (v) => { this._scope = v; },
|
||||
[['All', 'all'], ['Global', 'global'], ['Per-user', 'per_user']])}
|
||||
${this._segment('Type', this._source, (v) => { this._source = v; },
|
||||
[['All', 'all'], ['Remote', 'remote'], ['Local', 'local_script']])}
|
||||
${this._segment(t('marketplace.filter.scope'), this._scope, (v) => { this._scope = v; },
|
||||
[[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.global'), 'global'], [t('marketplace.filter.per_user'), 'per_user']])}
|
||||
${this._segment(t('marketplace.filter.type'), this._source, (v) => { this._source = v; },
|
||||
[[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.remote'), 'remote'], [t('marketplace.filter.local'), 'local_script']])}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -218,7 +222,7 @@ export class MarketplacePage extends LightElement {
|
||||
if (cards.length === 0) {
|
||||
return html`
|
||||
<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`
|
||||
<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-sub">${c.id}${c.version ? ` · v${c.version}` : ''}</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>
|
||||
|
||||
${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">
|
||||
<span class="connector-chip connector-chip--scope">
|
||||
<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 class="connector-chip ${isScript ? 'connector-chip--script' : ''}">
|
||||
<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>
|
||||
${c.auth_kind !== 'none' ? html`
|
||||
<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`
|
||||
<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}
|
||||
${c.oauth_scopes?.length ? html`
|
||||
<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>`)}
|
||||
</details>` : nothing}
|
||||
|
||||
<div class="connector-card-actions">
|
||||
<button class="btn btn-sm ${c.installed ? 'btn-outline-primary' : 'btn-primary'}"
|
||||
?disabled=${busy} @click=${() => this._install(c)}>
|
||||
${busy ? html`<i class="bi bi-hourglass-split me-1"></i>Installing…`
|
||||
: c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>Reinstall`
|
||||
: html`<i class="bi bi-download me-1"></i>Install`}
|
||||
${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>${t('marketplace.card.reinstall')}`
|
||||
: html`<i class="bi bi-download me-1"></i>${t('marketplace.card.install')}`}
|
||||
</button>
|
||||
${c.homepage ? html`
|
||||
<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}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import './shared/inbox-page.js';
|
||||
import './shared/chat-page.js';
|
||||
import './shared/projects-page.js';
|
||||
@@ -199,18 +200,18 @@ class MobileApp extends LitElement {
|
||||
${['notifications', 'settings'].includes(s) ? html`
|
||||
<div class="mobile-coming-soon">
|
||||
<i class="bi bi-tools"></i>
|
||||
<p>Coming soon</p>
|
||||
<p>${t('mobile.coming_soon')}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this._native ? nothing : html`
|
||||
<nav class="mobile-nav">
|
||||
${item('inbox', 'bi-inbox', 'Inbox')}
|
||||
${item('projects', 'bi-folder2-open', 'Projects')}
|
||||
${item('chat', '', 'Chat', 'chat-btn')}
|
||||
${item('notifications', 'bi-bell', 'Alerts')}
|
||||
${item('settings', 'bi-sliders', 'Settings')}
|
||||
${item('inbox', 'bi-inbox', t('mobile.nav.inbox'))}
|
||||
${item('projects', 'bi-folder2-open', t('mobile.nav.projects'))}
|
||||
${item('chat', '', t('mobile.nav.chat'), 'chat-btn')}
|
||||
${item('notifications', 'bi-bell', t('mobile.nav.alerts'))}
|
||||
${item('settings', 'bi-sliders', t('mobile.nav.settings'))}
|
||||
</nav>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: 'llm',
|
||||
icon: 'bi-cpu',
|
||||
title: 'LLM',
|
||||
desc: 'Chat & completion models for agents and tools',
|
||||
id: 'llm',
|
||||
icon: 'bi-cpu',
|
||||
titleKey: 'models.hub.card.llm.title',
|
||||
descKey: 'models.hub.card.llm.desc',
|
||||
},
|
||||
{
|
||||
id: 'transcribe',
|
||||
icon: 'bi-mic',
|
||||
title: 'Transcription',
|
||||
desc: 'Speech-to-text models via cloud or local plugin',
|
||||
id: 'transcribe',
|
||||
icon: 'bi-mic',
|
||||
titleKey: 'models.hub.card.transcribe.title',
|
||||
descKey: 'models.hub.card.transcribe.desc',
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
icon: 'bi-image',
|
||||
title: 'Image Generation',
|
||||
desc: 'Text-to-image models via cloud API',
|
||||
id: 'image',
|
||||
icon: 'bi-image',
|
||||
titleKey: 'models.hub.card.image.title',
|
||||
descKey: 'models.hub.card.image.desc',
|
||||
},
|
||||
{
|
||||
id: 'tts',
|
||||
icon: 'bi-volume-up',
|
||||
title: 'Text-to-Speech',
|
||||
desc: 'Speech synthesis models via cloud or local plugin',
|
||||
id: 'tts',
|
||||
icon: 'bi-volume-up',
|
||||
titleKey: 'models.hub.card.tts.title',
|
||||
descKey: 'models.hub.card.tts.desc',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,6 +45,8 @@ export class ModelsHubPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'models';
|
||||
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() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'models' && parts[1]) {
|
||||
@@ -103,7 +111,7 @@ export class ModelsHubPage extends LightElement {
|
||||
|
||||
_countLabel(id) {
|
||||
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() {
|
||||
@@ -118,9 +126,9 @@ export class ModelsHubPage extends LightElement {
|
||||
|
||||
return html`
|
||||
<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">
|
||||
Configure LLM, transcription, and image generation providers.
|
||||
${t('models.hub.subtitle')}
|
||||
</p>
|
||||
<div class="models-hub-grid">
|
||||
${CARDS.map(card => html`
|
||||
@@ -128,8 +136,8 @@ export class ModelsHubPage extends LightElement {
|
||||
<div class="models-type-card-icon">
|
||||
<i class="bi ${card.icon}"></i>
|
||||
</div>
|
||||
<div class="models-type-card-title">${card.title}</div>
|
||||
<div class="models-type-card-desc">${card.desc}</div>
|
||||
<div class="models-type-card-title">${t(card.titleKey)}</div>
|
||||
<div class="models-type-card-desc">${t(card.descKey)}</div>
|
||||
<div class="models-type-card-count ${this._counts[card.id] > 0 ? 'has-models' : ''}">
|
||||
${this._loading ? '…' : this._countLabel(card.id)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
function emptyIgForm() {
|
||||
return { provider_id: '', model_id: '', name: '', priority: 100 };
|
||||
@@ -31,9 +33,16 @@ export class ModelsImageSection extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
this._load();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
@@ -88,7 +97,7 @@ export class ModelsImageSection extends LightElement {
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/api/image-generate/models/${m.id}`, { method: 'DELETE' });
|
||||
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-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
|
||||
? html`<span class="ig-source-badge ig-source-plugin">${t('models.source_plugin')}</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">${t('models.source_cloud')}</span>`}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
<div class="llm-card-actions">
|
||||
${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>
|
||||
</span>
|
||||
` : 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>
|
||||
</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>
|
||||
</button>
|
||||
`}
|
||||
@@ -189,7 +198,7 @@ export class ModelsImageSection extends LightElement {
|
||||
<div class="llm-card-row2">
|
||||
${!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="ig-priority-tag" title="Priority">#${m.priority}</span>
|
||||
<span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
@@ -208,7 +217,7 @@ export class ModelsImageSection extends LightElement {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<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>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${igProviders.map(p => html`
|
||||
@@ -219,7 +228,7 @@ export class ModelsImageSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -232,8 +241,8 @@ export class ModelsImageSection extends LightElement {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`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.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.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`
|
||||
<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">
|
||||
<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>
|
||||
<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}
|
||||
@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 class="mb-3">
|
||||
<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>
|
||||
<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 }} />
|
||||
</div>
|
||||
|
||||
<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=${(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 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}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -292,17 +301,17 @@ export class ModelsImageSection extends LightElement {
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">Image Generation Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
<h2 class="llm-page-title">${t('models.image.title')}</h2>
|
||||
<span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +319,7 @@ export class ModelsImageSection extends LightElement {
|
||||
<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-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>
|
||||
` : ''}
|
||||
@@ -319,8 +328,7 @@ export class ModelsImageSection extends LightElement {
|
||||
<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-body">
|
||||
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.
|
||||
To add, modify, or remove them, ask the agent directly: it has all the documentation it needs.</p>
|
||||
<p class="mb-0">${t('models.readonly_plugin_full')}</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -333,10 +341,10 @@ export class ModelsImageSection extends LightElement {
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>No image generation models configured.</p>
|
||||
<p>${t('models.list_empty_image')}</p>
|
||||
${canAdd ? html`
|
||||
<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>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
@@ -77,9 +79,16 @@ export class ModelsLlmSection extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
this._load();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes, typesRes] = await Promise.all([
|
||||
@@ -144,7 +153,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
));
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = `Failed to save order: ${e.message}`;
|
||||
this._error = t('models.error.save_order', { msg: e.message });
|
||||
await this._load();
|
||||
}
|
||||
}
|
||||
@@ -183,7 +192,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
this._orModels = await res.json();
|
||||
} catch (e) {
|
||||
this._error = `Failed to load models: ${e.message}`;
|
||||
this._error = t('models.error.load_models', { msg: e.message });
|
||||
} finally {
|
||||
this._orLoading = false;
|
||||
}
|
||||
@@ -226,7 +235,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(model) {
|
||||
if (!confirm(`Delete model "${model.name}"?`)) return;
|
||||
if (!confirm(t('models.confirm_delete', { type: 'LLM', name: model.name }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -248,7 +257,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
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 {
|
||||
@@ -282,7 +291,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
async _submitCatalog(e) {
|
||||
e.preventDefault();
|
||||
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._error = null;
|
||||
|
||||
@@ -334,7 +343,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
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 {
|
||||
@@ -415,26 +424,27 @@ export class ModelsLlmSection extends LightElement {
|
||||
const out = this._fmtP(model.price_output_per_million);
|
||||
if (!inp && !out) return html`<span style="opacity:0.3">—</span>`;
|
||||
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 ?? '?'}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<span class="llm-strength-dot"
|
||||
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
|
||||
title=${STRENGTH_LABELS[strength] ?? strength}></span>
|
||||
title=${label}></span>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStatus(status) {
|
||||
const cfg = {
|
||||
healthy: { color: '#22c55e', title: 'Healthy' },
|
||||
degraded: { color: '#eab308', title: 'Degraded' },
|
||||
down: { color: '#ef4444', title: 'Down' },
|
||||
healthy: { color: '#22c55e', title: t('models.status_healthy') },
|
||||
degraded: { color: '#eab308', title: t('models.status_degraded') },
|
||||
down: { color: '#ef4444', title: t('models.status_down') },
|
||||
}[status] ?? { color: '#888', title: status };
|
||||
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-row1">
|
||||
<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}
|
||||
@click=${() => this._moveUp(i)}>
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<button class="llm-move-btn" title="Move down"
|
||||
<button class="llm-move-btn" title=${t('models.move_down')}
|
||||
?disabled=${last}
|
||||
@click=${() => this._moveDown(i)}>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
@@ -460,12 +470,12 @@ export class ModelsLlmSection extends LightElement {
|
||||
${this._renderStrengthDot(m.strength)}
|
||||
${this._renderStatus(m.status)}
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -480,7 +490,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
${(m.scope ?? []).length > 0 || m.extra_params ? html`
|
||||
<div class="llm-card-row3">
|
||||
${(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>
|
||||
@@ -496,10 +506,10 @@ export class ModelsLlmSection extends LightElement {
|
||||
if (mode.type === 'value_set') {
|
||||
return html`
|
||||
<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"
|
||||
@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`
|
||||
<option value=${v} ?selected=${form.reasoning === v}>${v}</option>
|
||||
`)}
|
||||
@@ -515,7 +525,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
.checked=${enabled}
|
||||
@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">
|
||||
Reasoning (thinking)
|
||||
${t('models.reasoning.thinking')}
|
||||
</label>
|
||||
</div>
|
||||
${enabled ? html`
|
||||
@@ -535,24 +545,24 @@ export class ModelsLlmSection extends LightElement {
|
||||
${this._renderReasoning(form, setField, reasoningMode)}
|
||||
<div class="row g-3 mb-3">
|
||||
<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"
|
||||
@change=${(e) => setField('strength', e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
<option value="">${t('models.strength.none')}</option>
|
||||
${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>
|
||||
</div>
|
||||
<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=${(e) => setField('priority', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
${SCOPE_OPTIONS.map(s => html`
|
||||
<div class="form-check">
|
||||
@@ -568,7 +578,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="m-is-default"
|
||||
.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>
|
||||
`;
|
||||
@@ -580,7 +590,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<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>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html`
|
||||
@@ -591,7 +601,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -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 llm-modal">
|
||||
<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>
|
||||
</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)}>
|
||||
<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
|
||||
placeholder="e.g. gpt-4o"
|
||||
placeholder=${t('models.ph.model_id')}
|
||||
@input=${(e) => this._setField('model_id', e.target.value)}
|
||||
@change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
|
||||
</div>
|
||||
<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}
|
||||
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)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3"
|
||||
.value=${f.extra_params}
|
||||
@@ -636,9 +646,9 @@ export class ModelsLlmSection extends LightElement {
|
||||
</div>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
|
||||
<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}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
${this._saving ? t('models.saving') : t('models.add_model')}
|
||||
</button>
|
||||
</div>
|
||||
</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 llm-modal llm-modal-wide">
|
||||
<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>
|
||||
</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)}>
|
||||
<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"
|
||||
placeholder="Search models…"
|
||||
placeholder=${t('models.search')}
|
||||
.value=${this._orSearch}
|
||||
@input=${(e) => { this._orSearch = e.target.value; }} />
|
||||
|
||||
${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`
|
||||
<div class="llm-or-model-list">
|
||||
${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`
|
||||
<div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}"
|
||||
@click=${() => this._setOrField('model_id', m.id)}>
|
||||
@@ -712,17 +722,17 @@ export class ModelsLlmSection extends LightElement {
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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)} />
|
||||
</div>
|
||||
|
||||
${supportsMaxTokens ? html`
|
||||
<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}
|
||||
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"
|
||||
@input=${(e) => this._setOrField('max_tokens', e.target.value)} />
|
||||
</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)}
|
||||
|
||||
<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}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
${this._saving ? t('models.saving') : t('models.add_model')}
|
||||
</button>
|
||||
</div>
|
||||
</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 llm-modal">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
${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)}>
|
||||
<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}
|
||||
placeholder=${m.model_id || 'model name'}
|
||||
@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>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
|
||||
<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}>
|
||||
${this._saving ? 'Saving…' : 'Save changes'}
|
||||
${this._saving ? t('models.saving') : t('models.save_changes')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -785,18 +795,18 @@ export class ModelsLlmSection extends LightElement {
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">LLM Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
<h2 class="llm-page-title">${t('models.llm.title')}</h2>
|
||||
<span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}
|
||||
?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>
|
||||
</div>
|
||||
|
||||
@@ -804,7 +814,7 @@ export class ModelsLlmSection extends LightElement {
|
||||
<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-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>
|
||||
` : ''}
|
||||
@@ -817,9 +827,9 @@ export class ModelsLlmSection extends LightElement {
|
||||
${this._models.length === 0 && this._providers.length > 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
` : this._models.map((m, i) => this._renderCard(m, i))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
function emptyTForm() {
|
||||
return { provider_id: '', model_id: '', name: '', language: '', priority: 100 };
|
||||
@@ -35,9 +36,16 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
this._load();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
@@ -115,7 +123,7 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -196,23 +204,23 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
<tr class="llm-row">
|
||||
<td>
|
||||
${isPlugin
|
||||
? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">Plugin</span>`
|
||||
: html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">Cloud</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">${t('models.source_cloud')}</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="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">
|
||||
${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>
|
||||
</span>
|
||||
` : 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>
|
||||
</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>
|
||||
</button>
|
||||
`}
|
||||
@@ -228,7 +236,7 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<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>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${tProviders.map(p => html`
|
||||
@@ -239,7 +247,7 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -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 llm-modal">
|
||||
<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>
|
||||
</div>
|
||||
${this._loadingModels ? html`
|
||||
<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>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
@@ -272,9 +280,9 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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'; }}>
|
||||
Enter model ID manually
|
||||
${t('models.enter_id')}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
@@ -287,8 +295,8 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`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.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.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`
|
||||
<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">
|
||||
<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>
|
||||
<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}
|
||||
@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 class="mb-3">
|
||||
<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>
|
||||
<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 }} />
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<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>
|
||||
<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 }} />
|
||||
</div>
|
||||
<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=${(e) => this._form = { ...this._form, priority: e.target.value }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -356,14 +364,14 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
<h2 class="llm-page-title">Transcription Models</h2>
|
||||
<h2 class="llm-page-title">${t('models.transcribe.title')}</h2>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -371,7 +379,7 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
<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-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>
|
||||
` : ''}
|
||||
@@ -382,20 +390,20 @@ export class ModelsTranscribeSection extends LightElement {
|
||||
|
||||
${this._models.length === 0 ? html`
|
||||
<p class="text-muted" style="font-size:0.9rem">
|
||||
No transcription models configured.
|
||||
${canAdd ? html`Click <strong>Add</strong> to add a cloud model.` : ''}
|
||||
Activate the <strong>Whisper Local</strong> plugin for on-device transcription.
|
||||
${t('models.list_empty_transcribe')}
|
||||
${canAdd ? html` ${t('models.list_empty_add_hint')}` : ''}
|
||||
${t('models.list_empty_whisper')}
|
||||
</p>
|
||||
` : html`
|
||||
<div class="table-responsive">
|
||||
<table class="table llm-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5rem">Source</th>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>Model ID</th>
|
||||
<th>Language</th>
|
||||
<th style="width:5rem">${t('models.source')}</th>
|
||||
<th>${t('models.name_col')}</th>
|
||||
<th>${t('models.provider_col')}</th>
|
||||
<th>${t('models.model_id_col')}</th>
|
||||
<th>${t('models.language_col')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
// Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint.
|
||||
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
|
||||
@@ -38,9 +40,16 @@ export class ModelsTtsSection extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
this._load();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
@@ -123,7 +132,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' });
|
||||
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-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
|
||||
? html`<span class="ig-source-badge ig-source-plugin">${t('models.source_plugin')}</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">${t('models.source_cloud')}</span>`}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
<div class="llm-card-actions">
|
||||
${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>
|
||||
</span>
|
||||
` : 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>
|
||||
</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>
|
||||
</button>
|
||||
`}
|
||||
@@ -224,9 +233,9 @@ export class ModelsTtsSection extends LightElement {
|
||||
<div class="llm-card-row2">
|
||||
${!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>
|
||||
${m.voice_id ? html`<span class="llm-model-id" style="opacity:0.6" title="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>` : ''}
|
||||
${!isPlugin ? html`<span class="ig-priority-tag" title="Priority">#${m.priority}</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=${t('models.label.response_fmt')}>${m.response_format}</span>` : ''}
|
||||
${!isPlugin ? html`<span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
@@ -249,7 +258,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<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>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${ttsProviders.map(p => html`
|
||||
@@ -260,7 +269,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -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 llm-modal">
|
||||
<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>
|
||||
</div>
|
||||
${this._loadingModels ? html`
|
||||
<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>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
@@ -289,7 +298,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
<div class="tts-model-pick-row1">
|
||||
<span class="tts-model-pick-name">${m.name}</span>
|
||||
${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>
|
||||
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
|
||||
@@ -300,9 +309,9 @@ export class ModelsTtsSection extends LightElement {
|
||||
`)}
|
||||
</div>
|
||||
<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'; }}>
|
||||
Enter model ID manually
|
||||
${t('models.enter_id')}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
@@ -317,8 +326,8 @@ export class ModelsTtsSection extends LightElement {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`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.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.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`
|
||||
<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">
|
||||
<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>
|
||||
<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}
|
||||
@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 class="mb-3">
|
||||
<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>
|
||||
<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 }} />
|
||||
<div class="form-text">
|
||||
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 class="form-text">${unsafeHTML(t('models.form.voice_hint'))}</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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 }} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
<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 }} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
<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>
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
<select class="form-select form-select-sm" .value=${f.response_format}
|
||||
@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`
|
||||
<option value=${fmt}>${fmt}</option>
|
||||
`)}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
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 class="form-text">${unsafeHTML(t('models.form.response_hint'))}</div>
|
||||
</div>
|
||||
|
||||
<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=${(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 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}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -424,17 +427,17 @@ export class ModelsTtsSection extends LightElement {
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">Text-to-Speech Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
<h2 class="llm-page-title">${t('models.tts.title')}</h2>
|
||||
<span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -442,7 +445,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
<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-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>
|
||||
` : ''}
|
||||
@@ -451,7 +454,7 @@ export class ModelsTtsSection extends LightElement {
|
||||
<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-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>
|
||||
` : ''}
|
||||
@@ -464,10 +467,10 @@ export class ModelsTtsSection extends LightElement {
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-volume-up"></i>
|
||||
<p>No TTS models configured.</p>
|
||||
<p>${t('models.list_empty_tts')}</p>
|
||||
${canAdd ? html`
|
||||
<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>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t, I18nMixin, LOCALES, setLocale, getLocale } from '../lib/i18n.js';
|
||||
|
||||
export class ProfilePage extends LightElement {
|
||||
export class ProfilePage extends I18nMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -10,6 +11,8 @@ export class ProfilePage extends LightElement {
|
||||
_displayName: { state: true },
|
||||
_savingName: { state: true },
|
||||
_nameMsg: { state: true },
|
||||
_locale: { state: true },
|
||||
_localeMsg: { state: true },
|
||||
_pwCurrent: { state: true },
|
||||
_pwNew: { state: true },
|
||||
_pwConfirm: { state: true },
|
||||
@@ -25,6 +28,8 @@ export class ProfilePage extends LightElement {
|
||||
this._displayName = '';
|
||||
this._savingName = false;
|
||||
this._nameMsg = null;
|
||||
this._locale = '';
|
||||
this._localeMsg = null;
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
@@ -47,6 +52,7 @@ export class ProfilePage extends LightElement {
|
||||
if (res.ok) {
|
||||
this._me = await res.json();
|
||||
this._displayName = this._me.display_name ?? '';
|
||||
this._locale = this._me.locale ?? '';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -62,7 +68,7 @@ export class ProfilePage extends LightElement {
|
||||
body: JSON.stringify({ display_name: this._displayName.trim() || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._nameMsg = { type: 'ok', text: 'Saved.' };
|
||||
this._nameMsg = { type: 'ok', text: t('profile.saved') };
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._nameMsg = { type: 'err', text: e.message };
|
||||
@@ -71,15 +77,34 @@ export class ProfilePage extends LightElement {
|
||||
}
|
||||
}
|
||||
|
||||
// '' → back to the instance default; otherwise a concrete locale id.
|
||||
async _changeLocale(value) {
|
||||
this._localeMsg = null;
|
||||
try {
|
||||
const res = await fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale: value === '' ? null : value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._locale = value;
|
||||
// Apply immediately: the explicit choice, or the instance default when reset.
|
||||
setLocale(value === '' ? (this._me?.default_locale || 'en') : value);
|
||||
this._localeMsg = { type: 'ok', text: t('profile.saved') };
|
||||
} catch (e) {
|
||||
this._localeMsg = { type: 'err', text: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async _savePassword() {
|
||||
if (this._savingPw) return;
|
||||
this._pwMsg = null;
|
||||
if (this._pwNew.length < 4) {
|
||||
this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' };
|
||||
this._pwMsg = { type: 'err', text: t('profile.pw.short') };
|
||||
return;
|
||||
}
|
||||
if (this._pwNew !== this._pwConfirm) {
|
||||
this._pwMsg = { type: 'err', text: 'Passwords do not match.' };
|
||||
this._pwMsg = { type: 'err', text: t('profile.pw.mismatch') };
|
||||
return;
|
||||
}
|
||||
this._savingPw = true;
|
||||
@@ -93,7 +118,7 @@ export class ProfilePage extends LightElement {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._pwMsg = { type: 'ok', text: 'Password changed.' };
|
||||
this._pwMsg = { type: 'ok', text: t('profile.pw.changed') };
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
@@ -107,71 +132,89 @@ export class ProfilePage extends LightElement {
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const me = this._me;
|
||||
const defaultLabel = LOCALES.find(l => l.id === me?.default_locale)?.label ?? me?.default_locale ?? 'English';
|
||||
|
||||
return html`
|
||||
<div class="um-page" style="display:flex">
|
||||
<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 style="padding:0 24px 48px;max-width:480px">
|
||||
|
||||
${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">
|
||||
<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">
|
||||
<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 />
|
||||
</div>
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : 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">
|
||||
<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">
|
||||
<input class="form-control" placeholder="Your name"
|
||||
<input class="form-control" placeholder=${t('profile.name.ph')}
|
||||
.value=${this._displayName}
|
||||
@input=${e => this._displayName = e.target.value} />
|
||||
</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}
|
||||
<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>
|
||||
</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">
|
||||
<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`
|
||||
<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"
|
||||
.value=${this._pwCurrent}
|
||||
@input=${e => this._pwCurrent = e.target.value} />
|
||||
</div>
|
||||
` : nothing}
|
||||
<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"
|
||||
.value=${this._pwNew}
|
||||
@input=${e => this._pwNew = e.target.value} />
|
||||
</div>
|
||||
<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"
|
||||
.value=${this._pwConfirm}
|
||||
@input=${e => this._pwConfirm = e.target.value} />
|
||||
</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}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectBoardSection extends LightElement {
|
||||
@@ -35,7 +36,14 @@ export class ProjectBoardSection extends LightElement {
|
||||
this._activeTab = 'tickets';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
@@ -166,7 +174,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
}
|
||||
|
||||
async _deleteTicket(ticket) {
|
||||
if (!confirm(`Delete ticket "${ticket.title}"?`)) return;
|
||||
if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
|
||||
@@ -272,7 +280,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
${ticket.status === 'todo' ? html`
|
||||
<button class="btn btn-sm btn-outline-primary ticket-card-btn"
|
||||
@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 class="btn btn-sm btn-outline-danger ticket-card-btn"
|
||||
@click=${() => this._deleteTicket(ticket)}>
|
||||
@@ -281,7 +289,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
` : nothing}
|
||||
|
||||
${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`
|
||||
<a href="#session/${ticket.session_id}" class="ticket-card-session-link">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
@@ -292,12 +300,12 @@ export class ProjectBoardSection extends LightElement {
|
||||
${isCompleted ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary ticket-card-btn"
|
||||
@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 class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
|
||||
@click=${() => this._toggleExpand(ticket.id)}>
|
||||
<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>
|
||||
${ticket.session_id != null ? html`
|
||||
<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'}">
|
||||
${isDone
|
||||
? 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>`
|
||||
: 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>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -341,7 +349,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
<button
|
||||
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
|
||||
@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>
|
||||
</div>
|
||||
`;
|
||||
@@ -351,9 +359,9 @@ export class ProjectBoardSection extends LightElement {
|
||||
const { running, todo, completed } = this._groupTickets();
|
||||
return html`
|
||||
<div class="ticket-list">
|
||||
${this._renderSection('Running', 'activity', 'ticket-section-header--running', running, 'No tickets running')}
|
||||
${this._renderSection('Todo', 'circle', '', todo, 'No tickets to do')}
|
||||
${this._renderSection('Completed', 'check-circle', 'ticket-section-header--completed', completed, 'No completed tickets')}
|
||||
${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))}
|
||||
${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))}
|
||||
${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -364,7 +372,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
<div class="agent-dialog agent-dialog--ticket">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||
<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"
|
||||
@click=${() => this._modal = null}>
|
||||
<i class="bi bi-x"></i>
|
||||
@@ -377,21 +385,21 @@ export class ProjectBoardSection extends LightElement {
|
||||
|
||||
<form @submit=${e => this._createTicket(e)}>
|
||||
<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
|
||||
placeholder="What needs to be done"
|
||||
placeholder=${t('project_board.modal.title_ph')}
|
||||
.value=${this._form.title}
|
||||
@input=${e => this._form = { ...this._form, title: e.target.value }} />
|
||||
</div>
|
||||
<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"
|
||||
placeholder="Detailed instructions for the agent…"
|
||||
placeholder=${t('project_board.modal.desc_ph')}
|
||||
.value=${this._form.description}
|
||||
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
|
||||
</div>
|
||||
<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"
|
||||
.value=${this._form.agent_id}
|
||||
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
|
||||
@@ -401,11 +409,11 @@ export class ProjectBoardSection extends LightElement {
|
||||
</select>
|
||||
</div>
|
||||
<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"
|
||||
.value=${this._form.security_group}
|
||||
@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`
|
||||
<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 style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||
<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}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>Create`}
|
||||
? 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>${t('project_board.modal.create')}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -440,7 +448,7 @@ export class ProjectBoardSection extends LightElement {
|
||||
<div class="project-page-header">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<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>
|
||||
<h2 class="project-page-title">
|
||||
<i class="bi bi-folder2"></i>${this._project.name}
|
||||
@@ -448,11 +456,11 @@ export class ProjectBoardSection extends LightElement {
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<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 class="btn btn-sm btn-primary"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectListSection extends LightElement {
|
||||
@@ -20,6 +21,17 @@ export class ProjectListSection extends LightElement {
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
_emptyForm() {
|
||||
return { name: '', path: '', description: '' };
|
||||
}
|
||||
@@ -76,7 +88,7 @@ export class ProjectListSection extends LightElement {
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -104,7 +116,7 @@ export class ProjectListSection extends LightElement {
|
||||
<div class="agent-dialog">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||
<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"
|
||||
@click=${() => this._closeModal()}>
|
||||
<i class="bi bi-x"></i>
|
||||
@@ -117,33 +129,33 @@ export class ProjectListSection extends LightElement {
|
||||
|
||||
<form @submit=${e => this._submit(e)}>
|
||||
<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
|
||||
placeholder="My Project"
|
||||
placeholder=${t('projects.modal.name_ph')}
|
||||
.value=${this._form.name}
|
||||
@input=${e => this._setField('name', e.target.value)} />
|
||||
</div>
|
||||
<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
|
||||
placeholder="/path/to/project"
|
||||
placeholder=${t('projects.modal.path_ph')}
|
||||
.value=${this._form.path}
|
||||
@input=${e => this._setField('path', e.target.value)} />
|
||||
</div>
|
||||
<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"
|
||||
placeholder="What this project is about"
|
||||
placeholder=${t('projects.modal.desc_ph')}
|
||||
.value=${this._form.description}
|
||||
@input=${e => this._setField('description', e.target.value)}></textarea>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||
<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}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save' : 'Create'}`}
|
||||
? 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 ? t('projects.modal.save') : t('projects.modal.create')}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -158,11 +170,11 @@ export class ProjectListSection extends LightElement {
|
||||
<div class="project-card-header">
|
||||
<div class="project-card-title">${project.name}</div>
|
||||
<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)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@@ -172,7 +184,7 @@ export class ProjectListSection extends LightElement {
|
||||
${project.description
|
||||
? html`<div class="project-card-desc">${project.description}</div>`
|
||||
: 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>
|
||||
`;
|
||||
}
|
||||
@@ -181,9 +193,9 @@ export class ProjectListSection extends LightElement {
|
||||
return html`
|
||||
<div class="project-page">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +206,7 @@ export class ProjectListSection extends LightElement {
|
||||
${this._projects.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-kanban"></i>
|
||||
<p>No projects yet. Create one to get started.</p>
|
||||
<p>${t('projects.empty')}</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="project-grid">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
|
||||
@@ -26,6 +28,8 @@ export class RolesPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'roles';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -33,6 +37,11 @@ export class RolesPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -40,8 +49,8 @@ export class RolesPage extends LightElement {
|
||||
fetch('/api/roles'),
|
||||
fetch('/api/tool-permission-groups'),
|
||||
]);
|
||||
if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`);
|
||||
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
|
||||
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
|
||||
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
|
||||
this._roles = await rRes.json();
|
||||
this._groups = await gRes.json();
|
||||
} catch (e) {
|
||||
@@ -51,10 +60,25 @@ export class RolesPage extends LightElement {
|
||||
|
||||
// ── Modal helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI
|
||||
// surfaces it as a first-class select without hardcoding any role semantics.
|
||||
_attrsUiMode(attrs) {
|
||||
try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; }
|
||||
catch { return 'full'; }
|
||||
}
|
||||
|
||||
_mergeAttrs(attrs, uiMode) {
|
||||
let o = {};
|
||||
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
|
||||
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
|
||||
const keys = Object.keys(o);
|
||||
return keys.length ? JSON.stringify(o) : null;
|
||||
}
|
||||
|
||||
_openCreate() {
|
||||
this._modal = {
|
||||
mode: 'create',
|
||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' },
|
||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +86,7 @@ export class RolesPage extends LightElement {
|
||||
this._modal = {
|
||||
mode: 'edit',
|
||||
role,
|
||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' },
|
||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +103,7 @@ export class RolesPage extends LightElement {
|
||||
this._error = null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; }
|
||||
if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/roles', {
|
||||
method: 'POST',
|
||||
@@ -88,7 +112,7 @@ export class RolesPage extends LightElement {
|
||||
id: form.id.trim(),
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: form.attrs.trim() || null,
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -97,7 +121,7 @@ export class RolesPage extends LightElement {
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else {
|
||||
const { role } = this._modal;
|
||||
if (!form.label.trim()) { this._error = 'Label is required.'; return; }
|
||||
if (!form.label.trim()) { this._error = t('roles.error.label'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${role.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -105,7 +129,7 @@ export class RolesPage extends LightElement {
|
||||
body: JSON.stringify({
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: form.attrs.trim() || null,
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -116,7 +140,7 @@ export class RolesPage extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(role) {
|
||||
if (!confirm(`Delete role "${role.label}"?`)) return;
|
||||
if (!confirm(t('roles.confirm.delete', { name: role.label }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -133,7 +157,7 @@ export class RolesPage extends LightElement {
|
||||
_renderModal() {
|
||||
if (!this._modal) return nothing;
|
||||
const { mode, form, role } = this._modal;
|
||||
const title = mode === 'create' ? 'New role' : `Edit ${role.label}`;
|
||||
const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label });
|
||||
|
||||
return html`
|
||||
<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`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ID <span class="text-muted">(slug)</span></label>
|
||||
<input class="form-control font-monospace" placeholder="e.g. editor" .value=${form.id}
|
||||
<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=${t('roles.form.id_ph')} .value=${form.id}
|
||||
@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>
|
||||
` : nothing}
|
||||
|
||||
<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)} />
|
||||
</div>
|
||||
<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)}>
|
||||
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Attrs <span class="text-muted">(JSON, optional)</span></label>
|
||||
<input class="form-control font-monospace" placeholder="{}" .value=${form.attrs}
|
||||
<label class="form-label">${t('roles.form.interface')}</label>
|
||||
<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)} />
|
||||
</div>
|
||||
</div>
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,11 +222,11 @@ export class RolesPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<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">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,15 +236,16 @@ export class RolesPage extends LightElement {
|
||||
` : nothing}
|
||||
|
||||
<div class="um-table-wrap">
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : roles.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div>
|
||||
${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>${t('roles.empty')}</p></div>
|
||||
` : html`
|
||||
<table class="um-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Label</th>
|
||||
<th>Permission group</th>
|
||||
<th>${t('roles.col.id')}</th>
|
||||
<th>${t('roles.col.label')}</th>
|
||||
<th>${t('roles.col.group')}</th>
|
||||
<th>${t('roles.col.interface')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -224,14 +257,17 @@ export class RolesPage extends LightElement {
|
||||
<td><code>${r.id}</code></td>
|
||||
<td><strong>${r.label}</strong></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>
|
||||
<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}
|
||||
@click=${() => !isAdmin && this._openEdit(r)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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}
|
||||
@click=${() => !isAdmin && this._delete(r)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const PAGE_ID = 'session';
|
||||
|
||||
@@ -57,6 +59,8 @@ export class SessionDetailPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -68,6 +72,12 @@ export class SessionDetailPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
this._closeWs();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._closeWs();
|
||||
@@ -250,15 +260,15 @@ export class SessionDetailPage extends LightElement {
|
||||
<div class="sd-session-header">
|
||||
<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()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
<i class="bi bi-arrow-left"></i> ${t('session.back')}
|
||||
</button>
|
||||
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
|
||||
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span>
|
||||
<span class="text-secondary small">id: ${session.id}</span>
|
||||
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing}
|
||||
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing}
|
||||
<span class="fw-semibold font-monospace">${t('session.agent')} ${session.agent_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">${t('session.ephemeral')}</span>` : nothing}
|
||||
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">${t('session.automated')}</span>` : nothing}
|
||||
${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}
|
||||
</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-role">
|
||||
${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}
|
||||
<span>User</span>
|
||||
<span>${t('session.user_role')}</span>
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
@@ -291,14 +301,14 @@ export class SessionDetailPage extends LightElement {
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
Assistant
|
||||
${t('session.assistant_role')}
|
||||
${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}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
${t('session.reasoning_label')}
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
@@ -316,14 +326,14 @@ export class SessionDetailPage extends LightElement {
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<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}
|
||||
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
${t('session.reasoning_label')}
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${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
|
||||
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
|
||||
: 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>
|
||||
<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>
|
||||
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
|
||||
item.result ?? item.error ?? '—'
|
||||
@@ -369,14 +379,14 @@ export class SessionDetailPage extends LightElement {
|
||||
return html`
|
||||
<div class="sd-agent-frame-start">
|
||||
<i class="bi bi-robot me-1"></i>
|
||||
<span>Sub-agent: <strong>${item.agent_id}</strong></span>
|
||||
<span class="text-secondary small ms-2">depth ${item.depth}</span>
|
||||
<span>${t('session.sub_agent')} <strong>${item.agent_id}</strong></span>
|
||||
<span class="text-secondary small ms-2">${t('session.depth', { n: item.depth })}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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) {
|
||||
@@ -576,18 +586,18 @@ export class SessionDetailPage extends LightElement {
|
||||
<div class="sd-container">
|
||||
${this._loading ? html`
|
||||
<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>
|
||||
` : this._error ? html`
|
||||
<div class="alert alert-danger">${this._error}</div>
|
||||
` : !this._data ? html`
|
||||
<div class="text-secondary text-center py-5">No session loaded.<br>
|
||||
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span>
|
||||
<div class="text-secondary text-center py-5">${t('session.no_session')}<br>
|
||||
<span class="small">${unsafeHTML(t('session.no_session_hint'))}</span>
|
||||
</div>
|
||||
` : html`
|
||||
${this._renderSessionHeader(this._data.session)}
|
||||
${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))
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t, I18nMixin, LOCALES, getLocale, setLocale } from '../lib/i18n.js';
|
||||
|
||||
export class SetupPage extends LightElement {
|
||||
export class SetupPage extends I18nMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -9,6 +10,7 @@ export class SetupPage extends LightElement {
|
||||
_password: { state: true },
|
||||
_confirm: { state: true },
|
||||
_encrypted: { state: true },
|
||||
_locale: { state: true },
|
||||
_error: { state: true },
|
||||
_busy: { state: true },
|
||||
};
|
||||
@@ -20,6 +22,7 @@ export class SetupPage extends LightElement {
|
||||
this._password = '';
|
||||
this._confirm = '';
|
||||
this._encrypted = true;
|
||||
this._locale = getLocale();
|
||||
this._error = null;
|
||||
this._busy = false;
|
||||
}
|
||||
@@ -31,15 +34,15 @@ export class SetupPage extends LightElement {
|
||||
this._error = null;
|
||||
|
||||
if (!this._username.trim()) {
|
||||
this._error = 'Choose a username.';
|
||||
this._error = t('setup.username');
|
||||
return;
|
||||
}
|
||||
if (this._password.length < 4) {
|
||||
this._error = 'Password must be at least 4 characters.';
|
||||
this._error = t('setup.pw.short');
|
||||
return;
|
||||
}
|
||||
if (this._password !== this._confirm) {
|
||||
this._error = 'The two passwords do not match.';
|
||||
this._error = t('setup.pw.mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ export class SetupPage extends LightElement {
|
||||
username: this._username.trim(),
|
||||
password: this._password,
|
||||
encrypted: this._encrypted,
|
||||
locale: this._locale,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -66,7 +70,7 @@ export class SetupPage extends LightElement {
|
||||
// First user created — reload into the app.
|
||||
window.location.reload();
|
||||
} catch {
|
||||
this._error = 'Network error — please try again.';
|
||||
this._error = t('setup.network');
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
@@ -74,8 +78,8 @@ export class SetupPage extends LightElement {
|
||||
|
||||
render() {
|
||||
const btnLabel = this._busy
|
||||
? html`<span class="setup-spinner"></span>Creating…`
|
||||
: 'Create account';
|
||||
? html`<span class="setup-spinner"></span>${t('setup.creating')}`
|
||||
: t('setup.submit');
|
||||
|
||||
return html`
|
||||
<div class="setup-page">
|
||||
@@ -83,13 +87,13 @@ export class SetupPage extends LightElement {
|
||||
<div class="setup-logo">
|
||||
<img src="/assets/icons/icon-192.png" alt="Skald" />
|
||||
</div>
|
||||
<h1 class="setup-title">Welcome to Skald</h1>
|
||||
<p class="setup-subtitle">Create the admin account to get started.</p>
|
||||
<h1 class="setup-title">${t('setup.title')}</h1>
|
||||
<p class="setup-subtitle">${t('setup.subtitle')}</p>
|
||||
|
||||
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<label class="form-label">${t('login.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
@@ -100,7 +104,7 @@ export class SetupPage extends LightElement {
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<label class="form-label">${t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
@@ -111,7 +115,7 @@ export class SetupPage extends LightElement {
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<label class="form-label">${t('setup.confirm')}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
@@ -121,6 +125,19 @@ export class SetupPage extends LightElement {
|
||||
required />
|
||||
</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">
|
||||
<input
|
||||
class="form-check-input"
|
||||
@@ -130,15 +147,13 @@ export class SetupPage extends LightElement {
|
||||
@change=${e => this._encrypted = e.target.checked}
|
||||
?disabled=${this._busy} />
|
||||
<label class="form-check-label" for="encrypt-chk">
|
||||
Encrypt my conversation history
|
||||
${t('setup.encrypt')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${this._encrypted ? html`
|
||||
<div class="setup-warn">
|
||||
<strong>Warning:</strong> your password derives the encryption key.
|
||||
If you forget it, <strong>your entire conversation history will be
|
||||
permanently lost</strong> — there is no recovery.
|
||||
<strong>${t('setup.warn.strong')}</strong> ${t('setup.warn')}
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../../lib/chat-session.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
|
||||
|
||||
export class ChatPage extends ChatSession {
|
||||
@@ -105,17 +106,17 @@ export class ChatPage extends ChatSession {
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
${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()}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'}
|
||||
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`}
|
||||
<i class="bi bi-folder2-open"></i> ${this.label || t('chat.mobile.project')}
|
||||
` : html`<i class="bi bi-chat-dots-fill"></i> ${t('chat.mobile.chat')}`}
|
||||
</span>
|
||||
<div class="chat-page-header-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="New conversation"
|
||||
title=${t('chat.new_session')}
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
@@ -125,14 +126,14 @@ export class ChatPage extends ChatSession {
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="chat-page-empty">
|
||||
<i class="bi bi-stars"></i>
|
||||
<p>Ask me anything</p>
|
||||
<p>${t('chat.mobile.ask')}</p>
|
||||
</div>
|
||||
` : this._messages.map(m => renderMsg(this, m))}
|
||||
|
||||
${this._waiting ? html`
|
||||
<div class="copilot-msg assistant copilot-thinking">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Thinking…
|
||||
${t('chat.thinking')}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -152,7 +153,7 @@ export class ChatPage extends ChatSession {
|
||||
<textarea
|
||||
class="chat-page-textarea"
|
||||
rows="1"
|
||||
placeholder="Type a message…"
|
||||
placeholder=${t('chat.mobile.placeholder')}
|
||||
@input=${(e) => this._autoResize(e.target)}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
@@ -160,7 +161,7 @@ export class ChatPage extends ChatSession {
|
||||
<div class="chat-page-toolbar-left">
|
||||
<button
|
||||
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()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
@@ -179,7 +180,7 @@ export class ChatPage extends ChatSession {
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
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()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
@@ -189,13 +190,13 @@ export class ChatPage extends ChatSession {
|
||||
? html`<button
|
||||
class="chat-page-send chat-page-send--stop"
|
||||
@click=${() => this._cancel()}
|
||||
title="Stop"
|
||||
title=${t('chat.stop')}
|
||||
><i class="bi bi-stop-fill"></i></button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="chat-page-send"
|
||||
@click=${() => this._send()}
|
||||
title="Send"
|
||||
title=${t('chat.send')}
|
||||
><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../../lib/i18n.js';
|
||||
|
||||
// Shared vocabulary for the Connectors list and a connector's own page.
|
||||
//
|
||||
// Both surfaces have to answer "what state is this connector in?" and both draw the
|
||||
@@ -13,14 +15,25 @@ export function connectorIconUrl(name, size = 'sm') {
|
||||
/// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents
|
||||
/// in `web/css/connectors.css`.
|
||||
export const STATUS_LABEL = {
|
||||
active: { text: 'active', tone: 'ok' },
|
||||
pending: { text: 'needs fix', tone: 'script' },
|
||||
needs_login: { text: 'needs sign-in', tone: 'script' },
|
||||
enabled: { text: 'enabled', tone: 'scope' },
|
||||
off: { text: 'off', tone: '' },
|
||||
available: { text: 'available', tone: '' },
|
||||
active: { tone: 'ok' },
|
||||
pending: { tone: 'script' },
|
||||
needs_login: { tone: 'script' },
|
||||
enabled: { tone: 'scope' },
|
||||
off: { tone: '' },
|
||||
available: { tone: '' },
|
||||
};
|
||||
|
||||
export function statusText(status) {
|
||||
return {
|
||||
active: t('connectors.status.active'),
|
||||
pending: t('connectors.status.needs_fix'),
|
||||
needs_login: t('connectors.status.needs_signin'),
|
||||
enabled: t('connectors.status.enabled'),
|
||||
off: t('connectors.status.off'),
|
||||
available: t('connectors.status.available'),
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
/// The one place that decides what a connector's state *is*, from whichever runtime
|
||||
/// rows exist for it.
|
||||
///
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { fileWatcher } from '../../lib/file-watcher.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
|
||||
/**
|
||||
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
|
||||
@@ -346,7 +347,7 @@ export class FileViewerBase extends LightElement {
|
||||
const showingSource = this._htmlMode === 'source';
|
||||
return html`<button
|
||||
class=${btnClass}
|
||||
title=${showingSource ? 'Show preview' : 'Show source'}
|
||||
title=${showingSource ? t('fv.mode_preview') : t('fv.mode_source')}
|
||||
@click=${() => this._toggleHtmlMode()}>
|
||||
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
|
||||
</button>`;
|
||||
@@ -389,7 +390,7 @@ export class FileViewerBase extends LightElement {
|
||||
if (this._kind === 'binary') {
|
||||
return html`<div class="fv-state text-muted">
|
||||
<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>`;
|
||||
}
|
||||
if (this._kind === 'html') {
|
||||
@@ -417,7 +418,7 @@ export class FileViewerBase extends LightElement {
|
||||
return html`
|
||||
${this._compileError
|
||||
? html`<details class="fv-compile-error">
|
||||
<summary><i class="bi bi-exclamation-triangle text-warning"></i> LaTeX compilation failed — showing source instead</summary>
|
||||
<summary><i class="bi bi-exclamation-triangle text-warning"></i> ${t('fv.latex_failed')}</summary>
|
||||
<pre>${this._compileError}</pre>
|
||||
</details>`
|
||||
: nothing}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { FileViewerBase } from './file-viewer-base.js';
|
||||
|
||||
/**
|
||||
@@ -43,14 +44,14 @@ export class MobileFileViewerPage extends FileViewerBase {
|
||||
<div class="mobile-file-viewer">
|
||||
<div class="mobile-section-header">
|
||||
<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>
|
||||
</button>
|
||||
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
|
||||
</span>
|
||||
<span class="fv-header-actions">
|
||||
${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>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t, I18nMixin } from '../lib/i18n.js';
|
||||
|
||||
|
||||
export class AppSidebar extends LightElement {
|
||||
export class AppSidebar extends I18nMixin(LightElement) {
|
||||
static properties = {
|
||||
_activePage: { state: true },
|
||||
_tasksSection: { state: true },
|
||||
@@ -120,7 +121,7 @@ export class AppSidebar extends LightElement {
|
||||
const match = hash.match(/^([^/?]+)/);
|
||||
const segment = match ? match[1] : '';
|
||||
// `connector` (singular) is the per-connector detail page, `connectors` the list.
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
@@ -183,7 +184,7 @@ export class AppSidebar extends LightElement {
|
||||
class="sidebar-link ${active ? 'active' : ''}"
|
||||
@click=${(e) => this._openTaskManager(e)}>
|
||||
<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>
|
||||
</a>
|
||||
${active ? html`
|
||||
@@ -191,22 +192,22 @@ export class AppSidebar extends LightElement {
|
||||
<a href="#tasks/running"
|
||||
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
|
||||
@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 href="#tasks/cron"
|
||||
class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}"
|
||||
@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 href="#tasks/scheduled"
|
||||
class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}"
|
||||
@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 href="#tasks/history"
|
||||
class="sidebar-sublink ${sec === 'history' ? 'active' : ''}"
|
||||
@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>
|
||||
</div>
|
||||
` : 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>
|
||||
<span class="sidebar-project-name">${p.name}</span>
|
||||
<button class="sidebar-project-chat-btn"
|
||||
title="Open chat"
|
||||
title=${t('topbar.open_chat')}
|
||||
@click=${(e) => this._openProjectChat(p.id, p.name, e)}>
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</button>
|
||||
@@ -234,10 +235,14 @@ export class AppSidebar extends LightElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
// Simplified interface (role attrs `ui_mode: "simple"`): chat + inbox only.
|
||||
// Hiding links is not access control — every route stays capability-gated
|
||||
// server-side; this only shapes the navigation for less technical members.
|
||||
const simple = this._me?.ui_mode === 'simple';
|
||||
return html`
|
||||
<div class="sidebar-brand">
|
||||
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
|
||||
<span>Skald</span>
|
||||
<span>${t('topbar.brand')}</span>
|
||||
</div>
|
||||
|
||||
<hr class="sidebar-divider" />
|
||||
@@ -245,26 +250,34 @@ export class AppSidebar extends LightElement {
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('home', e)}>
|
||||
<i class="bi bi-house-door"></i>
|
||||
<span class="sidebar-link-name">Home</span>
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<span class="sidebar-link-name">${t('nav.chat')}</span>
|
||||
</a>
|
||||
|
||||
<a href="#inbox" class="sidebar-link ${this._activePage === 'inbox' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('inbox', e)}>
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span class="sidebar-link-name">
|
||||
Inbox
|
||||
${t('nav.inbox')}
|
||||
${this._inboxCount > 0
|
||||
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
|
||||
: ''}
|
||||
</span>
|
||||
</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"
|
||||
class="sidebar-link ${this._activePage === 'projects' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('projects', e)}>
|
||||
<i class="bi bi-kanban"></i>
|
||||
<span class="sidebar-link-name">Projects</span>
|
||||
<span class="sidebar-link-name">${t('nav.projects')}</span>
|
||||
</a>
|
||||
${this._renderRecentProjects()}
|
||||
|
||||
@@ -273,48 +286,54 @@ export class AppSidebar extends LightElement {
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'models' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('models', e)}>
|
||||
<i class="bi bi-cpu"></i>
|
||||
<span class="sidebar-link-name">Models</span>
|
||||
<span class="sidebar-link-name">${t('nav.models')}</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'providers' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('providers', e)}>
|
||||
<i class="bi bi-plug"></i>
|
||||
<span class="sidebar-link-name">Providers</span>
|
||||
<span class="sidebar-link-name">${t('nav.providers')}</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'approval' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('approval', e)}>
|
||||
<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 href="#" class="sidebar-link ${this._activePage === 'agents' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('agents', e)}>
|
||||
<i class="bi bi-people"></i>
|
||||
<span class="sidebar-link-name">Agents</span>
|
||||
<span class="sidebar-link-name">${t('nav.agents')}</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('users', e)}>
|
||||
<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 href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('roles', e)}>
|
||||
<i class="bi bi-tags"></i>
|
||||
<span class="sidebar-link-name">Roles</span>
|
||||
<span class="sidebar-link-name">${t('nav.roles')}</span>
|
||||
</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' : ''}"
|
||||
@click=${(e) => this._togglePage('connectors', e)}>
|
||||
<i class="bi bi-plug"></i>
|
||||
<span class="sidebar-link-name">Connectors</span>
|
||||
<span class="sidebar-link-name">${t('nav.connectors')}</span>
|
||||
</a>
|
||||
${this._me?.role_id === 'admin' ? html`
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('catalog', e)}>
|
||||
<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 href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('config', e)}>
|
||||
<i class="bi bi-gear"></i>
|
||||
<span class="sidebar-link-name">Config</span>
|
||||
<span class="sidebar-link-name">${t('nav.config')}</span>
|
||||
</a>
|
||||
|
||||
${this._debugMode ? html`
|
||||
@@ -323,15 +342,16 @@ export class AppSidebar extends LightElement {
|
||||
class="sidebar-link ${this._activePage === 'llm-requests' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('llm-requests', e)}>
|
||||
<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 href="#tic"
|
||||
class="sidebar-link ${this._activePage === 'tic' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('tic', e)}>
|
||||
<i class="bi bi-bell"></i>
|
||||
<span class="sidebar-link-name">TIC Sessions</span>
|
||||
<span class="sidebar-link-name">${t('nav.tic')}</span>
|
||||
</a>
|
||||
` : nothing}
|
||||
`}
|
||||
</nav>
|
||||
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { toString as cronToString } from 'cronstrue';
|
||||
import { formatDate } from './utils.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
|
||||
export class CronJobsSection extends LightElement {
|
||||
static properties = {
|
||||
@@ -15,6 +17,17 @@ export class CronJobsSection extends LightElement {
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -28,7 +41,7 @@ export class CronJobsSection extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(job) {
|
||||
if (!confirm(`Delete job "${job.title}"?`)) return;
|
||||
if (!confirm(t('cron.confirm.delete', { title: job.title }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -50,10 +63,10 @@ export class CronJobsSection extends LightElement {
|
||||
|
||||
_statusBadge(job) {
|
||||
if (job.running_session_id != null)
|
||||
return html`<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)
|
||||
return html`<span class="task-badge task-badge--disabled">disabled</span>`;
|
||||
return html`<span class="task-badge task-badge--idle">idle</span>`;
|
||||
return html`<span class="task-badge task-badge--disabled">${t('cron.badge.disabled')}</span>`;
|
||||
return html`<span class="task-badge task-badge--idle">${t('cron.badge.idle')}</span>`;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
@@ -64,7 +77,7 @@ export class CronJobsSection extends LightElement {
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._statusBadge(job)}
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,15 +94,15 @@ export class CronJobsSection extends LightElement {
|
||||
|
||||
<div class="task-card-meta">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +112,7 @@ export class CronJobsSection extends LightElement {
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
.checked=${job.enabled}
|
||||
@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>
|
||||
@@ -110,9 +123,9 @@ export class CronJobsSection extends LightElement {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<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)">
|
||||
${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>
|
||||
|
||||
@@ -123,7 +136,7 @@ export class CronJobsSection extends LightElement {
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<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>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const PAGE_ID = 'tic';
|
||||
const PER_PAGE = 20;
|
||||
@@ -42,6 +43,8 @@ export class TicSessionsPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -49,6 +52,11 @@ export class TicSessionsPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
@@ -77,7 +85,7 @@ export class TicSessionsPage extends LightElement {
|
||||
if (this._loading) return html`
|
||||
<div class="tic-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
<span>${t('tic.loading')}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
@@ -89,7 +97,7 @@ export class TicSessionsPage extends LightElement {
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="tic-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No TIC sessions found.</span>
|
||||
<span>${t('tic.empty')}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -99,10 +107,10 @@ export class TicSessionsPage extends LightElement {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Agent</th>
|
||||
<th>Started</th>
|
||||
<th class="text-end">Messages</th>
|
||||
<th>Last activity</th>
|
||||
<th>${t('tic.table.agent')}</th>
|
||||
<th>${t('tic.table.started')}</th>
|
||||
<th class="text-end">${t('tic.table.messages')}</th>
|
||||
<th>${t('tic.table.last_activity')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -131,7 +139,7 @@ export class TicSessionsPage extends LightElement {
|
||||
@click=${() => this._fetch(cur - 1)}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="tic-page-info">Page ${cur} of ${pages} — ${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}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
@@ -231,12 +239,12 @@ export class TicSessionsPage extends LightElement {
|
||||
|
||||
<div class="tic-page">
|
||||
<div class="tic-header">
|
||||
<h2 class="tic-title"><i class="bi bi-bell"></i> TIC Sessions</h2>
|
||||
<span class="tic-total-badge">${this._total} total</span>
|
||||
<h2 class="tic-title"><i class="bi bi-bell"></i> ${t('tic.title')}</h2>
|
||||
<span class="tic-total-badge">${t('tic.total', { n: this._total })}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary tic-refresh-btn"
|
||||
?disabled=${this._loading}
|
||||
@click=${() => this._fetch(this._page)}>
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
<i class="bi bi-arrow-clockwise"></i> ${t('tic.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
${this._renderTable()}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t, I18nMixin } from '../lib/i18n.js';
|
||||
|
||||
export class AppTopbar extends LightElement {
|
||||
// Stable per-user avatar color: same user, same hue, everywhere.
|
||||
function avatarColor(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return `hsl(${h % 360}, 55%, 52%)`;
|
||||
}
|
||||
|
||||
export class AppTopbar extends I18nMixin(LightElement) {
|
||||
static properties = {
|
||||
_theme: { state: true },
|
||||
_copilotCollapsed: { state: true },
|
||||
@@ -65,23 +73,28 @@ export class AppTopbar extends LightElement {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
get _avatarColor() {
|
||||
const name = this._me?.username || '';
|
||||
return name ? avatarColor(name) : 'var(--accent)';
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDark = this._theme === 'dark';
|
||||
return html`
|
||||
<span class="topbar-title">Skald</span>
|
||||
<span class="topbar-title">${t('topbar.brand')}</span>
|
||||
<span class="topbar-spacer"></span>
|
||||
${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'))}>
|
||||
<i class="bi bi-stars"></i>
|
||||
</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()}>
|
||||
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
|
||||
</button>
|
||||
<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}
|
||||
</button>
|
||||
${this._menuOpen ? html`
|
||||
@@ -91,10 +104,10 @@ export class AppTopbar extends LightElement {
|
||||
<div class="topbar-dropdown-sub">@${this._me?.username || ''}</div>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
export class UsersPage extends LightElement {
|
||||
|
||||
@@ -24,6 +26,8 @@ export class UsersPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'users';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -31,6 +35,11 @@ export class UsersPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -81,7 +90,7 @@ export class UsersPage extends LightElement {
|
||||
this._error = null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; return; }
|
||||
if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
@@ -100,7 +109,7 @@ export class UsersPage extends LightElement {
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else if (mode === 'edit') {
|
||||
const { user } = this._modal;
|
||||
if (!form.username.trim()) { this._error = 'Username is required.'; return; }
|
||||
if (!form.username.trim()) { this._error = t('users.error.required_username'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -118,7 +127,7 @@ export class UsersPage extends LightElement {
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else if (mode === 'password') {
|
||||
const { user } = this._modal;
|
||||
if (!form.password) { this._error = 'Password must not be empty.'; return; }
|
||||
if (!form.password) { this._error = t('users.error.password_empty'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}/password`, {
|
||||
method: 'POST',
|
||||
@@ -132,7 +141,7 @@ export class UsersPage extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(user) {
|
||||
if (!confirm(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return;
|
||||
if (!confirm(t('users.confirm.delete', { username: user.username }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -149,9 +158,9 @@ export class UsersPage extends LightElement {
|
||||
_renderModal() {
|
||||
if (!this._modal) return nothing;
|
||||
const { mode, form, user } = this._modal;
|
||||
const title = mode === 'create' ? 'New user'
|
||||
: mode === 'edit' ? `Edit ${user.username}`
|
||||
: `Reset password — ${user.username}`;
|
||||
const title = mode === 'create' ? t('users.modal.create_title')
|
||||
: mode === 'edit' ? t('users.modal.edit_title', { username: user.username })
|
||||
: t('users.modal.reset_title', { username: user.username });
|
||||
|
||||
return html`
|
||||
<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`
|
||||
<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)} />
|
||||
</div>
|
||||
<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)} />
|
||||
</div>
|
||||
<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)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<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)} />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="um-enc"
|
||||
.checked=${form.encrypted}
|
||||
@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>
|
||||
${form.encrypted ? html`
|
||||
<div class="setup-warn mt-2">
|
||||
<strong>Warning:</strong> if the password is lost, the conversation history is permanently unrecoverable.
|
||||
</div>
|
||||
<div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
|
||||
` : nothing}
|
||||
` : mode === 'edit' ? html`
|
||||
<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)} />
|
||||
</div>
|
||||
<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)} />
|
||||
</div>
|
||||
<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)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
@@ -213,23 +220,22 @@ export class UsersPage extends LightElement {
|
||||
<input class="form-check-input" type="checkbox" id="um-active"
|
||||
.checked=${form.active}
|
||||
@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>
|
||||
` : html`
|
||||
<div class="alert alert-warning py-2 mb-3" style="font-size:.82rem">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Only works for cleartext (non-encrypted) users.
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
|
||||
</div>
|
||||
<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)} />
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,11 +251,11 @@ export class UsersPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<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">
|
||||
<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()}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,17 +265,17 @@ export class UsersPage extends LightElement {
|
||||
` : nothing}
|
||||
|
||||
<div class="um-table-wrap">
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : users.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</p></div>
|
||||
${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>${t('users.empty')}</p></div>
|
||||
` : html`
|
||||
<table class="um-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display name</th>
|
||||
<th>Role</th>
|
||||
<th>DB</th>
|
||||
<th>Status</th>
|
||||
<th>${t('users.table.username')}</th>
|
||||
<th>${t('users.table.display_name')}</th>
|
||||
<th>${t('users.table.role')}</th>
|
||||
<th>${t('users.table.db')}</th>
|
||||
<th>${t('users.table.status')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -280,20 +286,20 @@ export class UsersPage extends LightElement {
|
||||
<td>${u.display_name ?? '—'}</td>
|
||||
<td>${this._roleLabel(u.role_id)}</td>
|
||||
<td>${u.encrypted
|
||||
? html`<span class="um-badge um-badge-encrypted">Encrypted</span>`
|
||||
: html`<span class="um-badge um-badge-clear">Cleartext</span>`}</td>
|
||||
? html`<span class="um-badge um-badge-encrypted">${t('users.badge.encrypted')}</span>`
|
||||
: html`<span class="um-badge um-badge-clear">${t('users.badge.cleartext')}</span>`}</td>
|
||||
<td>${u.active
|
||||
? html`<span class="um-badge um-badge-active">Active</span>`
|
||||
: html`<span class="um-badge um-badge-inactive">Inactive</span>`}</td>
|
||||
? html`<span class="um-badge um-badge-active">${t('users.badge.active')}</span>`
|
||||
: html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}</td>
|
||||
<td>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
}
|
||||
|
||||
.copilot-composer:focus-within {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.copilot-textarea {
|
||||
@@ -26,9 +26,9 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
padding: 0.6rem 0.75rem 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.65rem 0.85rem 0.45rem;
|
||||
min-height: 2.6rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
@@ -59,11 +59,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--placeholder-color);
|
||||
font-size: 0.85rem;
|
||||
@@ -99,14 +99,14 @@
|
||||
|
||||
.copilot-model-pill:hover,
|
||||
.copilot-model-pill.open {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: #6366f1;
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copilot-model-pill i:first-child {
|
||||
font-size: 0.75rem;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copilot-model-overlay {
|
||||
@@ -141,8 +141,8 @@
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.copilot-model-item:hover { background: rgba(99, 102, 241, 0.07); }
|
||||
.copilot-model-item.active { color: #6366f1; font-weight: 600; }
|
||||
.copilot-model-item:hover { background: rgba(var(--accent-rgb), 0.07); }
|
||||
.copilot-model-item.active { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* ── Slash-command autocomplete ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -177,9 +177,9 @@
|
||||
}
|
||||
|
||||
.copilot-cmd-item:hover,
|
||||
.copilot-cmd-item.active { background: rgba(99, 102, 241, 0.1); }
|
||||
.copilot-cmd-item.active { background: rgba(var(--accent-rgb), 0.1); }
|
||||
|
||||
.copilot-cmd-name { font-weight: 600; color: #6366f1; white-space: nowrap; }
|
||||
.copilot-cmd-name { font-weight: 600; color: var(--accent); white-space: nowrap; }
|
||||
|
||||
.copilot-cmd-desc {
|
||||
color: var(--text-muted, #888);
|
||||
@@ -199,7 +199,7 @@
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.45rem;
|
||||
background: #6366f1;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
@@ -207,7 +207,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copilot-send-btn:hover { background: #4f46e5; }
|
||||
.copilot-send-btn:hover { background: var(--accent-hover); }
|
||||
.copilot-send-btn--stop { background: #dc2626; }
|
||||
.copilot-send-btn--stop:hover { background: #b91c1c; }
|
||||
.copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; }
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
.copilot-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Message bubbles ───────────────────────────────────────────────────────── */
|
||||
|
||||
.copilot-msg {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
@@ -159,17 +159,17 @@
|
||||
.copilot-tool-path {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: inherit;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0 0.25em;
|
||||
background: rgba(99,102,241,0.10);
|
||||
background: rgba(var(--accent-rgb), 0.10);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.copilot-tool-path:hover {
|
||||
text-decoration: underline;
|
||||
background: rgba(99,102,241,0.18);
|
||||
background: rgba(var(--accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.copilot-tool-body {
|
||||
@@ -285,7 +285,7 @@
|
||||
.copilot-markdown pre code { background: none; padding: 0; font-size: inherit; }
|
||||
|
||||
.copilot-markdown blockquote {
|
||||
border-left: 3px solid #6366f1;
|
||||
border-left: 3px solid var(--accent);
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.3rem 0.75rem;
|
||||
color: var(--placeholder-color);
|
||||
@@ -298,7 +298,7 @@
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.copilot-markdown a { color: #6366f1; text-decoration: underline; }
|
||||
.copilot-markdown a { color: var(--accent); text-decoration: underline; }
|
||||
.copilot-markdown strong { font-weight: 700; }
|
||||
.copilot-markdown em { font-style: italic; }
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
.copilot-approval-path {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.75rem;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copilot-approval-actions {
|
||||
@@ -450,7 +450,7 @@
|
||||
|
||||
.copilot-agent,
|
||||
.copilot-agent-end {
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
overflow: clip;
|
||||
@@ -462,18 +462,18 @@
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--msg-assistant-text);
|
||||
}
|
||||
|
||||
.copilot-agent-header i {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copilot-agent-header strong {
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -505,10 +505,10 @@
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.65rem;
|
||||
color: var(--placeholder-color);
|
||||
border-top: 1px solid rgba(99, 102, 241, 0.15);
|
||||
border-top: 1px solid rgba(var(--accent-rgb), 0.15);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.copilot-agent-preview--result {
|
||||
@@ -519,7 +519,7 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copilot-agent-badge.running { background: rgba(234, 179, 8, 0.2); color: #fbbf24; }
|
||||
.copilot-agent-badge.done { background: rgba(22, 163, 74, 0.18); color: #4ade80; }
|
||||
.copilot-agent-header { background: rgba(99, 102, 241, 0.1); }
|
||||
.copilot-agent-header { background: rgba(var(--accent-rgb), 0.1); }
|
||||
}
|
||||
|
||||
/* ── Attachment chips (composer pending + sent user bubble) ──────────────────── */
|
||||
@@ -550,7 +550,7 @@
|
||||
}
|
||||
|
||||
.attach-chip--clickable { cursor: pointer; }
|
||||
.attach-chip--clickable:hover { border-color: #6366f1; }
|
||||
.attach-chip--clickable:hover { border-color: var(--accent); }
|
||||
.attach-chip--uploading { opacity: 0.7; }
|
||||
|
||||
.attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; }
|
||||
|
||||
@@ -11,13 +11,126 @@ app-copilot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
app-copilot.collapsed {
|
||||
app-copilot.collapsed:not([mode="full"]) {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Full mode (home route): the chat fills the workspace ──────────────────── */
|
||||
|
||||
app-copilot[mode="full"] {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Center the conversation in a readable column on wide screens. */
|
||||
app-copilot[mode="full"] .copilot-header,
|
||||
app-copilot[mode="full"] .copilot-tabs,
|
||||
app-copilot[mode="full"] .copilot-messages,
|
||||
app-copilot[mode="full"] .copilot-input-area {
|
||||
padding-left: max(1.25rem, calc((100% - 860px) / 2));
|
||||
padding-right: max(1.25rem, calc((100% - 860px) / 2));
|
||||
}
|
||||
|
||||
app-copilot[mode="full"] .copilot-header {
|
||||
font-size: 1rem;
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
app-copilot[mode="full"] .copilot-msg {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* ── Welcome hero (empty state, full mode) ─────────────────────────────────── */
|
||||
|
||||
.chat-hero {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
max-width: 560px;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.chat-hero-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.chat-hero-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin: 1.1rem 0 0.3rem;
|
||||
}
|
||||
|
||||
.chat-hero-sub {
|
||||
color: var(--placeholder-color);
|
||||
font-size: 1rem;
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
|
||||
.chat-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
color: var(--msg-assistant-text);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.chat-suggestion i {
|
||||
color: var(--accent);
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-suggestion:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chat-suggestions { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Privacy chip (chat header) ────────────────────────────────────────────── */
|
||||
|
||||
.chat-privacy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.18rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-privacy i {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.copilot-resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -31,7 +144,7 @@ app-copilot.collapsed {
|
||||
|
||||
.copilot-resize-handle:hover,
|
||||
.copilot-resize-handle:active {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
background: rgba(var(--accent-rgb), 0.35);
|
||||
}
|
||||
|
||||
.copilot-expand-btn {
|
||||
@@ -43,7 +156,7 @@ app-copilot.collapsed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
writing-mode: vertical-rl;
|
||||
padding: 1rem 0;
|
||||
@@ -72,7 +185,7 @@ app-copilot.collapsed {
|
||||
|
||||
.copilot-header i {
|
||||
font-size: 1rem;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Tabs (General + project chats) ─────────────────────────────────────────── */
|
||||
@@ -105,7 +218,7 @@ app-copilot.collapsed {
|
||||
|
||||
.copilot-tab--active {
|
||||
color: var(--bs-body-color, inherit);
|
||||
border-bottom-color: #6366f1;
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
|
||||
.fv-md hr { border: none; border-top: 1px solid var(--bs-border-color); margin: 1.25rem 0; }
|
||||
.fv-md a { color: #6366f1; text-decoration: underline; }
|
||||
.fv-md a { color: var(--accent); text-decoration: underline; }
|
||||
.fv-md strong { font-weight: 700; }
|
||||
.fv-md em { font-style: italic; }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ── Home page ─────────────────────────────────────────────────────────────── */
|
||||
/* ── Dashboard page ────────────────────────────────────────────────────────── */
|
||||
|
||||
home-page {
|
||||
dashboard-page {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
@@ -13,34 +13,6 @@ home-page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Debug toggle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.home-debug-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.home-debug-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.home-debug-toggle:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.home-debug-toggle i { font-size: 0.82rem; }
|
||||
|
||||
/* ── Hero ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.home-hero {
|
||||
|
||||
@@ -371,7 +371,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
|
||||
|
||||
.chat-page-composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.chat-page-textarea {
|
||||
@@ -428,7 +428,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
|
||||
|
||||
.chat-page-model-pill:focus,
|
||||
.chat-page-model-pill:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
/* ── Mic + send buttons ────────────────────────────────────────────────────── */
|
||||
@@ -559,8 +559,8 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent, #6366f1), var(--accent-hover, #4f46e5));
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
|
||||
background: linear-gradient(135deg, var(--accent, var(--accent)), var(--accent-hover, var(--accent-hover)));
|
||||
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35);
|
||||
}
|
||||
|
||||
.project-card-main {
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 4px;
|
||||
background: var(--bs-primary-bg-subtle, #eef2ff);
|
||||
color: var(--bs-primary, #4f46e5);
|
||||
color: var(--bs-primary, var(--accent-hover));
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -261,7 +261,7 @@
|
||||
}
|
||||
|
||||
.llm-params-pill {
|
||||
background: #6366f1 !important;
|
||||
background: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ file-viewer-page {
|
||||
|
||||
users-page,
|
||||
roles-page,
|
||||
shared-folders-page,
|
||||
connectors-page,
|
||||
connector-detail-page,
|
||||
marketplace-page,
|
||||
|
||||
@@ -17,8 +17,8 @@ app-sidebar {
|
||||
gap: 0.55rem;
|
||||
padding: 0 1rem 1.25rem;
|
||||
color: var(--sidebar-brand-color);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
@@ -89,11 +89,11 @@ app-sidebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.20rem 0.30rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
color: var(--sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.92rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@ app-sidebar {
|
||||
.sidebar-link.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-text-active);
|
||||
font-weight: 500;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-link.active i {
|
||||
@@ -183,11 +183,11 @@ app-sidebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.18rem 0.40rem;
|
||||
padding: 0.32rem 0.55rem;
|
||||
color: var(--sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.84rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.86rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,13 +130,13 @@
|
||||
}
|
||||
|
||||
.task-badge--cron {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--bs-primary, #6366f1);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--bs-primary, var(--accent));
|
||||
}
|
||||
|
||||
.task-badge--sync {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--bs-primary, #6366f1);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--bs-primary, var(--accent));
|
||||
}
|
||||
|
||||
.task-badge--async {
|
||||
|
||||
@@ -12,8 +12,8 @@ app-topbar {
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--sidebar-brand-color);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -26,10 +26,10 @@ app-topbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
@@ -50,12 +50,12 @@ app-topbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6366f1;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: 0;
|
||||
@@ -81,13 +81,13 @@ app-topbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
@@ -95,7 +95,7 @@ app-topbar {
|
||||
}
|
||||
|
||||
.topbar-avatar:hover {
|
||||
background: var(--accent-hover);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.topbar-dropdown {
|
||||
@@ -105,7 +105,7 @@ app-topbar {
|
||||
min-width: 200px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 6px;
|
||||
z-index: 100;
|
||||
|
||||