plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s
Nightly Build / build (push) Successful in 7m1s
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.
This commit is contained in:
@@ -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 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.
|
||||
|
||||
@@ -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 |
|
||||
| `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) |
|
||||
| `plugins-page.js` | `<plugins-page>` | `#plugins` — user half: granted plugins + schema-driven per-user config form |
|
||||
| `plugin-catalog.js` | `<plugin-catalog>` | `#plugin-catalog` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) |
|
||||
| `plugin-catalog.js` | `<plugin-catalog>` | `#plugins` — 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-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 |
|
||||
|
||||
Generated
+1
@@ -3010,6 +3010,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"core-api",
|
||||
"rand 0.10.1",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<PluginPage> {
|
||||
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<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,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
@@ -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).
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
@@ -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<Vec<UserPluginView>> {
|
||||
let granted: std::collections::HashSet<String> = 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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
<div class="um-page">
|
||||
<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>
|
||||
|
||||
${this._error ? html`
|
||||
@@ -138,8 +140,8 @@ export class PluginCatalogPage extends LightElement {
|
||||
<div class="connector-chips">
|
||||
${hasSchema(p.config_schema) ? html`
|
||||
<span class="connector-chip"><i class="bi bi-sliders"></i>${t('plugins.badge.instance_config')}</span>` : nothing}
|
||||
${hasSchema(p.user_config_schema) ? html`
|
||||
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing}
|
||||
${p.has_user_page ? html`
|
||||
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_page')}</span>` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
|
||||
@@ -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=<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`.
|
||||
//
|
||||
// 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 {
|
||||
</div>
|
||||
${p.description ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${p.description}</div>` : nothing}
|
||||
<div class="connector-chips">
|
||||
${hasSchema(p.user_config_schema) ? html`
|
||||
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_config')}</span>` : nothing}
|
||||
${p.has_user_page ? html`
|
||||
<span class="connector-chip connector-chip--scope"><i class="bi bi-person"></i>${t('plugins.badge.user_page')}</span>` : nothing}
|
||||
</div>
|
||||
<div class="form-check form-switch mt-1 mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="plugin-detail-on"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-17
@@ -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.',
|
||||
|
||||
+2
-17
@@ -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.',
|
||||
|
||||
+2
-17
@@ -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.',
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
<shared-folders-page></shared-folders-page>
|
||||
<connectors-page></connectors-page>
|
||||
<connector-detail-page></connector-detail-page>
|
||||
<plugins-page></plugins-page>
|
||||
<plugin-page-host></plugin-page-host>
|
||||
<plugin-catalog-page></plugin-catalog-page>
|
||||
<plugin-detail-page></plugin-detail-page>
|
||||
|
||||
Reference in New Issue
Block a user