From 4b1affa600d27b724204ff6dc7c275a03c73fc0c Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Tue, 28 Jul 2026 20:48:03 +0100 Subject: [PATCH] plugins: merge the user Plugins page into per-plugin sidebar pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic per-user #plugins page is gone: a plugin with per-user settings hosts them in its own web_pages() sidebar page instead (Telegram's pairing page is new; Honcho's opt-in page already existed). The admin catalog moves from #plugin-catalog to #plugins (old hash redirected), and user_config_schema is removed from the Plugin trait, the API DTOs and both plugins — the my-config endpoint, the plugin_user_configs store and the update_user_config hook stay, now driven by each plugin's own page fragment. --- CLAUDE.md | 5 +- Cargo.lock | 1 + crates/core-api/src/plugin.rs | 22 +- crates/core-api/src/user_plugin_config.rs | 2 +- crates/plugin-honcho/src/lib.rs | 21 -- crates/plugin-telegram-bot/Cargo.toml | 1 + crates/plugin-telegram-bot/src/lib.rs | 54 +++-- crates/plugin-telegram-bot/web/i18n.js | 57 +++++ crates/plugin-telegram-bot/web/telegram.js | 159 ++++++++++++++ .../skald-core/src/db/plugin_user_configs.rs | 9 +- crates/skald-core/src/plugin/mod.rs | 44 ++-- docs/index.md | 4 +- docs/plugins/comfyui.md | 2 +- docs/plugins/elevenlabs.md | 2 +- docs/plugins/honcho.md | 2 +- docs/plugins/kokoro_tts.md | 2 +- docs/plugins/orpheus_tts_3b.md | 2 +- docs/plugins/remote_connectivity.md | 2 +- docs/plugins/telegram.md | 4 +- docs/plugins/whisper_local.md | 2 +- src/frontend/api/plugins.rs | 6 +- web/app.js | 2 - web/components/plugin-catalog.js | 14 +- web/components/plugin-detail.js | 14 +- web/components/plugins-page.js | 202 ------------------ web/components/shared/plugin-common.js | 8 +- web/components/sidebar.js | 7 +- web/css/page-shell.css | 1 - web/i18n/en.js | 19 +- web/i18n/fr.js | 19 +- web/i18n/it.js | 19 +- web/index.html | 1 - 32 files changed, 338 insertions(+), 371 deletions(-) create mode 100644 crates/plugin-telegram-bot/web/i18n.js create mode 100644 crates/plugin-telegram-bot/web/telegram.js delete mode 100644 web/components/plugins-page.js diff --git a/CLAUDE.md b/CLAUDE.md index 635cd6e..125f681 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: - **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. - **The core never learns about the process shell.** There is no in-core restart hook — the former `restart` tool and its `tools::restart::set_restart_handler` seam were removed. The only coupling to the supervisor is now the `run.sh` exit-code protocol (exit `255` ⇒ re-exec the same binary by path), a seam no code currently triggers (kept for a future admin-driven restart). The live expression of this principle is `skald_core::boot`, which emits startup lines each shell renders (`src/boot_format.rs` here). -**Plugin visibility & per-user config.** The admin surface is split in two: `#plugin-catalog` (`plugin-catalog.js`) is a status board — one card per plugin with an enable toggle + health dot + a Configure button — and `#plugin-detail?id=` (`plugin-detail.js`) holds the instance-config form + per-user access checklist for one plugin (the plugin counterpart of `connector-detail.js`). The user-facing half is `#plugins` (`plugins-page.js`): granted plugins + their per-user config forms. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). A plugin with a non-empty `Plugin::user_config_schema()` exposes per-user settings, stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: the user pastes the bot's pairing code in their Plugins page, the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool) and stores a `{linked, chat_id}` status blob for the UI. Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. +**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form + per-user access checklist for one plugin (the plugin counterpart of `connector-detail.js`). There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. @@ -388,8 +388,7 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che | `approval-rules.js` | `` | Approval rule management | | `cron-jobs.js` | `` | Scheduled job management | | `connectors.js` | `` | MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the **Add connector** dropdown (Marketplace / manual form at `#connectors/new`), per-row removal from the catalog, and the **Sign-in providers** modal (§7/§14/§15) | -| `plugins-page.js` | `` | `#plugins` — user half: granted plugins + schema-driven per-user config form | -| `plugin-catalog.js` | `` | `#plugin-catalog` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | +| `plugin-catalog.js` | `` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | | `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | | `plugin-page-host.js` | `` | Host for plugin-contributed pages (`#plugin//`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `system-agents.js` | `` | `#system-agents` — the caller's own run history for the background system agents (TIC): agent, start, status, duration, counters; row → the run's session | diff --git a/Cargo.lock b/Cargo.lock index f265be5..8c4d2b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3010,6 +3010,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum", "chrono", "core-api", "rand 0.10.1", diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index 16ae57e..2ca4022 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -120,16 +120,14 @@ pub trait Plugin: Send + Sync { /// JSON Schema describing the plugin's config fields. fn config_schema(&self) -> Value { serde_json::json!({}) } - /// JSON Schema describing the plugin's *per-user* config fields (e.g. - /// Telegram's pairing code). Empty schema (the default) = the plugin has - /// no per-user settings and does not appear as configurable in the user - /// UI. Values are stored admin-readable in `system.db` — never secrets. - fn user_config_schema(&self) -> Value { serde_json::json!({}) } - - /// Applies a per-user config submission. The default just stores the blob - /// in the generic store; plugins that need validation or a side effect - /// (e.g. Telegram turning a pairing code into a chat binding) override it - /// and may store a sanitized status blob for the UI via `ctx.user_config`. + /// Applies a per-user config submission, received through the core + /// `PUT /api/plugins/{id}/my-config` endpoint from the plugin's own + /// [`Plugin::web_pages`] fragment (e.g. Telegram's pairing page, Honcho's + /// opt-in page). The default just stores the blob in the generic store; + /// plugins that need validation or a side effect (e.g. Telegram turning a + /// pairing code into a chat binding) override it and may store a sanitized + /// status blob for the UI via `ctx.user_config`. Values are stored + /// admin-readable in `system.db` — never secrets. async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> { ctx.user_config.set(self.id(), user_id, config).await } @@ -138,8 +136,8 @@ pub trait Plugin: Send + Sync { /// pairing lifecycle rather than the generic `plugin_access` grants — e.g. /// the mobile connector, whose access is the device→user binding (§13). /// When `true`, the admin Plugins UI suppresses the "User access" - /// checklist (it would control nothing), the plugin never appears in a - /// user's "My plugins" view, and its non-`admin_only` `web_pages()` are + /// checklist (it would control nothing), the plugin is left out of + /// `GET /api/plugins/mine`, and its non-`admin_only` `web_pages()` are /// visible to every logged-in user — the page itself scopes what each /// caller sees (e.g. admin sees all devices, others only their own). /// Default `false`: access is the admin's per-user `plugin_access` grant diff --git a/crates/core-api/src/user_plugin_config.rs b/crates/core-api/src/user_plugin_config.rs index f14f4fc..936601b 100644 --- a/crates/core-api/src/user_plugin_config.rs +++ b/crates/core-api/src/user_plugin_config.rs @@ -6,7 +6,7 @@ use serde_json::Value; /// `system.db`). /// /// Values are deliberately admin-readable — the table lives in the registry -/// database — so `user_config_schema`s must never collect secrets. A plugin +/// database — so per-user plugin configs must never collect secrets. A plugin /// that needs per-user secrets should keep them elsewhere. #[async_trait] pub trait PluginUserConfigApi: Send + Sync { diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index 8944218..e8c8f2c 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -829,27 +829,6 @@ impl core_api::plugin::Plugin for HonchoPlugin { }) } - /// Per-user opt-in. Honcho stores conversations in cleartext on an external - /// server, so a user must knowingly enable it. A plain boolean — no secrets — - /// so the admin-readable `plugin_user_configs` store is an honest home. The - /// default `update_user_config` (store the blob) is exactly right; no override. - fn user_config_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Enable long-term memory", - "description": "Let the assistant remember you across sessions. \ - Your messages will be stored in cleartext on the \ - Honcho memory server, outside your encrypted \ - database. Off unless you turn it on.", - "default": false - } - } - }) - } - /// Two dedicated pages served from this plugin's own router (`web/*.js`): /// an **admin** config page (connection + a connectivity test) and a /// **user** opt-in page (the per-user consent to long-term memory). The diff --git a/crates/plugin-telegram-bot/Cargo.toml b/crates/plugin-telegram-bot/Cargo.toml index 2760407..6d9b267 100644 --- a/crates/plugin-telegram-bot/Cargo.toml +++ b/crates/plugin-telegram-bot/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" core-api = { path = "../core-api" } anyhow = "1" async-trait = "0.1" +axum = { version = "0.8" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/crates/plugin-telegram-bot/src/lib.rs b/crates/plugin-telegram-bot/src/lib.rs index bc3adb1..134f7df 100644 --- a/crates/plugin-telegram-bot/src/lib.rs +++ b/crates/plugin-telegram-bot/src/lib.rs @@ -12,11 +12,13 @@ /// # Pairing /// /// Unknown chats receive a pairing code. The user links their own account by -/// pasting the code in the Plugins page of the web app (the plugin's -/// `user_config_schema` / `update_user_config` hook); the admin's agent can -/// also bind a chat via the `telegram_pairing` tool (category `Config`). The -/// binding is written to the config table; the resulting `ConfigKeyUpdated` -/// event reloads the in-memory cache instantly. +/// pasting the code in the plugin's own Telegram page in the web app's sidebar +/// (served as a `web_pages()` fragment, saved through the core +/// `PUT /api/plugins/telegram/my-config` endpoint into the +/// `update_user_config` hook); the admin's agent can also bind a chat via the +/// `telegram_pairing` tool (category `Config`). The binding is written to the +/// config table; the resulting `ConfigKeyUpdated` event reloads the in-memory +/// cache instantly. /// /// # Human-in-the-loop approvals /// @@ -42,7 +44,7 @@ use tracing::{info, warn}; use core_api::command::CommandApi; use core_api::config_api::ConfigApi; use core_api::location::LocationUpdater; -use core_api::plugin::{Plugin, PluginContext}; +use core_api::plugin::{Plugin, PluginContext, PluginPage}; use core_api::transcribe::TranscribeProvider; use core_api::tts::TtsProvider; use core_api::user_channel::UserChannelApi; @@ -197,18 +199,34 @@ impl Plugin for TelegramPlugin { }) } - fn user_config_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "pairing_code": { - "type": "string", - "title": "Pairing code", - "description": "Send any message to the bot — it replies with a 6-character code. Paste it here to link your Telegram chat." - } - }, - "required": ["pairing_code"] - }) + /// The user-facing pairing page (`#plugin/telegram/telegram`), served as a + /// fragment from this plugin's own router. Visible to any user with a + /// `plugin_access` grant — the correct audience for self-service pairing. + fn web_pages(&self) -> Vec { + vec![PluginPage { + page_id: "telegram", + title: "Telegram".into(), + icon: "telegram", + entry: "web/telegram.js".into(), + admin_only: false, + // Sidebar priority: core "Your space" items live in 10–90, mobile + // connector took 100, honcho 120–130 — slot in between. + priority: 110, + }] + } + + /// Serves the page fragment + its string table. Stateless and cheap to + /// build (the contract: routers are built at boot, enabled or not) — the + /// pairing save itself reuses the core `/api/plugins/telegram/my-config` + /// endpoint, so no runtime state is needed here. + fn http_router(&self) -> Option { + use axum::{Router, routing::get, http::header, response::{IntoResponse, Response}}; + fn serve_js(body: &'static str) -> Response { + ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response() + } + Some(Router::new() + .route("/web/telegram.js", get(|| async { serve_js(include_str!("../web/telegram.js")) })) + .route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) }))) } /// Self-service pairing: the user pastes the code the bot replied with, diff --git a/crates/plugin-telegram-bot/web/i18n.js b/crates/plugin-telegram-bot/web/i18n.js new file mode 100644 index 0000000..e27f5f4 --- /dev/null +++ b/crates/plugin-telegram-bot/web/i18n.js @@ -0,0 +1,57 @@ +// Frontend translations for the Telegram page fragment. +// +// Served at `/api/plugin/telegram/web/i18n.js` and imported by `telegram.js`, +// which registers it into the host's shared dictionaries via `addStrings` +// (see `web/lib/i18n.js`). Keys are namespaced `plugin.telegram.*` so they +// never collide with core keys. +const P = 'plugin.telegram'; + +export default { + en: { + [`${P}.title`]: 'Telegram', + [`${P}.intro`]: 'Chat with the assistant from Telegram by linking your Telegram chat to your account.', + [`${P}.status.linked`]: 'Your Telegram chat is linked.', + [`${P}.status.unlinked`]: 'Your Telegram chat is not linked yet.', + [`${P}.status.chat_id`]: 'Chat ID', + [`${P}.howto_title`]: 'How to link it', + [`${P}.howto_body`]: 'Send any message to the bot — it replies with a 6-character code. Paste the code here.', + [`${P}.code_label`]: 'Pairing code', + [`${P}.save`]: 'Link', + [`${P}.saved`]: 'Linked!', + [`${P}.relink_hint`]: 'Pasting a new code replaces the current link.', + [`${P}.loading`]: 'Loading…', + [`${P}.unavailable`]: 'Telegram is not available to you yet. Ask your administrator to grant access.', + }, + + it: { + [`${P}.title`]: 'Telegram', + [`${P}.intro`]: 'Chatta con l’assistente da Telegram collegando la tua chat Telegram al tuo account.', + [`${P}.status.linked`]: 'La tua chat Telegram è collegata.', + [`${P}.status.unlinked`]: 'La tua chat Telegram non è ancora collegata.', + [`${P}.status.chat_id`]: 'ID chat', + [`${P}.howto_title`]: 'Come collegarla', + [`${P}.howto_body`]: 'Invia un messaggio qualsiasi al bot — ti risponde con un codice di 6 caratteri. Incolla il codice qui.', + [`${P}.code_label`]: 'Codice di pairing', + [`${P}.save`]: 'Collega', + [`${P}.saved`]: 'Collegata!', + [`${P}.relink_hint`]: 'Incollare un nuovo codice sostituisce il collegamento attuale.', + [`${P}.loading`]: 'Caricamento…', + [`${P}.unavailable`]: 'Telegram non è ancora disponibile per te. Chiedi all’amministratore di darti l’accesso.', + }, + + fr: { + [`${P}.title`]: 'Telegram', + [`${P}.intro`]: 'Discutez avec l’assistant depuis Telegram en reliant votre conversation Telegram à votre compte.', + [`${P}.status.linked`]: 'Votre conversation Telegram est reliée.', + [`${P}.status.unlinked`]: 'Votre conversation Telegram n’est pas encore reliée.', + [`${P}.status.chat_id`]: 'ID de conversation', + [`${P}.howto_title`]: 'Comment la relier', + [`${P}.howto_body`]: 'Envoyez n’importe quel message au bot — il répond avec un code à 6 caractères. Collez le code ici.', + [`${P}.code_label`]: 'Code d’appairage', + [`${P}.save`]: 'Relier', + [`${P}.saved`]: 'Reliée !', + [`${P}.relink_hint`]: 'Coller un nouveau code remplace le lien actuel.', + [`${P}.loading`]: 'Chargement…', + [`${P}.unavailable`]: 'Telegram n’est pas encore disponible pour vous. Demandez l’accès à votre administrateur.', + }, +}; diff --git a/crates/plugin-telegram-bot/web/telegram.js b/crates/plugin-telegram-bot/web/telegram.js new file mode 100644 index 0000000..507266a --- /dev/null +++ b/crates/plugin-telegram-bot/web/telegram.js @@ -0,0 +1,159 @@ +// Telegram pairing page (page_id `telegram`, visible to any user with a +// `plugin_access` grant). +// +// Self-service chat linking: the user sends any message to the bot, gets a +// 6-character code back, and pastes it here. Reuses the core per-user config +// endpoints — `GET /api/plugins/mine` to read the `{linked, chat_id}` status +// blob, `PUT /api/plugins/telegram/my-config` to submit the code (the +// plugin's `update_user_config` override turns it into a chat↔user binding) — +// so this fragment needs no backend of its own. Default-exports the element +// class; the host registers it. +import { LitElement, html, nothing } from 'lit'; +import { t, addStrings, I18nMixin } from '/lib/i18n.js'; +import STRINGS from './i18n.js'; + +addStrings(STRINGS); + +const P = 'plugin.telegram'; +const ID = 'telegram'; + +/// JSON fetch that throws the server's error text on non-2xx and tolerates an +/// empty (204) body. The server's error text is already localized, so it is +/// safe to surface directly. +async function jf(url, opts = {}) { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + ...opts, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(txt || `HTTP ${res.status}`); + } + if (res.status === 204) return null; + const ct = res.headers.get('content-type') || ''; + return ct.includes('application/json') ? res.json() : res.text(); +} + +export default class TelegramPage extends I18nMixin(LitElement) { + // Light DOM, so Bootstrap classes and the app's theme CSS variables apply. + createRenderRoot() { return this; } + + static get properties() { + return { + _row: { state: true }, // UserPluginView | null (null once loaded = not granted) + _code: { state: true }, // pairing code draft + _status: { state: true }, // { ok?, err? } + _error: { state: true }, + _loading: { state: true }, + }; + } + + constructor() { + super(); + this._row = null; + this._code = ''; + this._status = {}; + this._error = null; + this._loading = true; + } + + connectedCallback() { + super.connectedCallback(); + this._load(); + } + + async _load() { + this._loading = true; + this._error = null; + try { + const mine = await jf('/api/plugins/mine'); + this._row = (mine ?? []).find(x => x.id === ID) ?? null; + } catch (e) { + this._error = e.message; + } finally { + this._loading = false; + } + } + + async _save() { + this._status = {}; + try { + await jf(`/api/plugins/${ID}/my-config`, { + method: 'PUT', + body: JSON.stringify({ pairing_code: this._code.trim() }), + }); + this._code = ''; + this._status = { ok: t(`${P}.saved`) }; + await this._load(); + } catch (e) { + this._status = { err: e.message }; + } + } + + render() { + return html` +
+
+

${t(`${P}.title`)}

+
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${this._loading + ? html`
${t(`${P}.loading`)}
` + : this._row ? this._renderBody() : this._renderUnavailable()} +
+
`; + } + + _renderUnavailable() { + return html` +
+ +

${t(`${P}.unavailable`)}

+
`; + } + + _renderBody() { + const linked = !!this._row?.user_config?.linked; + const chatId = this._row?.user_config?.chat_id; + return html` +

${t(`${P}.intro`)}

+ +
+
+
+
+
+ ${linked ? t(`${P}.status.linked`) : t(`${P}.status.unlinked`)} +
+ ${linked && chatId != null ? html` +
${t(`${P}.status.chat_id`)}: ${chatId}
` : nothing} +
+ + ${linked ? html`` : html``} + +
+
+ +
+ ${t(`${P}.howto_title`)} +
+

${t(`${P}.howto_body`)}

+ +
+ + { this._code = e.target.value; this._status = {}; }} /> +
+ + ${this._status.err ? html`
${this._status.err}
` : nothing} + ${this._status.ok ? html`
${this._status.ok}
` : nothing} + + + ${linked ? html` +
${t(`${P}.relink_hint`)}
` : nothing} + `; + } +} diff --git a/crates/skald-core/src/db/plugin_user_configs.rs b/crates/skald-core/src/db/plugin_user_configs.rs index 28f9448..03db219 100644 --- a/crates/skald-core/src/db/plugin_user_configs.rs +++ b/crates/skald-core/src/db/plugin_user_configs.rs @@ -1,10 +1,11 @@ //! Per-user plugin configuration blobs (`plugin_user_configs` table). //! //! Registry table in `system.db` — **admin-readable, never secrets**. A plugin -//! with a non-empty `user_config_schema()` lets each granted user submit their -//! own settings from the UI (e.g. Telegram's pairing code); the plugin's -//! `update_user_config` hook validates and stores here. `plugin_id` is a bare -//! TEXT for the same reason as `plugin_access`. +//! with per-user settings surfaces them in its own `web_pages()` fragment +//! (e.g. Telegram's pairing page); the submission travels through the core +//! `PUT /api/plugins/{id}/my-config` endpoint into the plugin's +//! `update_user_config` hook, which validates and stores here. `plugin_id` is +//! a bare TEXT for the same reason as `plugin_access`. use anyhow::Result; use serde_json::Value; diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index 406f6ad..7b1b22e 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -34,7 +34,6 @@ pub struct PluginInfo { pub running: bool, pub config: Value, pub config_schema: Value, - pub user_config_schema: Value, /// Whether the plugin contributes an `http_router()` — its routes are /// mounted at boot and gated at runtime, so they serve as soon as the /// plugin is enabled (no restart). @@ -42,6 +41,10 @@ pub struct PluginInfo { /// Whether the plugin gates access through its own binding lifecycle — the /// admin UI hides the "User access" checklist when true (see the trait). pub manages_own_access: bool, + /// Whether the plugin contributes a user-facing (`!admin_only`) page via + /// `web_pages()` — the static signal that it has per-user settings of its + /// own (e.g. Telegram's pairing page). Informational, for the admin UI. + pub has_user_page: bool, /// Whether the plugin-detail page shows the generic `config_schema` form /// (`false` = the plugin hosts its own config UI in one of its pages). pub config_in_detail_page: bool, @@ -49,13 +52,14 @@ pub struct PluginInfo { } /// One user's view of a plugin they may use — served by `GET /api/plugins/mine`. +/// Read by the plugin's own page fragment (e.g. Telegram's pairing page reads +/// its `{linked, chat_id}` status blob from `user_config`). #[derive(Debug, Clone, Serialize)] pub struct UserPluginView { - pub id: String, - pub name: String, - pub description: String, - pub user_config_schema: Value, - pub user_config: Value, + pub id: String, + pub name: String, + pub description: String, + pub user_config: Value, } /// A plugin-contributed web page as seen by one user — served by @@ -404,9 +408,9 @@ impl PluginManager { running: plugin.is_running(), config: serde_json::from_str(&config_json).unwrap_or(json!({})), config_schema: plugin.config_schema(), - user_config_schema: plugin.user_config_schema(), has_router: plugin.http_router().is_some(), manages_own_access: plugin.manages_own_access(), + has_user_page: plugin.web_pages().iter().any(|pg| !pg.admin_only), config_in_detail_page: plugin.config_in_detail_page(), runtime_status: plugin.runtime_status(), }); @@ -423,9 +427,10 @@ impl PluginManager { // ── Per-user access & configuration ─────────────────────────────────────── - /// The plugins a user sees in their UI: **enabled** and granted in + /// The plugins a user may interact with: **enabled** and granted in /// `plugin_access` (admins see every enabled plugin). Each entry carries - /// the user's current config blob for the schema-driven form. + /// the user's current config blob — read by the plugin's own page + /// fragment, never rendered by a generic core UI. pub async fn list_accessible(&self, user_id: &str, is_admin: bool) -> Result> { let granted: std::collections::HashSet = if is_admin { std::collections::HashSet::new() @@ -434,8 +439,8 @@ impl PluginManager { }; let mut out = Vec::new(); for plugin in &self.plugins { - // Binding-managed plugins (e.g. mobile-connector) aren't configured - // from the "My plugins" view — they own their own pairing UI. + // Binding-managed plugins (e.g. mobile-connector) own their access + // model — there is no per-user config blob of ours to show them. if plugin.manages_own_access() { continue; } @@ -449,10 +454,9 @@ impl PluginManager { .await? .unwrap_or(json!({})); out.push(UserPluginView { - id: plugin.id().to_string(), - name: plugin.name().to_string(), - description: plugin.description().to_string(), - user_config_schema: plugin.user_config_schema(), + id: plugin.id().to_string(), + name: plugin.name().to_string(), + description: plugin.description().to_string(), user_config, }); } @@ -529,17 +533,15 @@ impl PluginManager { plugin_access::set_access(&self.db, id, user_ids).await } - /// Applies a user's per-plugin config submission. The plugin must be - /// enabled, declare a non-empty `user_config_schema`, and the caller must - /// hold access (enforced by the API layer). + /// Applies a user's per-plugin config submission, received from the + /// plugin's own page fragment. The plugin must be enabled and the caller + /// must hold access (enforced by the API layer); what the submission means + /// is entirely the plugin's business (see `Plugin::update_user_config`). pub async fn update_user_config(&self, id: &str, user_id: &str, config: Value) -> Result<()> { let plugin = self.find(id)?; if !self.is_enabled(id).await? { anyhow::bail!("plugin is not enabled: {id}"); } - if plugin.user_config_schema().as_object().is_none_or(|s| s.is_empty()) { - anyhow::bail!("plugin has no per-user configuration: {id}"); - } let skald = self.skald()?; plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await } diff --git a/docs/index.md b/docs/index.md index ea52683..f9d6e08 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,7 +33,7 @@ Plugins are optional add-ons an admin can enable and configure — extra voices, General plugin mechanics that apply to all of them: -- An admin enables/disables and configures each plugin from the **Plugin catalog** (sidebar → Plugins, admin view): one card per plugin, an enable toggle, and a **Configure** button opening its settings form. +- An admin enables/disables and configures each plugin from the **Plugins** page (sidebar → Plugins, admin-only): one card per plugin, an enable toggle, and a **Configure** button opening its settings form. - A plugin only becomes visible to a given user once the admin grants them access — being enabled instance-wide isn't enough by itself (Mobile Connector is the one exception: access there is the device-pairing itself, not a grant list). -- Some plugins add a **per-user** settings form of their own (e.g. Telegram's pairing code, Honcho's memory opt-in) on that user's own Plugins page — separate from the admin's instance-wide config. +- A plugin with **per-user** settings (e.g. Telegram's pairing code, Honcho's memory opt-in) gives each granted user its own dedicated **sidebar page** to manage them — separate from the admin's instance-wide config. - A plugin can add tools the assistant calls directly (e.g. `set_secret`, `telegram_pairing`), a dedicated sidebar page, or both. diff --git a/docs/plugins/comfyui.md b/docs/plugins/comfyui.md index bd0f80a..1465f9f 100644 --- a/docs/plugins/comfyui.md +++ b/docs/plugins/comfyui.md @@ -17,7 +17,7 @@ The plugin polls the ComfyUI server every 5 seconds. If it's offline, every mode ## Enabling & configuring (admin) -1. Plugin catalog → **ComfyUI** → enable, then **Configure**. +1. Plugins page → **ComfyUI** → enable, then **Configure**. 2. Fields: - **`base_url`** (default `http://localhost:8188`) — where ComfyUI's API is listening. - **`workflows_dir`** (default `data/comfyui/workflows`) — folder to watch for `.json` workflow files. Created automatically if missing. diff --git a/docs/plugins/elevenlabs.md b/docs/plugins/elevenlabs.md index d2bf234..68c74cb 100644 --- a/docs/plugins/elevenlabs.md +++ b/docs/plugins/elevenlabs.md @@ -16,7 +16,7 @@ Enabling the plugin does not by itself add any voice or transcription model — ## Enabling & configuring (admin) -1. Plugin catalog → **ElevenLabs** → enable. (This plugin has no config form of its own.) +1. Plugins page → **ElevenLabs** → enable. (This plugin has no config form of its own.) 2. Go to the Models hub → **LLM Providers**, add a new provider, choose type **ElevenLabs**, paste the API key (stored as a secret field, not shown again after saving). 3. Go to the Models hub → **Transcription** and/or **TTS**, add a model, pick the ElevenLabs provider just created, and choose a voice/model from the list — fetched live from ElevenLabs, so it always reflects what's actually available on that account. diff --git a/docs/plugins/honcho.md b/docs/plugins/honcho.md index ddecfd2..f4c7202 100644 --- a/docs/plugins/honcho.md +++ b/docs/plugins/honcho.md @@ -17,7 +17,7 @@ Streams a user's completed chat turns to an external [Honcho](https://honcho.dev ## Enabling & configuring (admin) -1. Plugin catalog → **Honcho Memory** → enable, then **Configure** (or its own admin page, once enabled: sidebar → Honcho). +1. Plugins page → **Honcho Memory** → enable, then **Configure** (or its own admin page, once enabled: sidebar → Honcho). 2. Fields: - **`base_url`** (default `http://localhost:8000`) — the Honcho server's URL. - **`api_key`** — optional, only if the server requires auth. diff --git a/docs/plugins/kokoro_tts.md b/docs/plugins/kokoro_tts.md index 546b71e..203b91a 100644 --- a/docs/plugins/kokoro_tts.md +++ b/docs/plugins/kokoro_tts.md @@ -15,7 +15,7 @@ Lightweight, fast local text-to-speech using the Kokoro ONNX model. Runs on CPU ## Enabling & configuring (admin) -1. Plugin catalog → **Kokoro TTS** → enable, then **Configure**. +1. Plugins page → **Kokoro TTS** → enable, then **Configure**. 2. Fields: - **`voice`** (default `if_sara`) — voice id. Prefix meaning: `a`=American, `b`=British, `i`=Italian, `j`=Japanese, `z`=Chinese; `f`=female, `m`=male. Includes `if_sara`, `im_nicola` (Italian), plus several English voices (`af_*`, `am_*`, `bf_*`, `bm_*`). - **`lang`** (default `it`) — language code for phonemisation: `it`, `en-us`, `en-gb`, `ja`, `zh`, `es`, `fr`, `hi`, `pt-br`, `ko`. diff --git a/docs/plugins/orpheus_tts_3b.md b/docs/plugins/orpheus_tts_3b.md index 0df48b5..16ed559 100644 --- a/docs/plugins/orpheus_tts_3b.md +++ b/docs/plugins/orpheus_tts_3b.md @@ -19,7 +19,7 @@ The model is gated on HuggingFace, so it requires a personal access token before ## Enabling & configuring (admin) 1. Get a HuggingFace token and store it as the secret `HUGGINGFACE_TOKEN` (see above). -2. Plugin catalog → **Orpheus TTS 3B** → enable, then **Configure**. +2. Plugins page → **Orpheus TTS 3B** → enable, then **Configure**. 3. Fields: - **`quantization`** (`none` | `int8` | `int4`, default `int8`) — lower precision uses less VRAM at some quality cost. - **`voice`** (`tara` | `dan` | `leah` | `zac` | `zoe` | `mia` | `julia` | `leo`, default `tara`). diff --git a/docs/plugins/remote_connectivity.md b/docs/plugins/remote_connectivity.md index fb7e894..d5589f9 100644 --- a/docs/plugins/remote_connectivity.md +++ b/docs/plugins/remote_connectivity.md @@ -19,7 +19,7 @@ Depends on which provider is chosen: ## Enabling & configuring (admin) -1. Plugin catalog → **Remote Connectivity** → enable, then **Configure**. +1. Plugins page → **Remote Connectivity** → enable, then **Configure**. 2. Fields: - **`provider`** (`tailscale_sys` | `tailscale`, default `tailscale_sys`) — see requirements above. - **`auth_key`** — only for the embedded `tailscale` provider; a Tailscale auth key (`tskey-auth-…`), needed on first join. diff --git a/docs/plugins/telegram.md b/docs/plugins/telegram.md index 2e08536..7755abc 100644 --- a/docs/plugins/telegram.md +++ b/docs/plugins/telegram.md @@ -16,7 +16,7 @@ One bot serves everyone on the instance; each person pairs their **own** Telegra ## Enabling & configuring (admin) -1. Plugin catalog → **Telegram Bot** → enable, then **Configure**. +1. Plugins page → **Telegram Bot** → enable, then **Configure**. 2. Field: - **`token`** (required) — the bot token from BotFather. Stored as a secret field, not shown again after saving. @@ -26,7 +26,7 @@ Once the bot is enabled and a user has been granted access to the plugin: 1. The user opens Telegram, finds the bot (by the username chosen in BotFather), and sends it any message. 2. The bot replies with a short pairing code. -3. The user goes to their own Plugins page in the web app, finds Telegram, and pastes the code into the **pairing code** field. +3. The user opens the **Telegram** page in the web app's sidebar and pastes the code into the **pairing code** field. That's the whole flow — no admin involvement needed for a normal pairing. (An admin *can* alternatively bind a chat to a user directly using the `telegram_pairing` tool from the assistant, e.g. if a user can't access the web app.) diff --git a/docs/plugins/whisper_local.md b/docs/plugins/whisper_local.md index c91e9e3..b59feca 100644 --- a/docs/plugins/whisper_local.md +++ b/docs/plugins/whisper_local.md @@ -23,7 +23,7 @@ The model (roughly 1–3 GB depending on size) is loaded into memory only when f ## Enabling & configuring (admin) 1. Download a model file first (see above) and note its path. -2. Plugin catalog → **Whisper Local** → enable, then **Configure**. +2. Plugins page → **Whisper Local** → enable, then **Configure**. 3. Fields: - **`model`** (required) — path to the `.bin` file, e.g. `models/ggml-large-v3.bin`. - **`language`** — a BCP-47 code (`it`, `en`, …) or `auto` for automatic detection (default `auto`). diff --git a/src/frontend/api/plugins.rs b/src/frontend/api/plugins.rs index 0968ea7..c72a37f 100644 --- a/src/frontend/api/plugins.rs +++ b/src/frontend/api/plugins.rs @@ -3,9 +3,9 @@ //! Two audiences, mirroring the Connectors split: //! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide //! config, and the per-user access grants (`plugin_access`). -//! - **Any user**: sees the plugins granted to them (`/plugins/mine`) and -//! edits their own per-user config when the plugin declares a -//! `user_config_schema` (e.g. Telegram's pairing code). +//! - **Any user**: sees the plugins granted to them (`/plugins/mine`, read by +//! the plugins' own page fragments) and submits their own per-user config +//! (`/{id}/my-config` — e.g. Telegram's pairing code from its sidebar page). use axum::{ extract::{Extension, Path, State}, diff --git a/web/app.js b/web/app.js index 468d00b..81d8bc6 100644 --- a/web/app.js +++ b/web/app.js @@ -14,7 +14,6 @@ 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 { PluginsPage } from './components/plugins-page.js'; import { PluginPageHost } from './components/plugin-page-host.js'; import { PluginCatalogPage } from './components/plugin-catalog.js'; import { PluginDetailPage } from './components/plugin-detail.js'; @@ -56,7 +55,6 @@ customElements.define('roles-page', RolesPage); customElements.define('shared-folders-page', SharedFoldersPage); customElements.define('connectors-page', ConnectorsPage); customElements.define('connector-detail-page', ConnectorDetailPage); -customElements.define('plugins-page', PluginsPage); customElements.define('plugin-page-host', PluginPageHost); customElements.define('plugin-catalog-page', PluginCatalogPage); customElements.define('plugin-detail-page', PluginDetailPage); diff --git a/web/components/plugin-catalog.js b/web/components/plugin-catalog.js index 8eccaf9..ea2c2fc 100644 --- a/web/components/plugin-catalog.js +++ b/web/components/plugin-catalog.js @@ -3,8 +3,10 @@ import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { jf, hasSchema, pluginHealth } from './shared/plugin-common.js'; -// Plugin catalog (`#plugin-catalog`) — the admin board of every registered -// plugin. +// Plugins page (`#plugins`) — the admin board of every registered plugin, +// and the single plugin management surface (the old per-user `#plugins` +// page is gone: a plugin with per-user settings — Telegram's pairing, +// Honcho's opt-in — hosts them in its own sidebar page via `web_pages()`). // // One card per plugin: an enable/disable toggle, a health dot (green = // enabled, running and fully configured; red = enabled but broken; grey = @@ -14,7 +16,7 @@ import { jf, hasSchema, pluginHealth } from './shared/plugin-common.js'; // // Styling reuses the connectors card grid (`web/css/connectors.css`). -const PAGE_ID = 'plugin-catalog'; +const PAGE_ID = 'plugins'; export class PluginCatalogPage extends LightElement { @@ -94,7 +96,7 @@ export class PluginCatalogPage extends LightElement { return html`
-

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

+

${t('nav.plugins')}

${this._error ? html` @@ -138,8 +140,8 @@ export class PluginCatalogPage extends LightElement {
${hasSchema(p.config_schema) ? html` ${t('plugins.badge.instance_config')}` : nothing} - ${hasSchema(p.user_config_schema) ? html` - ${t('plugins.badge.user_config')}` : nothing} + ${p.has_user_page ? html` + ${t('plugins.badge.user_page')}` : nothing}
diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js index 15c64fa..bd617e6 100644 --- a/web/components/plugin-detail.js +++ b/web/components/plugin-detail.js @@ -1,10 +1,10 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; -import { jf, schemaFields, hasSchema, pluginHealth } from './shared/plugin-common.js'; +import { jf, schemaFields, pluginHealth } from './shared/plugin-common.js'; // One plugin's admin page (`#plugin-detail?id=`), reached from the -// Configure button on `#plugin-catalog` — the plugin counterpart of +// Configure button on `#plugins` — the plugin counterpart of // `connector-detail.js`. // // Hosts what was squeezed into the old combined page: the instance-wide @@ -128,10 +128,10 @@ export class PluginDetailPage extends LightElement { _back() { // Prefer real history so the browser's own Back stays consistent; fall back - // to the catalog when this page was opened straight from a pasted URL. + // to the plugins list when this page was opened straight from a pasted URL. if (history.length > 1) { history.back(); return; } - history.pushState({ page: 'plugin-catalog' }, '', '#plugin-catalog'); - window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugin-catalog' } })); + history.pushState({ page: 'plugins' }, '', '#plugins'); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugins' } })); } _setDraft(key, value) { @@ -243,8 +243,8 @@ export class PluginDetailPage extends LightElement {
${p.description ? html`
${p.description}
` : nothing}
- ${hasSchema(p.user_config_schema) ? html` - ${t('plugins.badge.user_config')}` : nothing} + ${p.has_user_page ? html` + ${t('plugins.badge.user_page')}` : nothing}
this.requestUpdate(); - window.addEventListener('locale-changed', this.__onLocaleChanged); - window.addEventListener('llm-page-change', (e) => { - this._open = e.detail.page === 'plugins'; - 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 { - this._mine = await jf('/api/plugins/mine'); - } catch (e) { - this._error = e.message; - } - } - - _uDraft(p) { - if (!this._uDrafts[p.id]) { - // Seed the form from the stored config for keys the schema knows. - const draft = {}; - for (const f of schemaFields(p.user_config_schema)) { - const v = p.user_config?.[f.key]; - draft[f.key] = v ?? (f.type === 'boolean' ? false : ''); - } - this._uDrafts = { ...this._uDrafts, [p.id]: draft }; - } - return this._uDrafts[p.id]; - } - - _setUDraft(id, key, value) { - this._uDrafts = { ...this._uDrafts, [id]: { ...this._uDrafts[id], [key]: value } }; - } - - async _saveUserConfig(p) { - const draft = this._uDraft(p); - for (const f of schemaFields(p.user_config_schema)) { - if (f.required && !draft[f.key]) { - this._uStatus = { ...this._uStatus, [p.id]: { err: t('plugins.error.required', { field: f.label }) } }; - return; - } - } - this._uStatus = { ...this._uStatus, [p.id]: {} }; - try { - await jf(`/api/plugins/${encodeURIComponent(p.id)}/my-config`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(draft), - }); - this._uStatus = { ...this._uStatus, [p.id]: { ok: t('plugins.saved') } }; - // Drop the draft so the reloaded status blob re-seeds the form. - const drafts = { ...this._uDrafts }; - delete drafts[p.id]; - this._uDrafts = drafts; - this._mine = await jf('/api/plugins/mine'); - } catch (e) { - this._uStatus = { ...this._uStatus, [p.id]: { err: e.message } }; - } - } - - // ── Render ───────────────────────────────────────────────────────────────── - - render() { - if (!this._open) return nothing; - const loading = this._mine === null && !this._error; - - return html` -
-
-

${t('plugins.title')}

-
- - ${this._error ? html` -
${this._error}
` : nothing} - - ${loading - ? html`
${t('plugins.loading')}
` - : html` -
- ${this._renderMine()} -
`} -
`; - } - - _renderMine() { - const rows = this._mine ?? []; - if (rows.length === 0) { - return html` -
-

${t('plugins.empty.mine')}

-

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

-
`; - } - return html`
${rows.map(p => this._renderUserCard(p))}
`; - } - - /// Stored config entries the schema does not cover (e.g. Telegram's - /// `{linked, chat_id}` status blob) rendered as a small status list. - _renderUserStatus(p) { - const covered = new Set(schemaFields(p.user_config_schema).map(f => f.key)); - const extra = Object.entries(p.user_config || {}).filter(([k]) => !covered.has(k)); - if (!extra.length) return nothing; - return html` -
- ${extra.map(([k, v]) => html` -
- ${k} - ${typeof v === 'boolean' ? (v ? t('plugins.yes') : t('plugins.no')) : String(v)} -
`)} -
`; - } - - _renderUserCard(p) { - const fields = schemaFields(p.user_config_schema); - const status = this._uStatus[p.id] || {}; - const draft = fields.length ? this._uDraft(p) : {}; - return html` -
-
-
-
-
${p.name}
-
${p.id}
-
- ${t('plugins.status.active')} -
- ${p.description ? html`
${p.description}
` : nothing} - ${this._renderUserStatus(p)} - ${fields.length ? html` -
- ${fields.map(f => html` -
- - ${f.type === 'boolean' ? html` -
- this._setUDraft(p.id, f.key, e.target.checked)} /> -
` : html` - this._setUDraft(p.id, f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`} - ${f.description ? html`
${f.description}
` : nothing} -
`)} - ${status.err ? html`
${status.err}
` : nothing} - ${status.ok ? html`
${status.ok}
` : nothing} - -
` : nothing} -
`; - } -} diff --git a/web/components/shared/plugin-common.js b/web/components/shared/plugin-common.js index 4956efe..b48488b 100644 --- a/web/components/shared/plugin-common.js +++ b/web/components/shared/plugin-common.js @@ -1,7 +1,7 @@ -// Shared helpers for the plugin pages (`plugins-page`, `plugin-catalog`, -// `plugin-detail`). Kept separate from `connector-common.js` on purpose: the -// plugin model (JSON-Schema config blobs, `plugin_access`) is not the -// connector model (env/api_key manifests). +// Shared helpers for the plugin pages (`plugin-catalog`, `plugin-detail`). +// Kept separate from `connector-common.js` on purpose: the plugin model +// (JSON-Schema config blobs, `plugin_access`) is not the connector model +// (env/api_key manifests). export async function jf(url, opts) { const res = await fetch(url, opts); diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 7e49f70..e3f3552 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -31,7 +31,6 @@ const NAV = [ // Estensioni — what the assistant is made of / can use. Visible to everyone; // Agents is read-only for non-admins (editable only by the admin server-side). { id: 'connectors', group: 'extensions', priority: 10, icon: 'plug', labelKey: 'nav.connectors', aliases: ['connector', 'marketplace'] }, - { id: 'plugins', group: 'extensions', priority: 20, icon: 'puzzle', labelKey: 'nav.plugins' }, { id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' }, // The background agents the instance runs for you. Visible to everyone: the // run log is the caller's own, so there is nothing here to gate on a role. @@ -44,7 +43,7 @@ const NAV = [ { id: 'models', group: 'config', priority: 30, icon: 'cpu', labelKey: 'nav.models', adminOnly: true }, { id: 'providers', group: 'config', priority: 40, icon: 'plug', labelKey: 'nav.providers', adminOnly: true }, { id: 'approval', group: 'config', priority: 50, icon: 'shield-check', labelKey: 'nav.security', adminOnly: true }, - { id: 'plugin-catalog', group: 'config', priority: 70, icon: 'puzzle-fill', labelKey: 'nav.plugin_catalog', adminOnly: true, aliases: ['plugin-detail'] }, + { id: 'plugins', group: 'config', priority: 70, icon: 'puzzle-fill', labelKey: 'nav.plugins', adminOnly: true, aliases: ['plugin-catalog', 'plugin-detail'] }, { id: 'config', group: 'config', priority: 90, icon: 'gear', labelKey: 'nav.config', adminOnly: true }, // Sviluppo — debug surface, only with the debug flag on. @@ -228,7 +227,9 @@ export class AppSidebar extends I18nMixin(LightElement) { return m ? `plugin/${m[1]}/${m[2]}` : 'home'; } // `connector` (singular) is the per-connector detail page, `connectors` the list. - return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(segment) ? segment : 'home'; + // `plugin-catalog` is the pre-merge hash of what is now `#plugins`. + const page = segment === 'plugin-catalog' ? 'plugins' : segment; + return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(page) ? page : 'home'; } _tasksSectionFromHash() { diff --git a/web/css/page-shell.css b/web/css/page-shell.css index 15460b6..8f64770 100644 --- a/web/css/page-shell.css +++ b/web/css/page-shell.css @@ -77,7 +77,6 @@ roles-page, shared-folders-page, connectors-page, connector-detail-page, -plugins-page, plugin-catalog-page, plugin-detail-page, plugin-page-host, diff --git a/web/i18n/en.js b/web/i18n/en.js index 01b158e..eddf49f 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -21,7 +21,6 @@ export default { 'nav.roles': 'Roles', 'nav.connectors': 'Connectors', 'nav.plugins': 'Plugins', - 'nav.plugin_catalog': 'Plugin Catalog', 'nav.config': 'Settings', 'nav.llm_requests': 'LLM Requests', 'nav.system_agents': 'System agents', @@ -874,39 +873,25 @@ export default { 'connectors.error.no_connector': 'No connector named "{name}" is available to you.', // ── Plugins ───────────────────────────────────────────────────────────────── - 'plugins.title': 'Plugins', 'plugins.loading': 'Loading…', - 'plugins.section.mine': 'My plugins', - 'plugins.section.manage': 'Manage plugins', - 'plugins.empty.mine': 'No plugins available to you yet.', - 'plugins.empty.ask_admin': 'Ask an admin to grant you access.', 'plugins.empty.manage': 'No plugins registered.', - 'plugins.status.active': 'active', - 'plugins.status.running': 'running', - 'plugins.status.enabled': 'enabled', - 'plugins.status.off': 'off', 'plugins.enabled': 'Enabled', - 'plugins.save': 'Save', 'plugins.save_config': 'Save config', 'plugins.saved': 'Saved.', 'plugin_page.loading': 'Loading…', 'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).', - 'plugins.yes': 'yes', - 'plugins.no': 'no', - 'plugins.badge.user_config': 'per-user settings', - 'plugins.access.btn': 'User access', + 'plugins.badge.user_page': 'user page', 'plugins.access.desc': 'Tick a box to let that person see and configure this plugin. Saving replaces the whole list.', 'plugins.access.empty': 'No users.', 'plugins.access.save': 'Save access', 'plugins.error.required': '"{field}" is required.', - 'plugins.catalog.title': 'Plugin Catalog', 'plugins.catalog.configure': 'Configure', 'plugins.health.ok': 'active', 'plugins.health.off': 'off', 'plugins.health.needs_config': 'needs configuration', 'plugins.health.not_running': 'not running', 'plugins.badge.instance_config': 'instance settings', - 'plugins.detail.back': 'Back to catalog', + 'plugins.detail.back': 'Back to plugins', 'plugins.detail.config.title': 'Instance configuration', 'plugins.detail.config.empty': 'This plugin has no instance settings.', 'plugins.detail.config.custom_page': 'This plugin has its own configuration page.', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 3e9d8e5..e82f3b9 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -21,7 +21,6 @@ export default { 'nav.roles': 'Rôles', 'nav.connectors': 'Connecteurs', 'nav.plugins': 'Plugins', - 'nav.plugin_catalog': 'Catalogue des plugins', 'nav.config': 'Paramètres', 'nav.llm_requests': 'Requêtes LLM', 'nav.system_agents': 'Agents système', @@ -864,39 +863,25 @@ export default { 'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.', // ── Plugins ───────────────────────────────────────────────────────────────── - 'plugins.title': 'Plugins', 'plugins.loading': 'Chargement…', - 'plugins.section.mine': 'Mes plugins', - 'plugins.section.manage': 'Gérer les plugins', - 'plugins.empty.mine': 'Aucun plugin disponible pour vous.', - 'plugins.empty.ask_admin': "Demandez à un administrateur de vous accorder l'accès.", 'plugins.empty.manage': 'Aucun plugin enregistré.', - 'plugins.status.active': 'actif', - 'plugins.status.running': 'en cours', - 'plugins.status.enabled': 'activé', - 'plugins.status.off': 'arrêté', 'plugins.enabled': 'Activé', - 'plugins.save': 'Enregistrer', 'plugins.save_config': 'Enregistrer la config', 'plugins.saved': 'Enregistré.', 'plugin_page.loading': 'Chargement…', 'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).', - 'plugins.yes': 'oui', - 'plugins.no': 'non', - 'plugins.badge.user_config': 'réglages par utilisateur', - 'plugins.access.btn': 'Accès utilisateurs', + 'plugins.badge.user_page': 'page utilisateur', 'plugins.access.desc': "Cochez qui peut voir et configurer ce plugin. L'enregistrement remplace toute la liste.", 'plugins.access.empty': 'Aucun utilisateur.', 'plugins.access.save': 'Enregistrer les accès', 'plugins.error.required': '« {field} » est requis.', - 'plugins.catalog.title': 'Catalogue des plugins', 'plugins.catalog.configure': 'Configurer', 'plugins.health.ok': 'actif', 'plugins.health.off': 'arrêté', 'plugins.health.needs_config': 'à configurer', 'plugins.health.not_running': 'non démarré', 'plugins.badge.instance_config': 'réglages d’instance', - 'plugins.detail.back': 'Retour au catalogue', + 'plugins.detail.back': 'Retour aux plugins', 'plugins.detail.config.title': 'Configuration de l’instance', 'plugins.detail.config.empty': 'Ce plugin n’a aucun réglage d’instance.', 'plugins.detail.config.custom_page': 'Ce plugin possède sa propre page de configuration.', diff --git a/web/i18n/it.js b/web/i18n/it.js index c8e0ea6..cddb7a0 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -21,7 +21,6 @@ export default { 'nav.roles': 'Ruoli', 'nav.connectors': 'Connettori', 'nav.plugins': 'Plugin', - 'nav.plugin_catalog': 'Catalogo plugin', 'nav.config': 'Impostazioni', 'nav.llm_requests': 'Richieste LLM', 'nav.system_agents': 'Agenti di sistema', @@ -864,39 +863,25 @@ export default { 'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.', // ── Plugin ────────────────────────────────────────────────────────────────── - 'plugins.title': 'Plugin', 'plugins.loading': 'Caricamento…', - 'plugins.section.mine': 'I miei plugin', - 'plugins.section.manage': 'Gestione plugin', - 'plugins.empty.mine': 'Nessun plugin disponibile per te.', - 'plugins.empty.ask_admin': "Chiedi a un amministratore di concederti l'accesso.", 'plugins.empty.manage': 'Nessun plugin registrato.', - 'plugins.status.active': 'attivo', - 'plugins.status.running': 'in esecuzione', - 'plugins.status.enabled': 'abilitato', - 'plugins.status.off': 'spento', 'plugins.enabled': 'Abilitato', - 'plugins.save': 'Salva', 'plugins.save_config': 'Salva configurazione', 'plugins.saved': 'Salvato.', 'plugin_page.loading': 'Caricamento…', 'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).', - 'plugins.yes': 'sì', - 'plugins.no': 'no', - 'plugins.badge.user_config': 'impostazioni per utente', - 'plugins.access.btn': 'Accesso utenti', + 'plugins.badge.user_page': 'pagina utente', 'plugins.access.desc': "Seleziona chi può vedere e configurare questo plugin. Il salvataggio sostituisce l'intera lista.", 'plugins.access.empty': 'Nessun utente.', 'plugins.access.save': 'Salva accesso', 'plugins.error.required': '"{field}" è obbligatorio.', - 'plugins.catalog.title': 'Catalogo plugin', 'plugins.catalog.configure': 'Configura', 'plugins.health.ok': 'attivo', 'plugins.health.off': 'spento', 'plugins.health.needs_config': 'da configurare', 'plugins.health.not_running': 'non in esecuzione', 'plugins.badge.instance_config': 'impostazioni istanza', - 'plugins.detail.back': 'Torna al catalogo', + 'plugins.detail.back': 'Torna ai plugin', 'plugins.detail.config.title': 'Configurazione istanza', 'plugins.detail.config.empty': 'Questo plugin non ha impostazioni di istanza.', 'plugins.detail.config.custom_page': 'Questo plugin ha una propria pagina di configurazione.', diff --git a/web/index.html b/web/index.html index f506cf6..e98f8be 100644 --- a/web/index.html +++ b/web/index.html @@ -96,7 +96,6 @@ -