Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
32 changed files with 338 additions and 371 deletions
Showing only changes of commit 4b1affa600 - Show all commits
+2 -3
View File
@@ -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<Self>)` — 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 names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc<Self>)` — 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). - **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=<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=<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/<id>/`**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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin/<id>/`**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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` 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-rules-page>` | Approval rule management | | `approval-rules.js` | `<approval-rules-page>` | Approval rule management |
| `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management | | `cron-jobs.js` | `<cron-jobs-page>` | Scheduled job management |
| `connectors.js` | `<connectors-page>` | 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) | | `connectors.js` | `<connectors-page>` | 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-page>` | `#plugins`user half: granted plugins + schema-driven per-user config form | | `plugin-catalog.js` | `<plugin-catalog>` | `#plugins`admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) |
| `plugin-catalog.js` | `<plugin-catalog>` | `#plugin-catalog` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) |
| `plugin-detail.js` | `<plugin-detail>` | `#plugin-detail?id=<id>` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | | `plugin-detail.js` | `<plugin-detail>` | `#plugin-detail?id=<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` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `plugin-page-host.js` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` |
| `system-agents.js` | `<system-agents-page>` | `#system-agents` — the caller's own run history for the background system agents (TIC): agent, start, status, duration, counters; row → the run's session | | `system-agents.js` | `<system-agents-page>` | `#system-agents` — the caller's own run history for the background system agents (TIC): agent, start, status, duration, counters; row → the run's session |
Generated
+1
View File
@@ -3010,6 +3010,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum",
"chrono", "chrono",
"core-api", "core-api",
"rand 0.10.1", "rand 0.10.1",
+10 -12
View File
@@ -120,16 +120,14 @@ pub trait Plugin: Send + Sync {
/// JSON Schema describing the plugin's config fields. /// JSON Schema describing the plugin's config fields.
fn config_schema(&self) -> Value { serde_json::json!({}) } fn config_schema(&self) -> Value { serde_json::json!({}) }
/// JSON Schema describing the plugin's *per-user* config fields (e.g. /// Applies a per-user config submission, received through the core
/// Telegram's pairing code). Empty schema (the default) = the plugin has /// `PUT /api/plugins/{id}/my-config` endpoint from the plugin's own
/// no per-user settings and does not appear as configurable in the user /// [`Plugin::web_pages`] fragment (e.g. Telegram's pairing page, Honcho's
/// UI. Values are stored admin-readable in `system.db` — never secrets. /// opt-in page). The default just stores the blob in the generic store;
fn user_config_schema(&self) -> Value { serde_json::json!({}) } /// 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
/// Applies a per-user config submission. The default just stores the blob /// status blob for the UI via `ctx.user_config`. Values are stored
/// in the generic store; plugins that need validation or a side effect /// admin-readable in `system.db` — never secrets.
/// (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`.
async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> { async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> {
ctx.user_config.set(self.id(), user_id, config).await 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. /// pairing lifecycle rather than the generic `plugin_access` grants — e.g.
/// the mobile connector, whose access is the device→user binding (§13). /// the mobile connector, whose access is the device→user binding (§13).
/// When `true`, the admin Plugins UI suppresses the "User access" /// When `true`, the admin Plugins UI suppresses the "User access"
/// checklist (it would control nothing), the plugin never appears in a /// checklist (it would control nothing), the plugin is left out of
/// user's "My plugins" view, and its non-`admin_only` `web_pages()` are /// `GET /api/plugins/mine`, and its non-`admin_only` `web_pages()` are
/// visible to every logged-in user — the page itself scopes what each /// visible to every logged-in user — the page itself scopes what each
/// caller sees (e.g. admin sees all devices, others only their own). /// caller sees (e.g. admin sees all devices, others only their own).
/// Default `false`: access is the admin's per-user `plugin_access` grant /// Default `false`: access is the admin's per-user `plugin_access` grant
+1 -1
View File
@@ -6,7 +6,7 @@ use serde_json::Value;
/// `system.db`). /// `system.db`).
/// ///
/// Values are deliberately admin-readable — the table lives in the registry /// 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. /// that needs per-user secrets should keep them elsewhere.
#[async_trait] #[async_trait]
pub trait PluginUserConfigApi: Send + Sync { pub trait PluginUserConfigApi: Send + Sync {
-21
View File
@@ -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`): /// Two dedicated pages served from this plugin's own router (`web/*.js`):
/// an **admin** config page (connection + a connectivity test) and a /// an **admin** config page (connection + a connectivity test) and a
/// **user** opt-in page (the per-user consent to long-term memory). The /// **user** opt-in page (the per-user consent to long-term memory). The
+1
View File
@@ -7,6 +7,7 @@ edition = "2024"
core-api = { path = "../core-api" } core-api = { path = "../core-api" }
anyhow = "1" anyhow = "1"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+36 -18
View File
@@ -12,11 +12,13 @@
/// # Pairing /// # Pairing
/// ///
/// Unknown chats receive a pairing code. The user links their own account by /// 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 /// pasting the code in the plugin's own Telegram page in the web app's sidebar
/// `user_config_schema` / `update_user_config` hook); the admin's agent can /// (served as a `web_pages()` fragment, saved through the core
/// also bind a chat via the `telegram_pairing` tool (category `Config`). The /// `PUT /api/plugins/telegram/my-config` endpoint into the
/// binding is written to the config table; the resulting `ConfigKeyUpdated` /// `update_user_config` hook); the admin's agent can also bind a chat via the
/// event reloads the in-memory cache instantly. /// `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 /// # Human-in-the-loop approvals
/// ///
@@ -42,7 +44,7 @@ use tracing::{info, warn};
use core_api::command::CommandApi; use core_api::command::CommandApi;
use core_api::config_api::ConfigApi; use core_api::config_api::ConfigApi;
use core_api::location::LocationUpdater; 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::transcribe::TranscribeProvider;
use core_api::tts::TtsProvider; use core_api::tts::TtsProvider;
use core_api::user_channel::UserChannelApi; use core_api::user_channel::UserChannelApi;
@@ -197,18 +199,34 @@ impl Plugin for TelegramPlugin {
}) })
} }
fn user_config_schema(&self) -> Value { /// The user-facing pairing page (`#plugin/telegram/telegram`), served as a
json!({ /// fragment from this plugin's own router. Visible to any user with a
"type": "object", /// `plugin_access` grant — the correct audience for self-service pairing.
"properties": { fn web_pages(&self) -> Vec<PluginPage> {
"pairing_code": { vec![PluginPage {
"type": "string", page_id: "telegram",
"title": "Pairing code", title: "Telegram".into(),
"description": "Send any message to the bot — it replies with a 6-character code. Paste it here to link your Telegram chat." icon: "telegram",
} entry: "web/telegram.js".into(),
}, admin_only: false,
"required": ["pairing_code"] // Sidebar priority: core "Your space" items live in 1090, mobile
}) // connector took 100, honcho 120130 — 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<axum::Router> {
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, /// Self-service pairing: the user pastes the code the bot replied with,
+57
View File
@@ -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 lassistente 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 allamministratore di darti laccesso.',
},
fr: {
[`${P}.title`]: 'Telegram',
[`${P}.intro`]: 'Discutez avec lassistant depuis Telegram en reliant votre conversation Telegram à votre compte.',
[`${P}.status.linked`]: 'Votre conversation Telegram est reliée.',
[`${P}.status.unlinked`]: 'Votre conversation Telegram nest pas encore reliée.',
[`${P}.status.chat_id`]: 'ID de conversation',
[`${P}.howto_title`]: 'Comment la relier',
[`${P}.howto_body`]: 'Envoyez nimporte quel message au bot — il répond avec un code à 6 caractères. Collez le code ici.',
[`${P}.code_label`]: 'Code dappairage',
[`${P}.save`]: 'Relier',
[`${P}.saved`]: 'Reliée !',
[`${P}.relink_hint`]: 'Coller un nouveau code remplace le lien actuel.',
[`${P}.loading`]: 'Chargement…',
[`${P}.unavailable`]: 'Telegram nest pas encore disponible pour vous. Demandez laccès à votre administrateur.',
},
};
+159
View File
@@ -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`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-telegram me-2"></i>${t(`${P}.title`)}</h2>
</div>
<div style="padding:0 1.25rem 2rem; max-width:640px; overflow:auto">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.loading`)}</div>`
: this._row ? this._renderBody() : this._renderUnavailable()}
</div>
</div>`;
}
_renderUnavailable() {
return html`
<div class="um-empty" style="padding:1rem">
<i class="bi bi-shield-lock"></i>
<p>${t(`${P}.unavailable`)}</p>
</div>`;
}
_renderBody() {
const linked = !!this._row?.user_config?.linked;
const chatId = this._row?.user_config?.chat_id;
return html`
<p class="text-body-secondary" style="font-size:.9rem">${t(`${P}.intro`)}</p>
<div class="connector-card" style="cursor:default">
<div class="connector-card-head">
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-telegram"></i></div>
<div class="connector-card-title">
<div class="connector-card-name" style="font-size:.9rem">
${linked ? t(`${P}.status.linked`) : t(`${P}.status.unlinked`)}
</div>
${linked && chatId != null ? html`
<div class="connector-card-sub">${t(`${P}.status.chat_id`)}: ${chatId}</div>` : nothing}
</div>
<span class="connector-chip ${linked ? 'connector-chip--ok' : ''}">
${linked ? html`<i class="bi bi-check-lg"></i>` : html`<i class="bi bi-dash-lg"></i>`}
</span>
</div>
</div>
<div class="mt-4" style="font-size:.85rem; font-weight:600">
<i class="bi bi-link-45deg me-1"></i>${t(`${P}.howto_title`)}
</div>
<p class="text-body-secondary" style="font-size:.82rem">${t(`${P}.howto_body`)}</p>
<div class="mb-3" style="max-width:280px">
<label class="form-label" style="font-size:.8rem">${t(`${P}.code_label`)}</label>
<input class="form-control form-control-sm" type="text" .value=${this._code}
@input=${(e) => { this._code = e.target.value; this._status = {}; }} />
</div>
${this._status.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._status.err}</div>` : nothing}
${this._status.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${this._status.ok}</div>` : nothing}
<button class="btn btn-primary btn-sm" ?disabled=${!this._code.trim()} @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${t(`${P}.save`)}
</button>
${linked ? html`
<div class="text-body-secondary mt-2" style="font-size:.75rem">${t(`${P}.relink_hint`)}</div>` : nothing}
`;
}
}
@@ -1,10 +1,11 @@
//! Per-user plugin configuration blobs (`plugin_user_configs` table). //! Per-user plugin configuration blobs (`plugin_user_configs` table).
//! //!
//! Registry table in `system.db` — **admin-readable, never secrets**. A plugin //! Registry table in `system.db` — **admin-readable, never secrets**. A plugin
//! with a non-empty `user_config_schema()` lets each granted user submit their //! with per-user settings surfaces them in its own `web_pages()` fragment
//! own settings from the UI (e.g. Telegram's pairing code); the plugin's //! (e.g. Telegram's pairing page); the submission travels through the core
//! `update_user_config` hook validates and stores here. `plugin_id` is a bare //! `PUT /api/plugins/{id}/my-config` endpoint into the plugin's
//! TEXT for the same reason as `plugin_access`. //! `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 anyhow::Result;
use serde_json::Value; use serde_json::Value;
+23 -21
View File
@@ -34,7 +34,6 @@ pub struct PluginInfo {
pub running: bool, pub running: bool,
pub config: Value, pub config: Value,
pub config_schema: Value, pub config_schema: Value,
pub user_config_schema: Value,
/// Whether the plugin contributes an `http_router()` — its routes are /// Whether the plugin contributes an `http_router()` — its routes are
/// mounted at boot and gated at runtime, so they serve as soon as the /// mounted at boot and gated at runtime, so they serve as soon as the
/// plugin is enabled (no restart). /// plugin is enabled (no restart).
@@ -42,6 +41,10 @@ pub struct PluginInfo {
/// Whether the plugin gates access through its own binding lifecycle — the /// Whether the plugin gates access through its own binding lifecycle — the
/// admin UI hides the "User access" checklist when true (see the trait). /// admin UI hides the "User access" checklist when true (see the trait).
pub manages_own_access: bool, 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 /// Whether the plugin-detail page shows the generic `config_schema` form
/// (`false` = the plugin hosts its own config UI in one of its pages). /// (`false` = the plugin hosts its own config UI in one of its pages).
pub config_in_detail_page: bool, 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`. /// 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)] #[derive(Debug, Clone, Serialize)]
pub struct UserPluginView { pub struct UserPluginView {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub user_config_schema: Value, pub user_config: Value,
pub user_config: Value,
} }
/// A plugin-contributed web page as seen by one user — served by /// A plugin-contributed web page as seen by one user — served by
@@ -404,9 +408,9 @@ impl PluginManager {
running: plugin.is_running(), running: plugin.is_running(),
config: serde_json::from_str(&config_json).unwrap_or(json!({})), config: serde_json::from_str(&config_json).unwrap_or(json!({})),
config_schema: plugin.config_schema(), config_schema: plugin.config_schema(),
user_config_schema: plugin.user_config_schema(),
has_router: plugin.http_router().is_some(), has_router: plugin.http_router().is_some(),
manages_own_access: plugin.manages_own_access(), 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(), config_in_detail_page: plugin.config_in_detail_page(),
runtime_status: plugin.runtime_status(), runtime_status: plugin.runtime_status(),
}); });
@@ -423,9 +427,10 @@ impl PluginManager {
// ── Per-user access & configuration ─────────────────────────────────────── // ── 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 /// `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<Vec<UserPluginView>> { pub async fn list_accessible(&self, user_id: &str, is_admin: bool) -> Result<Vec<UserPluginView>> {
let granted: std::collections::HashSet<String> = if is_admin { let granted: std::collections::HashSet<String> = if is_admin {
std::collections::HashSet::new() std::collections::HashSet::new()
@@ -434,8 +439,8 @@ impl PluginManager {
}; };
let mut out = Vec::new(); let mut out = Vec::new();
for plugin in &self.plugins { for plugin in &self.plugins {
// Binding-managed plugins (e.g. mobile-connector) aren't configured // Binding-managed plugins (e.g. mobile-connector) own their access
// from the "My plugins" view — they own their own pairing UI. // model — there is no per-user config blob of ours to show them.
if plugin.manages_own_access() { if plugin.manages_own_access() {
continue; continue;
} }
@@ -449,10 +454,9 @@ impl PluginManager {
.await? .await?
.unwrap_or(json!({})); .unwrap_or(json!({}));
out.push(UserPluginView { out.push(UserPluginView {
id: plugin.id().to_string(), id: plugin.id().to_string(),
name: plugin.name().to_string(), name: plugin.name().to_string(),
description: plugin.description().to_string(), description: plugin.description().to_string(),
user_config_schema: plugin.user_config_schema(),
user_config, user_config,
}); });
} }
@@ -529,17 +533,15 @@ impl PluginManager {
plugin_access::set_access(&self.db, id, user_ids).await plugin_access::set_access(&self.db, id, user_ids).await
} }
/// Applies a user's per-plugin config submission. The plugin must be /// Applies a user's per-plugin config submission, received from the
/// enabled, declare a non-empty `user_config_schema`, and the caller must /// plugin's own page fragment. The plugin must be enabled and the caller
/// hold access (enforced by the API layer). /// 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<()> { pub async fn update_user_config(&self, id: &str, user_id: &str, config: Value) -> Result<()> {
let plugin = self.find(id)?; let plugin = self.find(id)?;
if !self.is_enabled(id).await? { if !self.is_enabled(id).await? {
anyhow::bail!("plugin is not enabled: {id}"); 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()?; let skald = self.skald()?;
plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await plugin.update_user_config(user_id, config, &self.build_context(&skald)?).await
} }
+2 -2
View File
@@ -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: 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). - 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. - A plugin can add tools the assistant calls directly (e.g. `set_secret`, `telegram_pairing`), a dedicated sidebar page, or both.
+1 -1
View File
@@ -17,7 +17,7 @@ The plugin polls the ComfyUI server every 5 seconds. If it's offline, every mode
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Plugin catalog**ComfyUI** → enable, then **Configure**. 1. Plugins page**ComfyUI** → enable, then **Configure**.
2. Fields: 2. Fields:
- **`base_url`** (default `http://localhost:8188`) — where ComfyUI's API is listening. - **`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. - **`workflows_dir`** (default `data/comfyui/workflows`) — folder to watch for `.json` workflow files. Created automatically if missing.
+1 -1
View File
@@ -16,7 +16,7 @@ Enabling the plugin does not by itself add any voice or transcription model —
## Enabling & configuring (admin) ## 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). 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. 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.
+1 -1
View File
@@ -17,7 +17,7 @@ Streams a user's completed chat turns to an external [Honcho](https://honcho.dev
## Enabling & configuring (admin) ## 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: 2. Fields:
- **`base_url`** (default `http://localhost:8000`) — the Honcho server's URL. - **`base_url`** (default `http://localhost:8000`) — the Honcho server's URL.
- **`api_key`** — optional, only if the server requires auth. - **`api_key`** — optional, only if the server requires auth.
+1 -1
View File
@@ -15,7 +15,7 @@ Lightweight, fast local text-to-speech using the Kokoro ONNX model. Runs on CPU
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Plugin catalog**Kokoro TTS** → enable, then **Configure**. 1. Plugins page**Kokoro TTS** → enable, then **Configure**.
2. Fields: 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_*`). - **`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`. - **`lang`** (default `it`) — language code for phonemisation: `it`, `en-us`, `en-gb`, `ja`, `zh`, `es`, `fr`, `hi`, `pt-br`, `ko`.
+1 -1
View File
@@ -19,7 +19,7 @@ The model is gated on HuggingFace, so it requires a personal access token before
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Get a HuggingFace token and store it as the secret `HUGGINGFACE_TOKEN` (see above). 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: 3. Fields:
- **`quantization`** (`none` | `int8` | `int4`, default `int8`) — lower precision uses less VRAM at some quality cost. - **`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`). - **`voice`** (`tara` | `dan` | `leah` | `zac` | `zoe` | `mia` | `julia` | `leo`, default `tara`).
+1 -1
View File
@@ -19,7 +19,7 @@ Depends on which provider is chosen:
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Plugin catalog**Remote Connectivity** → enable, then **Configure**. 1. Plugins page**Remote Connectivity** → enable, then **Configure**.
2. Fields: 2. Fields:
- **`provider`** (`tailscale_sys` | `tailscale`, default `tailscale_sys`) — see requirements above. - **`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. - **`auth_key`** — only for the embedded `tailscale` provider; a Tailscale auth key (`tskey-auth-…`), needed on first join.
+2 -2
View File
@@ -16,7 +16,7 @@ One bot serves everyone on the instance; each person pairs their **own** Telegra
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Plugin catalog**Telegram Bot** → enable, then **Configure**. 1. Plugins page**Telegram Bot** → enable, then **Configure**.
2. Field: 2. Field:
- **`token`** (required) — the bot token from BotFather. Stored as a secret field, not shown again after saving. - **`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. 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. 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.) 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.)
+1 -1
View File
@@ -23,7 +23,7 @@ The model (roughly 13 GB depending on size) is loaded into memory only when f
## Enabling & configuring (admin) ## Enabling & configuring (admin)
1. Download a model file first (see above) and note its path. 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: 3. Fields:
- **`model`** (required) — path to the `.bin` file, e.g. `models/ggml-large-v3.bin`. - **`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`). - **`language`** — a BCP-47 code (`it`, `en`, …) or `auto` for automatic detection (default `auto`).
+3 -3
View File
@@ -3,9 +3,9 @@
//! Two audiences, mirroring the Connectors split: //! Two audiences, mirroring the Connectors split:
//! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide //! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide
//! config, and the per-user access grants (`plugin_access`). //! config, and the per-user access grants (`plugin_access`).
//! - **Any user**: sees the plugins granted to them (`/plugins/mine`) and //! - **Any user**: sees the plugins granted to them (`/plugins/mine`, read by
//! edits their own per-user config when the plugin declares a //! the plugins' own page fragments) and submits their own per-user config
//! `user_config_schema` (e.g. Telegram's pairing code). //! (`/{id}/my-config` — e.g. Telegram's pairing code from its sidebar page).
use axum::{ use axum::{
extract::{Extension, Path, State}, extract::{Extension, Path, State},
-2
View File
@@ -14,7 +14,6 @@ import { RolesPage } from './components/roles-page.js';
import { SharedFoldersPage } from './components/shared-folders.js'; import { SharedFoldersPage } from './components/shared-folders.js';
import { ConnectorsPage } from './components/connectors.js'; import { ConnectorsPage } from './components/connectors.js';
import { ConnectorDetailPage } from './components/connector-detail.js'; import { ConnectorDetailPage } from './components/connector-detail.js';
import { PluginsPage } from './components/plugins-page.js';
import { PluginPageHost } from './components/plugin-page-host.js'; import { PluginPageHost } from './components/plugin-page-host.js';
import { PluginCatalogPage } from './components/plugin-catalog.js'; import { PluginCatalogPage } from './components/plugin-catalog.js';
import { PluginDetailPage } from './components/plugin-detail.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('shared-folders-page', SharedFoldersPage);
customElements.define('connectors-page', ConnectorsPage); customElements.define('connectors-page', ConnectorsPage);
customElements.define('connector-detail-page', ConnectorDetailPage); customElements.define('connector-detail-page', ConnectorDetailPage);
customElements.define('plugins-page', PluginsPage);
customElements.define('plugin-page-host', PluginPageHost); customElements.define('plugin-page-host', PluginPageHost);
customElements.define('plugin-catalog-page', PluginCatalogPage); customElements.define('plugin-catalog-page', PluginCatalogPage);
customElements.define('plugin-detail-page', PluginDetailPage); customElements.define('plugin-detail-page', PluginDetailPage);
+8 -6
View File
@@ -3,8 +3,10 @@ import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
import { jf, hasSchema, pluginHealth } from './shared/plugin-common.js'; import { jf, hasSchema, pluginHealth } from './shared/plugin-common.js';
// Plugin catalog (`#plugin-catalog`) — the admin board of every registered // Plugins page (`#plugins`) — the admin board of every registered plugin,
// 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 = // One card per plugin: an enable/disable toggle, a health dot (green =
// enabled, running and fully configured; red = enabled but broken; grey = // 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`). // Styling reuses the connectors card grid (`web/css/connectors.css`).
const PAGE_ID = 'plugin-catalog'; const PAGE_ID = 'plugins';
export class PluginCatalogPage extends LightElement { export class PluginCatalogPage extends LightElement {
@@ -94,7 +96,7 @@ export class PluginCatalogPage extends LightElement {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-puzzle-fill me-2"></i>${t('plugins.catalog.title')}</h2> <h2 class="um-title"><i class="bi bi-puzzle-fill me-2"></i>${t('nav.plugins')}</h2>
</div> </div>
${this._error ? html` ${this._error ? html`
@@ -138,8 +140,8 @@ export class PluginCatalogPage extends LightElement {
<div class="connector-chips"> <div class="connector-chips">
${hasSchema(p.config_schema) ? html` ${hasSchema(p.config_schema) ? html`
<span class="connector-chip"><i class="bi bi-sliders"></i>${t('plugins.badge.instance_config')}</span>` : nothing} <span class="connector-chip"><i class="bi bi-sliders"></i>${t('plugins.badge.instance_config')}</span>` : nothing}
${hasSchema(p.user_config_schema) ? html` ${p.has_user_page ? html`
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing} <span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_page')}</span>` : nothing}
</div> </div>
<div class="d-flex align-items-center justify-content-between mt-1"> <div class="d-flex align-items-center justify-content-between mt-1">
+7 -7
View File
@@ -1,10 +1,10 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { 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=<plugin id>`), reached from the // One plugin's admin page (`#plugin-detail?id=<plugin id>`), reached from the
// Configure button on `#plugin-catalog` — the plugin counterpart of // Configure button on `#plugins` — the plugin counterpart of
// `connector-detail.js`. // `connector-detail.js`.
// //
// Hosts what was squeezed into the old combined page: the instance-wide // Hosts what was squeezed into the old combined page: the instance-wide
@@ -128,10 +128,10 @@ export class PluginDetailPage extends LightElement {
_back() { _back() {
// Prefer real history so the browser's own Back stays consistent; fall 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; } if (history.length > 1) { history.back(); return; }
history.pushState({ page: 'plugin-catalog' }, '', '#plugin-catalog'); history.pushState({ page: 'plugins' }, '', '#plugins');
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugin-catalog' } })); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugins' } }));
} }
_setDraft(key, value) { _setDraft(key, value) {
@@ -243,8 +243,8 @@ export class PluginDetailPage extends LightElement {
</div> </div>
${p.description ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${p.description}</div>` : nothing} ${p.description ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${p.description}</div>` : nothing}
<div class="connector-chips"> <div class="connector-chips">
${hasSchema(p.user_config_schema) ? html` ${p.has_user_page ? html`
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing} <span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_page')}</span>` : nothing}
</div> </div>
<div class="form-check form-switch mt-1 mb-0"> <div class="form-check form-switch mt-1 mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="plugin-detail-on" <input class="form-check-input" type="checkbox" role="switch" id="plugin-detail-on"
-202
View File
@@ -1,202 +0,0 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { jf, schemaFields } from './shared/plugin-common.js';
// Plugins page (`#plugins`) — the user-facing half of the plugin split.
//
// Shows the plugins the caller has been granted (`plugin_access`, admin-granted).
// When a plugin declares a `user_config_schema` the card carries a small
// schema-driven form — e.g. Telegram's pairing code — saved via
// `PUT /api/plugins/{id}/my-config`.
//
// The admin half (enable/disable, instance config, access grants) lives on
// `#plugin-catalog` + `#plugin-detail` — see `plugin-catalog.js`.
//
// Styling reuses the connectors card grid (`web/css/connectors.css`).
export class PluginsPage extends LightElement {
static get properties() {
return {
_open: { state: true },
_mine: { state: true }, // UserPluginView[] — granted + enabled plugins
_error: { state: true },
_uDrafts: { state: true }, // user config drafts: { [pluginId]: {key: value} }
_uStatus: { state: true }, // { [pluginId]: { ok?: string, err?: string } }
};
}
constructor() {
super();
this._open = false;
this._reset();
}
_reset() {
this._mine = null;
this._error = null;
this._uDrafts = {};
this._uStatus = {};
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => 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`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-puzzle me-2"></i>${t('plugins.title')}</h2>
</div>
${this._error ? html`
<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> ${t('plugins.loading')}</div>`
: html`
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
${this._renderMine()}
</div>`}
</div>`;
}
_renderMine() {
const rows = this._mine ?? [];
if (rows.length === 0) {
return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-puzzle"></i>
<p>${t('plugins.empty.mine')}</p>
<p style="font-size:.8rem;opacity:.7">${t('plugins.empty.ask_admin')}</p>
</div>`;
}
return html`<div class="connector-grid">${rows.map(p => this._renderUserCard(p))}</div>`;
}
/// 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`
<div class="d-flex flex-column gap-1 mb-2" style="font-size:.78rem">
${extra.map(([k, v]) => html`
<div class="d-flex justify-content-between">
<span class="text-muted">${k}</span>
<span>${typeof v === 'boolean' ? (v ? t('plugins.yes') : t('plugins.no')) : String(v)}</span>
</div>`)}
</div>`;
}
_renderUserCard(p) {
const fields = schemaFields(p.user_config_schema);
const status = this._uStatus[p.id] || {};
const draft = fields.length ? this._uDraft(p) : {};
return html`
<div class="connector-card" style="cursor:default">
<div class="connector-card-head">
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-puzzle"></i></div>
<div class="connector-card-title">
<div class="connector-card-name">${p.name}</div>
<div class="connector-card-sub">${p.id}</div>
</div>
<span class="connector-chip connector-chip--ok">${t('plugins.status.active')}</span>
</div>
${p.description ? html`<div class="connector-card-desc">${p.description}</div>` : nothing}
${this._renderUserStatus(p)}
${fields.length ? html`
<div class="mt-2">
${fields.map(f => html`
<div class="mb-2">
<label class="form-label" style="font-size:.8rem">${f.label}${f.required ? html`<span class="text-danger">*</span>` : nothing}</label>
${f.type === 'boolean' ? html`
<div class="form-check">
<input class="form-check-input" type="checkbox" .checked=${!!draft[f.key]}
@change=${(e) => this._setUDraft(p.id, f.key, e.target.checked)} />
</div>` : html`
<input class="form-control form-control-sm"
type=${f.sensitive ? 'password' : (f.type === 'number' ? 'number' : 'text')}
.value=${String(draft[f.key] ?? '')}
@input=${(e) => this._setUDraft(p.id, f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`}
${f.description ? html`<div class="form-text" style="font-size:.7rem">${f.description}</div>` : nothing}
</div>`)}
${status.err ? html`<div class="alert alert-danger py-1 px-2" style="font-size:.78rem">${status.err}</div>` : nothing}
${status.ok ? html`<div class="alert alert-success py-1 px-2" style="font-size:.78rem">${status.ok}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._saveUserConfig(p)}>
<i class="bi bi-check-lg me-1"></i>${t('plugins.save')}
</button>
</div>` : nothing}
</div>`;
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
// Shared helpers for the plugin pages (`plugins-page`, `plugin-catalog`, // Shared helpers for the plugin pages (`plugin-catalog`, `plugin-detail`).
// `plugin-detail`). Kept separate from `connector-common.js` on purpose: the // Kept separate from `connector-common.js` on purpose: the plugin model
// plugin model (JSON-Schema config blobs, `plugin_access`) is not the // (JSON-Schema config blobs, `plugin_access`) is not the connector model
// connector model (env/api_key manifests). // (env/api_key manifests).
export async function jf(url, opts) { export async function jf(url, opts) {
const res = await fetch(url, opts); const res = await fetch(url, opts);
+4 -3
View File
@@ -31,7 +31,6 @@ const NAV = [
// Estensioni — what the assistant is made of / can use. Visible to everyone; // 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). // 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: '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' }, { id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' },
// The background agents the instance runs for you. Visible to everyone: the // 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. // 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: '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: '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: '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 }, { id: 'config', group: 'config', priority: 90, icon: 'gear', labelKey: 'nav.config', adminOnly: true },
// Sviluppo — debug surface, only with the debug flag on. // 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'; return m ? `plugin/${m[1]}/${m[2]}` : 'home';
} }
// `connector` (singular) is the per-connector detail page, `connectors` the list. // `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() { _tasksSectionFromHash() {
-1
View File
@@ -77,7 +77,6 @@ roles-page,
shared-folders-page, shared-folders-page,
connectors-page, connectors-page,
connector-detail-page, connector-detail-page,
plugins-page,
plugin-catalog-page, plugin-catalog-page,
plugin-detail-page, plugin-detail-page,
plugin-page-host, plugin-page-host,
+2 -17
View File
@@ -21,7 +21,6 @@ export default {
'nav.roles': 'Roles', 'nav.roles': 'Roles',
'nav.connectors': 'Connectors', 'nav.connectors': 'Connectors',
'nav.plugins': 'Plugins', 'nav.plugins': 'Plugins',
'nav.plugin_catalog': 'Plugin Catalog',
'nav.config': 'Settings', 'nav.config': 'Settings',
'nav.llm_requests': 'LLM Requests', 'nav.llm_requests': 'LLM Requests',
'nav.system_agents': 'System agents', 'nav.system_agents': 'System agents',
@@ -874,39 +873,25 @@ export default {
'connectors.error.no_connector': 'No connector named "{name}" is available to you.', 'connectors.error.no_connector': 'No connector named "{name}" is available to you.',
// ── Plugins ───────────────────────────────────────────────────────────────── // ── Plugins ─────────────────────────────────────────────────────────────────
'plugins.title': 'Plugins',
'plugins.loading': 'Loading…', '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.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.enabled': 'Enabled',
'plugins.save': 'Save',
'plugins.save_config': 'Save config', 'plugins.save_config': 'Save config',
'plugins.saved': 'Saved.', 'plugins.saved': 'Saved.',
'plugin_page.loading': 'Loading…', 'plugin_page.loading': 'Loading…',
'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).', 'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).',
'plugins.yes': 'yes', 'plugins.badge.user_page': 'user page',
'plugins.no': 'no',
'plugins.badge.user_config': 'per-user settings',
'plugins.access.btn': 'User access',
'plugins.access.desc': 'Tick a box to let that person see and configure this plugin. Saving replaces the whole list.', '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.empty': 'No users.',
'plugins.access.save': 'Save access', 'plugins.access.save': 'Save access',
'plugins.error.required': '"{field}" is required.', 'plugins.error.required': '"{field}" is required.',
'plugins.catalog.title': 'Plugin Catalog',
'plugins.catalog.configure': 'Configure', 'plugins.catalog.configure': 'Configure',
'plugins.health.ok': 'active', 'plugins.health.ok': 'active',
'plugins.health.off': 'off', 'plugins.health.off': 'off',
'plugins.health.needs_config': 'needs configuration', 'plugins.health.needs_config': 'needs configuration',
'plugins.health.not_running': 'not running', 'plugins.health.not_running': 'not running',
'plugins.badge.instance_config': 'instance settings', '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.title': 'Instance configuration',
'plugins.detail.config.empty': 'This plugin has no instance settings.', 'plugins.detail.config.empty': 'This plugin has no instance settings.',
'plugins.detail.config.custom_page': 'This plugin has its own configuration page.', 'plugins.detail.config.custom_page': 'This plugin has its own configuration page.',
+2 -17
View File
@@ -21,7 +21,6 @@ export default {
'nav.roles': 'Rôles', 'nav.roles': 'Rôles',
'nav.connectors': 'Connecteurs', 'nav.connectors': 'Connecteurs',
'nav.plugins': 'Plugins', 'nav.plugins': 'Plugins',
'nav.plugin_catalog': 'Catalogue des plugins',
'nav.config': 'Paramètres', 'nav.config': 'Paramètres',
'nav.llm_requests': 'Requêtes LLM', 'nav.llm_requests': 'Requêtes LLM',
'nav.system_agents': 'Agents système', 'nav.system_agents': 'Agents système',
@@ -864,39 +863,25 @@ export default {
'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.', 'connectors.error.no_connector': 'Aucun connecteur nommé "{name}" ne vous est disponible.',
// ── Plugins ───────────────────────────────────────────────────────────────── // ── Plugins ─────────────────────────────────────────────────────────────────
'plugins.title': 'Plugins',
'plugins.loading': 'Chargement…', '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.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.enabled': 'Activé',
'plugins.save': 'Enregistrer',
'plugins.save_config': 'Enregistrer la config', 'plugins.save_config': 'Enregistrer la config',
'plugins.saved': 'Enregistré.', 'plugins.saved': 'Enregistré.',
'plugin_page.loading': 'Chargement…', 'plugin_page.loading': 'Chargement…',
'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).', 'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).',
'plugins.yes': 'oui', 'plugins.badge.user_page': 'page utilisateur',
'plugins.no': 'non',
'plugins.badge.user_config': 'réglages par utilisateur',
'plugins.access.btn': 'Accès utilisateurs',
'plugins.access.desc': "Cochez qui peut voir et configurer ce plugin. L'enregistrement remplace toute la liste.", 'plugins.access.desc': "Cochez qui peut voir et configurer ce plugin. L'enregistrement remplace toute la liste.",
'plugins.access.empty': 'Aucun utilisateur.', 'plugins.access.empty': 'Aucun utilisateur.',
'plugins.access.save': 'Enregistrer les accès', 'plugins.access.save': 'Enregistrer les accès',
'plugins.error.required': '« {field} » est requis.', 'plugins.error.required': '« {field} » est requis.',
'plugins.catalog.title': 'Catalogue des plugins',
'plugins.catalog.configure': 'Configurer', 'plugins.catalog.configure': 'Configurer',
'plugins.health.ok': 'actif', 'plugins.health.ok': 'actif',
'plugins.health.off': 'arrêté', 'plugins.health.off': 'arrêté',
'plugins.health.needs_config': 'à configurer', 'plugins.health.needs_config': 'à configurer',
'plugins.health.not_running': 'non démarré', 'plugins.health.not_running': 'non démarré',
'plugins.badge.instance_config': 'réglages dinstance', 'plugins.badge.instance_config': 'réglages dinstance',
'plugins.detail.back': 'Retour au catalogue', 'plugins.detail.back': 'Retour aux plugins',
'plugins.detail.config.title': 'Configuration de linstance', 'plugins.detail.config.title': 'Configuration de linstance',
'plugins.detail.config.empty': 'Ce plugin na aucun réglage dinstance.', 'plugins.detail.config.empty': 'Ce plugin na aucun réglage dinstance.',
'plugins.detail.config.custom_page': 'Ce plugin possède sa propre page de configuration.', 'plugins.detail.config.custom_page': 'Ce plugin possède sa propre page de configuration.',
+2 -17
View File
@@ -21,7 +21,6 @@ export default {
'nav.roles': 'Ruoli', 'nav.roles': 'Ruoli',
'nav.connectors': 'Connettori', 'nav.connectors': 'Connettori',
'nav.plugins': 'Plugin', 'nav.plugins': 'Plugin',
'nav.plugin_catalog': 'Catalogo plugin',
'nav.config': 'Impostazioni', 'nav.config': 'Impostazioni',
'nav.llm_requests': 'Richieste LLM', 'nav.llm_requests': 'Richieste LLM',
'nav.system_agents': 'Agenti di sistema', 'nav.system_agents': 'Agenti di sistema',
@@ -864,39 +863,25 @@ export default {
'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.', 'connectors.error.no_connector': 'Nessun connettore chiamato "{name}" è disponibile per te.',
// ── Plugin ────────────────────────────────────────────────────────────────── // ── Plugin ──────────────────────────────────────────────────────────────────
'plugins.title': 'Plugin',
'plugins.loading': 'Caricamento…', '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.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.enabled': 'Abilitato',
'plugins.save': 'Salva',
'plugins.save_config': 'Salva configurazione', 'plugins.save_config': 'Salva configurazione',
'plugins.saved': 'Salvato.', 'plugins.saved': 'Salvato.',
'plugin_page.loading': 'Caricamento…', 'plugin_page.loading': 'Caricamento…',
'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).', 'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).',
'plugins.yes': 'sì', 'plugins.badge.user_page': 'pagina utente',
'plugins.no': 'no',
'plugins.badge.user_config': 'impostazioni per utente',
'plugins.access.btn': 'Accesso utenti',
'plugins.access.desc': "Seleziona chi può vedere e configurare questo plugin. Il salvataggio sostituisce l'intera lista.", 'plugins.access.desc': "Seleziona chi può vedere e configurare questo plugin. Il salvataggio sostituisce l'intera lista.",
'plugins.access.empty': 'Nessun utente.', 'plugins.access.empty': 'Nessun utente.',
'plugins.access.save': 'Salva accesso', 'plugins.access.save': 'Salva accesso',
'plugins.error.required': '"{field}" è obbligatorio.', 'plugins.error.required': '"{field}" è obbligatorio.',
'plugins.catalog.title': 'Catalogo plugin',
'plugins.catalog.configure': 'Configura', 'plugins.catalog.configure': 'Configura',
'plugins.health.ok': 'attivo', 'plugins.health.ok': 'attivo',
'plugins.health.off': 'spento', 'plugins.health.off': 'spento',
'plugins.health.needs_config': 'da configurare', 'plugins.health.needs_config': 'da configurare',
'plugins.health.not_running': 'non in esecuzione', 'plugins.health.not_running': 'non in esecuzione',
'plugins.badge.instance_config': 'impostazioni istanza', '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.title': 'Configurazione istanza',
'plugins.detail.config.empty': 'Questo plugin non ha impostazioni di 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.', 'plugins.detail.config.custom_page': 'Questo plugin ha una propria pagina di configurazione.',
-1
View File
@@ -96,7 +96,6 @@
<shared-folders-page></shared-folders-page> <shared-folders-page></shared-folders-page>
<connectors-page></connectors-page> <connectors-page></connectors-page>
<connector-detail-page></connector-detail-page> <connector-detail-page></connector-detail-page>
<plugins-page></plugins-page>
<plugin-page-host></plugin-page-host> <plugin-page-host></plugin-page-host>
<plugin-catalog-page></plugin-catalog-page> <plugin-catalog-page></plugin-catalog-page>
<plugin-detail-page></plugin-detail-page> <plugin-detail-page></plugin-detail-page>