From f85876350e2feaea91cecf678ca0db804baed035 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sun, 19 Jul 2026 10:55:44 +0100 Subject: [PATCH] feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint --- CLAUDE.md | 17 +- crates/core-api/src/config_property.rs | 20 + crates/skald-core/src/db/mcp_catalog.rs | 30 +- crates/skald-core/src/db/mod.rs | 8 + crates/skald-core/src/i18n.rs | 20 +- crates/skald-core/src/mcp/install.rs | 172 ++ crates/skald-core/src/mcp/mod.rs | 64 +- crates/skald-core/src/skald/accessors.rs | 4 + crates/skald-core/src/skald/user_context.rs | 5 + scripts/whatsapp_mcp/index.js | 1095 +++----- scripts/whatsapp_mcp/package-lock.json | 2702 ------------------- scripts/whatsapp_mcp/package.json | 21 +- src/frontend/api/config.rs | 23 +- src/frontend/api/marketplace.rs | 68 +- src/frontend/api/mcp.rs | 148 + src/frontend/api/mod.rs | 3 + web/components/catalog.js | 130 +- web/components/config-page.js | 59 +- web/components/connector-detail.js | 138 +- web/components/marketplace.js | 12 +- web/i18n/en.js | 75 +- web/i18n/fr.js | 62 +- web/i18n/it.js | 62 +- 23 files changed, 1324 insertions(+), 3614 deletions(-) delete mode 100644 scripts/whatsapp_mcp/package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index c40794f..7e8fb6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,11 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s **Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots). -**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 login). `connectors.js` (``) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (``) is a connector's own page and hosts the OAuth login panel. +**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (``) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (``) is a connector's own page and hosts both the OAuth login panel and the QR login panel. + +**Dependency reconciler (`mcp::install::ensure_installed`).** Copying a local-script connector's files into a container never installed its deps. `ensure_installed` closes that: a **content-hash reconciler** keyed on the connector's *source* files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — `npm ci --omit=dev` (node, from `package.json`) and/or `pip install --target .pydeps` (python, from `requirements.txt`, put on the server's `PYTHONPATH` by `user_row_spec`). Runs at activation **and** on every per-user startup path (`UserContext` build, remount) via `mcp::prepare_local_connector`, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore **never vendored** — connectors ship `package.json`/`requirements.txt`, not `node_modules/`. Authoring contract for connectors lives in `scripts/CONNECTOR_MANIFEST_GUIDE.md`. + +**Connector versioning.** `mcp_catalog` carries `version` (INTEGER — the update-comparison key), `version_string` (semver, display) and `version_release_date` (ISO, display), snapshotted from the feed on install. The marketplace list computes `update_available` = feed `version` > installed `version` (strict) and surfaces it as an "Update" button (`marketplace.js`). The integer is the UI signal; the actual re-install trigger is the reconciler's content-hash. ### OAuth per-user connectors (blueprint §15 — copy-paste flow) @@ -162,7 +166,16 @@ OAuth2 authorization-code + PKCE is wired for per-user connectors (Gmail is the - **Credential delivery = env, nothing on disk.** The manifest's `deliver` (`{as,format,env}`, parsed as `mcp::DeliverSpec`) says how the token reaches the server. `user_row_spec_resolved` assembles the credential (`google_authorized_user` JSON = client creds from the provider + refresh token) and injects it as an env var (`GMAIL_CREDS_JSON`) on the `docker exec` — never a file, coherent with §2 (the tempted admin doesn't read `/proc`). The server reads it via `Credentials.from_authorized_user_info`. Ran both at OAuth-complete and at login-time per-user startup. - **Google needs a Web-application client**: a Desktop client rejects an `https://` redirect (loopback only), so the `oauth/show.html` redirect must be registered on a **Web app** OAuth client, and exact-match under Authorized redirect URIs — `redirect_uri_mismatch` otherwise. -**Deferred:** the other §15 interactive kinds (QR / SSH via elicitation) — `deliver.as=file` and non-Google providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace. +### QR / interactive device login (blueprint §15 — polling flow) + +For a per-user connector whose credential is produced by **pairing** (`auth.type: "qr"`; WhatsApp is the first, on Baileys — the slim `skald-runtime` image has no Chromium, so a browser-based client is out), there is no code to paste and the server must **run** to produce the QR. The seam is a generic tool contract, reusable for future device kinds (SSH…): + +- **`login_status` tool contract.** A connector needing an interactive login exposes one tool, `login_status`, returning JSON `{state, qr?, message}` (state: `connecting|need_scan|ready|logged_out`; `qr` is a data-URL PNG only while `need_scan`). Skald calls it **directly, never the agent**. +- **Flow.** `activate` on a `qr` entry inserts a **pending** `mcp_user_servers` row and **starts** the server (unlike OAuth, which defers), returning `needs_login`/`login_kind:"qr"`. `/mcp/login/status` ensures the server is running (restarts a pending one), calls `login_status`, and returns its state; on `ready` it flips the row's `auth_state` so `all_startable` picks it up next login. `/mcp/login/reset` calls the connector's `logout` tool to re-arm (link a different device). The `connector-detail.js` QR panel polls `login/status` and renders the QR. +- **Credential = on-disk session, not a token.** The connector persists its session inside its own dir (e.g. `./auth/`), under the bind-mounted home so it survives a container recreate — the honest §4 gap (admin-root-readable), not `memory_docs`. +- **Node 18 gotcha**: the container ships Node 18; Baileys uses the Web Crypto global, so the server must `globalThis.crypto ??= require('crypto').webcrypto` or it dies pre-QR with "crypto is not defined". + +**Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace. ## Multimodal attachments diff --git a/crates/core-api/src/config_property.rs b/crates/core-api/src/config_property.rs index d752192..4f172a8 100644 --- a/crates/core-api/src/config_property.rs +++ b/crates/core-api/src/config_property.rs @@ -1,12 +1,32 @@ use serde::{Deserialize, Serialize}; +/// How a config property is rendered and edited in the Config UI. +/// +/// Beyond the plain scalars (`String`/`Int`/`Bool`, rendered as text/number/ +/// switch), a variant can stand for a **custom, higher-level control** whose +/// allowed values are computed by the backend rather than typed by hand — +/// `SecurityGroup` and `Locale` are both of this kind: they turn into a +/// dropdown fed by a server-supplied `options` list. +/// +/// **Adding your own is cheap and encouraged.** If a new config section would +/// otherwise expose a free-text field where only a fixed/derived set of values +/// is valid, prefer adding a variant here instead. The wiring is three small, +/// symmetric edits: +/// 1. add the variant below; +/// 2. in `frontend/api/config.rs`, map it to a type string and (if it's a +/// dropdown) build its `options: Vec`; +/// 3. in `web/components/config-page.js`, add a render branch for that type. +/// Anything carrying `options` renders as a `` computes its choices here and + // ships them in `options`. To add a new one: build its `Vec` + // and wire it into the `match` below (see `PropertyType` for the full + // three-step recipe). let security_groups = skald.run_context_manager().list_groups().await .unwrap_or_default() .into_iter() - .map(|g| SecurityGroupOption { id: g.id, name: g.name }) + .map(|g| SelectOption { id: g.id, name: g.name }) + .collect::>(); + let locales = skald_core::i18n::SUPPORTED_LOCALES.iter() + .map(|code| SelectOption { + id: code.to_string(), + name: skald_core::i18n::native_language_name(code), + }) .collect::>(); let mut sets = Vec::with_capacity(skald.config_properties().len()); @@ -56,11 +71,13 @@ pub async fn list_properties( let mut props = Vec::with_capacity(set.properties.len()); for prop in &set.properties { let value = skald.config().get(&prop.key).await?; + // Scalars carry no `options`; dropdown types attach their choices. let (type_str, options) = match prop.property_type { PropertyType::Int => ("int", None), PropertyType::Bool => ("bool", None), PropertyType::String => ("string", None), PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())), + PropertyType::Locale => ("locale", Some(locales.clone())), }; props.push(PropertyView { key: prop.key.clone(), diff --git a/src/frontend/api/marketplace.rs b/src/frontend/api/marketplace.rs index fc26779..f062e47 100644 --- a/src/frontend/api/marketplace.rs +++ b/src/frontend/api/marketplace.rs @@ -105,6 +105,28 @@ struct IndexEntry { #[serde(default)] scope: Option, /// `mcp_local` | `mcp_remote` — the §14 risk axis. #[serde(default, rename = "type")] kind: Option, + /// Versioning (§ marketplace updates): `version` is the monotonic **integer** + /// build number — the comparison key for "update available". Tolerant of a + /// legacy string `version` during the schema migration (parsed to `None`). + #[serde(default, deserialize_with = "de_flexible_i64")] version: Option, + #[serde(default)] version_string: Option, + #[serde(default)] version_release_date: Option, +} + +/// Deserializes an optional integer that may arrive as a JSON number or (during the +/// string-`version` → integer-`version` migration) as a numeric string. A +/// non-numeric string (`"2.0.1"`) yields `None` rather than a hard parse error, so +/// one un-migrated entry never fails the whole feed. +fn de_flexible_i64<'de, D>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v = Option::::deserialize(d)?; + Ok(v.and_then(|v| match v { + serde_json::Value::Number(n) => n.as_i64(), + serde_json::Value::String(s) => s.trim().parse::().ok(), + _ => None, + })) } #[derive(Debug, Deserialize)] @@ -182,7 +204,11 @@ struct VerifySpec { #[derive(Debug, Clone, Default, Deserialize)] struct Manifest { #[serde(default)] name: Option, - #[serde(default)] version: Option, + /// The monotonic **integer** build number (see [`IndexEntry::version`]). Tolerant + /// of a legacy string during migration. + #[serde(default, deserialize_with = "de_flexible_i64")] version: Option, + #[serde(default)] version_string: Option, + #[serde(default)] version_release_date: Option, #[serde(default, rename = "type")] kind: Option, #[serde(default)] transport: Option, #[serde(default)] requires: Vec, @@ -316,7 +342,14 @@ fn files_of<'a>(entry: &'a IndexEntry, manifest: &'a Manifest) -> &'a [FileEntry pub struct MarketplaceCard { pub id: String, pub name: String, - pub version: Option, + /// The feed's build number (integer) and its display metadata. + pub version: Option, + pub version_string: Option, + pub version_release_date: Option, + /// The installed catalog row's build number, when installed. `update_available` + /// is `true` when the feed's `version` is strictly greater. + pub installed_version: Option, + pub update_available: bool, /// `per_user` | `global` pub scope: String, /// `remote` | `local_script` @@ -342,15 +375,25 @@ pub struct MarketplaceCard { pub installed: bool, } -fn card_of(h: &Hydrated, installed: bool) -> MarketplaceCard { +fn card_of(h: &Hydrated, installed: bool, installed_version: Option) -> MarketplaceCard { let source = norm_source(&h.entry, &h.manifest); let doc = h.manifest.docs.first().cloned().unwrap_or_default(); + // Prefer the manifest's version trio, falling back to the index entry's. + let version = h.manifest.version.or(h.entry.version); + let version_string = h.manifest.version_string.clone().or_else(|| h.entry.version_string.clone()); + let version_release_date = h.manifest.version_release_date.clone().or_else(|| h.entry.version_release_date.clone()); + // "Update available" is a strict integer bump on an already-installed connector. + let update_available = matches!((version, installed_version), (Some(feed), Some(have)) if feed > have); MarketplaceCard { id: h.entry.id.clone(), name: h.entry.name.clone() .or_else(|| h.manifest.name.clone()) .unwrap_or_else(|| h.entry.id.clone()), - version: h.manifest.version.clone(), + version, + version_string, + version_release_date, + installed_version, + update_available, scope: norm_scope(&h.entry, &h.manifest), transport: norm_transport(&h.manifest, &source), source, @@ -507,14 +550,16 @@ pub async fn list( ) -> Result, ApiError> { require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; let feed = feed(q.refresh).await?; - let installed: std::collections::HashSet = mcp_catalog::list(skald.db()) + // name → installed build number (present = installed; the value drives the + // "update available" comparison, `None` for a pre-versioning install). + let installed: std::collections::HashMap> = mcp_catalog::list(skald.db()) .await? .into_iter() - .map(|r| r.name) + .map(|r| (r.name, r.version)) .collect(); let cards: Vec = feed .iter() - .map(|h| card_of(h, installed.contains(&h.entry.id))) + .map(|h| card_of(h, installed.contains_key(&h.entry.id), installed.get(&h.entry.id).copied().flatten())) .collect(); Ok(Json(json!({ "base_url": base_url(), "connectors": cards }))) } @@ -717,6 +762,13 @@ pub async fn install( .llm_short_description .as_deref() .or(h.entry.user_description.as_deref()), + // Snapshot the feed's version so a later listing can compare it against a + // newer feed and surface "update available". Manifest wins over index. + version: h.manifest.version.or(h.entry.version), + version_string: h.manifest.version_string.as_deref() + .or(h.entry.version_string.as_deref()), + version_release_date: h.manifest.version_release_date.as_deref() + .or(h.entry.version_release_date.as_deref()), }, ) .await?; @@ -1103,7 +1155,7 @@ mod tests { assert!(!feed.is_empty(), "feed returned no connectors"); for h in &feed { - let c = card_of(h, false); + let c = card_of(h, false, None); println!( "{:<8} scope={:<8} source={:<12} transport={:<6} auth={:<7} files={}", c.id, c.scope, c.source, c.transport, c.auth_kind, c.file_count diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index 8835cd4..586a096 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -332,6 +332,11 @@ pub async fn catalog_upsert( icon_large_path: None, friendly_name: body.friendly_name.as_deref(), description: body.description.as_deref(), + // Versioning is the feed's to set (marketplace install); the manual form + // leaves it untouched (COALESCE in `upsert`). + version: None, + version_string: None, + version_release_date: None, }).await?; Ok(Json(json!({ "id": id }))) } @@ -751,6 +756,22 @@ pub async fn activate( .and_then(|e| serde_json::to_string(e).ok()) .or_else(|| entry.env_json.clone()); + // Reconcile node/python dependencies into the container before anything + // tries to run the server (verify, the QR login, or a first message). + // Blocking and one-time: the content-hash lock in `ensure_installed` + // makes every later activation/login a no-op. A hard failure here is a + // clear error rather than a connector that silently never starts. + if entry.source == "local_script" { + if let Some(script) = entry.script_path.as_deref() { + if let Ok((folder, _)) = skald_core::mcp::split_script_path(script) { + let container = skald_core::container::container_name(&auth.user_id); + skald_core::mcp::install::ensure_installed(&auth.user_id, &name, folder, &container) + .await + .map_err(|e| ApiError::bad_request(format!("dependency install failed: {e}")))?; + } + } + } + // OAuth connectors do NOT activate directly (§15): the refresh token // comes from an interactive consent, not from the activation form. We // persist a PENDING row (files installed, command wired) and hand off to @@ -794,6 +815,42 @@ pub async fn activate( }))); } + // QR (and other interactive-login) connectors, e.g. WhatsApp: unlike + // OAuth there is no code to paste back — the server must RUN to produce + // the QR, and the credential is the on-disk session it persists after the + // scan. Insert a PENDING row, start the server so it emits a QR, and hand + // off to the login panel, which polls `/mcp/login/status` until it reports + // `ready` (flipping the row so `all_startable` picks it up next login). + if entry.auth_kind == "qr" { + let id = mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { + name: &name, + catalog_name: Some(&entry.name), + source: &entry.source, + transport: &entry.transport, + command: command.as_deref(), + args_json, + env_json, + url: entry.url.as_deref(), + api_key: None, // the "credential" is the on-disk session + oauth_provider: None, + deliver_json: None, + script_rel_path: script_rel_path.as_deref(), + verify_command: None, + verify_script_rel_path: None, + auth_state: "pending", + }).await?; + if let Some(row) = mcp_user_servers::get(&ctx.pool, id).await? { + let container = skald_core::container::container_name(&auth.user_id); + let spec = skald_core::mcp::user_row_spec_resolved(&row, &container, skald.db()).await; + // The QR only appears once the socket connects; ignore a start + // error here — the login panel surfaces the real state via polling. + let _ = ctx.user_mcp.start_server(spec).await; + } + return Ok(Json(json!({ + "id": id, "auth_state": "pending", "needs_login": true, "login_kind": "qr", + }))); + } + mcp_user_servers::insert(&ctx.pool, mcp_user_servers::InsertUserServer { name: &name, catalog_name: Some(&entry.name), @@ -1045,3 +1102,94 @@ pub async fn oauth_complete( Err(e) => Ok(Json(json!({ "id": row.id, "error": e.to_string(), "auth_state": "ready" }))), } } + +// ── user: interactive QR / device login for a per-user connector (§15) ───────── +// +// The generic seam for any connector whose login is neither an api-key nor an +// OAuth code-paste (WhatsApp's QR today; SSH / other device pairings later): the +// connector's server exposes a standard `login_status` tool returning +// `{state, qr?, message}`, and Skald calls it DIRECTLY (never the agent). Unlike +// OAuth, the server must be RUNNING to produce the credential (a QR the user +// scans), and the credential is the on-disk session it persists — so there is +// nothing to paste back, only a state to poll until it reports `ready`. + +#[derive(Deserialize)] +pub struct LoginBody { + /// The pending `mcp_user_servers` row to sign in. + pub server_id: i64, +} + +/// Starts `row`'s server in the user's runtime if it is not already live — +/// reconciling its deps first (a container recreated since activation may lack +/// them). Idempotent: a no-op when the server is already connected. +async fn ensure_user_server_running( + skald: &Skald, + ctx: &skald_core::skald::UserContext, + user_id: &str, + row: &mcp_user_servers::McpUserServerRow, +) -> Result<(), ApiError> { + if ctx.user_mcp.is_running(&row.name) { + return Ok(()); + } + let container = skald_core::container::container_name(user_id); + skald_core::mcp::prepare_local_connector(skald.db(), user_id, &container, row).await; + let spec = skald_core::mcp::user_row_spec_resolved(row, &container, skald.db()).await; + ctx.user_mcp.start_server(spec).await + .map_err(|e| ApiError::bad_request(format!("could not start the connector: {e}")))?; + Ok(()) +} + +/// `POST /api/mcp/login/status` — polls a connector's interactive-login state. +/// Ensures the server is running, calls its `login_status` tool, and returns the +/// `{state, qr, message}` it reports (with `id`/`auth_state`). When the connector +/// reports `ready`, its row is flipped so `all_startable` starts it on the next +/// login. Safe to poll on an interval from the login panel. +pub async fn login_status( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let row = mcp_user_servers::get(&ctx.pool, body.server_id).await? + .ok_or_else(|| ApiError::not_found("no such connector"))?; + ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?; + + let result = ctx.user_mcp.call(&row.name, "login_status", json!({})).await + .map_err(|e| ApiError::bad_request(format!( + "this connector has no interactive login (no login_status tool): {e}" + )))?; + // The tool returns a JSON string in a text part; fall back to a plain message + // if a connector ever returns something else. + let wire = result.to_wire(); + let mut v: Value = serde_json::from_str(&wire) + .unwrap_or_else(|_| json!({ "state": "connecting", "message": wire })); + let state = v.get("state").and_then(|s| s.as_str()).unwrap_or("connecting").to_string(); + + if state == "ready" && row.auth_state != "ready" { + mcp_user_servers::set_auth_state(&ctx.pool, row.id, "ready").await?; + } + if let Value::Object(ref mut m) = v { + m.insert("id".into(), json!(row.id)); + m.insert("auth_state".into(), json!(if state == "ready" { "ready" } else { "pending" })); + } + Ok(Json(v)) +} + +/// `POST /api/mcp/login/reset` — re-arm the login (e.g. link a different phone). +/// Calls the connector's `logout` tool to clear the on-disk session and force a +/// fresh QR, and marks the row pending again. +pub async fn login_reset( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let row = mcp_user_servers::get(&ctx.pool, body.server_id).await? + .ok_or_else(|| ApiError::not_found("no such connector"))?; + ensure_user_server_running(&skald, &ctx, &auth.user_id, &row).await?; + let _ = ctx.user_mcp.call(&row.name, "logout", json!({})).await; + if row.auth_state == "ready" { + mcp_user_servers::set_auth_state(&ctx.pool, row.id, "pending").await?; + } + Ok(Json(json!({ "ok": true, "id": row.id, "auth_state": "pending" }))) +} diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 1a6a521..ea52f54 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -158,6 +158,9 @@ pub fn router() -> Router> { // user: interactive OAuth login for a pending per-user connector (§15) .route("/mcp/oauth/start", post(mcp::oauth_start)) .route("/mcp/oauth/complete", post(mcp::oauth_complete)) + // user: interactive QR / device login for a pending per-user connector (§15) + .route("/mcp/login/status", post(mcp::login_status)) + .route("/mcp/login/reset", post(mcp::login_reset)) // Dev / debug .route("/dev/debug_mode", get(dev::get_debug_mode).post(dev::set_debug_mode).put(dev::set_debug_mode)) .route("/dev/llm-requests", get(dev::list_llm_requests)) diff --git a/web/components/catalog.js b/web/components/catalog.js index 3bd70f0..f6f00c1 100644 --- a/web/components/catalog.js +++ b/web/components/catalog.js @@ -16,6 +16,11 @@ import { t } from '../lib/i18n.js'; // that puts unvetted code on the box — which is why it needs `mcp.register_local_script` // and why it sits second. // +// The manual path is a dedicated page (`#catalog/new`), not a dialog: the form is +// long and technical, a fixed modal grew taller than the viewport with no way to +// scroll, and a click on the overlay discarded everything typed so far. A page +// scrolls, and leaving it is a deliberate navigation. +// // Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). const ADMIN_ID = 'admin'; @@ -36,7 +41,8 @@ export class CatalogPage extends LightElement { _rows: { state: true }, _addOpen: { state: true }, // the "Add connector" chooser _error: { state: true }, - _modal: { state: true }, + _view: { state: true }, // 'list' | 'new' + _form: { state: true }, // manual-entry fields, when _view === 'new' }; } @@ -51,7 +57,8 @@ export class CatalogPage extends LightElement { this._rows = null; this._addOpen = false; this._error = null; - this._modal = null; + this._view = 'list'; + this._form = null; } connectedCallback() { @@ -61,7 +68,10 @@ export class CatalogPage extends LightElement { window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'catalog'; this.style.display = this._open ? 'flex' : 'none'; - if (this._open) this._load(); + if (this._open) { this._syncViewFromHash(); this._load(); } + }); + window.addEventListener('hashchange', () => { + if (this._open) this._syncViewFromHash(); }); document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); } @@ -97,25 +107,43 @@ export class CatalogPage extends LightElement { // ── Manual entry ─────────────────────────────────────────────────────────── - _openManual() { - this._addOpen = false; - this._modal = { - form: { + // The `new` view is derived from the `#catalog/new` sub-route, so the browser's + // Back/Forward works and a pasted URL lands on the form. Entering the view + // always starts a fresh form. + _syncViewFromHash() { + const parts = location.hash.slice(1).split('/'); + const wantsNew = parts[0] === 'catalog' && parts[1] === 'new'; + if (wantsNew && this._view !== 'new') { + this._error = null; + this._form = { name: '', scope: 'per_user', source: 'remote', transport: 'stdio', command: '', args: '', url: '', script_path: '', config_schema: '', auth_kind: 'none', friendly_name: '', description: '', - }, - }; + }; + } + this._view = wantsNew ? 'new' : 'list'; + } + + _openManual() { + this._addOpen = false; + history.pushState({ page: 'catalog', view: 'new' }, '', '#catalog/new'); + this._syncViewFromHash(); + } + + _closeNew() { + // Prefer real history so the browser's own Back stays consistent; fall back to + // the list when this page was opened straight from a pasted URL. + if (history.length > 1) { history.back(); return; } + history.pushState({ page: 'catalog' }, '', '#catalog'); + this._view = 'list'; } _patch(field, value) { - this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; + this._form = { ...this._form, [field]: value }; } - _closeModal() { this._modal = null; this._error = null; } - async _saveManual() { - const f = this._modal.form; + const f = this._form; if (!f.name.trim()) { this._error = t('catalog.error.name'); return; } const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); try { @@ -137,7 +165,9 @@ export class CatalogPage extends LightElement { description: f.description.trim() || null, }), }); - this._closeModal(); + this._view = 'list'; + this._form = null; + history.pushState({ page: 'catalog' }, '', '#catalog'); await this._load(); } catch (e) { this._error = e.message; } } @@ -154,6 +184,7 @@ export class CatalogPage extends LightElement { render() { if (!this._open) return nothing; + if (this._view === 'new') return this._renderNew(); const rows = this._rows ?? []; const loading = this._rows === null && !this._error && this._isAdmin; @@ -166,7 +197,7 @@ export class CatalogPage extends LightElement { - ${this._error && !this._modal ? html` + ${this._error ? html`
${this._error}
` : nothing}
@@ -183,8 +214,7 @@ export class CatalogPage extends LightElement { ${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} `}
- - ${this._renderModal()}`; + `; } // Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes @@ -265,6 +295,14 @@ export class CatalogPage extends LightElement { `; } + _area(label, value, oninput, opts = {}) { + return html`
+ + +
`; + } + _select(label, value, options, onchange) { return html`
@@ -274,39 +312,43 @@ export class CatalogPage extends LightElement {
`; } - _renderModal() { - if (!this._modal) return nothing; - const f = this._modal.form; + _renderNew() { + const f = this._form; const isScript = f.source === 'local_script'; return html` -
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> -
-
- ${t('catalog.modal.title')} - +
+
+
+ +

+ ${t('catalog.new.title')}

-
+
+
+
${this._error ? html`
${this._error}
` : nothing} ${isScript ? html` -
${unsafeHTML(t('catalog.modal.script_warn'))}
` : nothing} - ${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })} - ${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} - ${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} - ${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} +
${unsafeHTML(t('catalog.new.script_warn'))}
` : nothing} + ${this._field(t('catalog.new.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.new.name_hint'), mono: true })} + ${this._select(t('catalog.new.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} + ${this._select(t('catalog.new.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} + ${this._select(t('catalog.new.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} ${isScript - ? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })} - ${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}` - : this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} - ${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })} - ${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })} - ${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} - ${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} - ${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })} -
-
`; diff --git a/web/components/config-page.js b/web/components/config-page.js index 6dca4d4..30c5ca8 100644 --- a/web/components/config-page.js +++ b/web/components/config-page.js @@ -2,6 +2,23 @@ import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; +function _maybeT(key, fallback) { + const v = t(key); + return v !== key ? v : fallback; +} + +function _configSetSlug(name) { + const slugs = { + 'Interface': 'interface', + 'TIC Agent': 'tic_agent', + }; + return slugs[name] ?? null; +} + +function _propKeyId(propKey) { + return propKey.replace(/\./g, '__'); +} + export class ConfigPage extends LightElement { static properties = { _open: { state: true }, @@ -130,7 +147,7 @@ export class ConfigPage extends LightElement { .checked=${checked} @change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
`; } @@ -144,7 +161,13 @@ export class ConfigPage extends LightElement { @input=${e => this._setValue(prop.key, e.target.value)} />`; } + // Dropdown-style property types. The backend ships the allowed values in + // `prop.options` (a list of {id, name}); we only decide how to frame them. + // Adding a new custom type from a config section? Give it a `property_type` + // on the backend, attach its `options`, and add a branch like these — a + // free-text box becomes a proper picker for the price of a few lines. if (prop.property_type === 'security_group') { + // Nullable: the empty choice means "fall back to the instance default". const groups = prop.options ?? []; return html` { this._setValue(prop.key, e.target.value); this._save(prop); }}> + ${locales.map(l => html` + `)} + `; + } + return html`
-
${set.name}
-
${set.description}
+
${sName}
+
${sDesc}
${set.properties.map(p => this._renderRow(p))} @@ -180,22 +220,25 @@ export class ConfigPage extends LightElement { _renderRow(prop) { const saving = this._saving.has(prop.key); const saved = this._saved.has(prop.key); + const pk = _propKeyId(prop.key); + const pName = _maybeT(`config.prop.${pk}.name`, prop.name); + const pDesc = _maybeT(`config.prop.${pk}.desc`, prop.description); return html`
-
${prop.name}
-
${prop.description}
+
${pName}
+
${pDesc}
${this._renderInput(prop)} - ${prop.property_type !== 'bool' ? html` + ${!['bool', 'locale'].includes(prop.property_type) ? html` ` : nothing}
`; @@ -237,7 +280,7 @@ export class ConfigPage extends LightElement { ?disabled=${this._debugLoading} @change=${() => this._toggleDebugMode()} />
diff --git a/web/components/connector-detail.js b/web/components/connector-detail.js index 8eabab2..2795c1d 100644 --- a/web/components/connector-detail.js +++ b/web/components/connector-detail.js @@ -48,6 +48,7 @@ export class ConnectorDetailPage extends LightElement { _access: { state: true }, // admin: Set of granted user ids _noIcon: { state: true }, _oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code } + _qr: { state: true }, // in-flight QR/device login: { state, qr, message } }; } @@ -72,6 +73,9 @@ export class ConnectorDetailPage extends LightElement { this._users = null; this._access = null; this._oauth = null; + this._qr = null; + this._qrServerId = null; + this._stopQrPoll(); } connectedCallback() { @@ -82,6 +86,7 @@ export class ConnectorDetailPage extends LightElement { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadFromHash(); + else this._stopQrPoll(); // never poll a connector's login off-screen }); window.addEventListener('hashchange', () => { if (this._open) this._loadFromHash(); @@ -90,12 +95,18 @@ export class ConnectorDetailPage extends LightElement { disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); + this._stopQrPoll(); super.disconnectedCallback(); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } - get _status() { return statusOf({ _act: this._act, _glob: this._glob }); } + get _status() { + const s = statusOf({ _act: this._act, _glob: this._glob }); + // A QR/device connector at `pending` is waiting for its scan, not misconfigured. + if (s === 'pending' && this._entry?.auth_kind === 'qr') return 'needs_login'; + return s; + } async _loadFromHash() { const name = nameFromHash(); @@ -267,6 +278,76 @@ export class ConnectorDetailPage extends LightElement { finally { this._busy = false; } } + // ── QR / device login (§15): activate → server emits a QR → scan → poll ready ── + // Unlike OAuth there is no code to paste: the connector's server must run to + // produce the QR, so activation starts it and we poll `login_status` until the + // phone scan flips it to `ready`. + + async _startQrLogin() { + this._busy = true; this._error = null; + try { + // First sign-in creates the pending row (which installs deps + starts the + // server — this can take a while on a cold container). Reuse it thereafter. + let serverId = this._act?.id; + if (!serverId) { + const res = await jf('/api/mcp/activate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catalog_name: this._name }), + }); + if (res?.error) { this._error = res.error; return; } + serverId = res.id; + } + this._qrServerId = serverId; + await this._pollQr(); // fetch the first QR immediately + this._startQrPoll(); // then keep it fresh + await this._load(); + } catch (e) { this._error = e.message; } + finally { this._busy = false; } + } + + _startQrPoll() { + this._stopQrPoll(); + // The QR rotates every ~20 s and the scan can land any moment: poll briskly. + this.__qrTimer = setInterval(() => this._pollQr(), 2500); + } + + _stopQrPoll() { + if (this.__qrTimer) { clearInterval(this.__qrTimer); this.__qrTimer = null; } + } + + async _pollQr() { + if (!this._qrServerId) return; + try { + const res = await jf('/api/mcp/login/status', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_id: this._qrServerId }), + }); + this._qr = res; + if (res?.state === 'ready') { + this._stopQrPoll(); + await this._load(); // pick up the flipped auth_state + } + } catch (_) { /* transient (server still connecting) — keep polling */ } + } + + async _resetQrLogin() { + const id = this._qrServerId || this._act?.id; + if (!id) return; + this._busy = true; this._error = null; + try { + await jf('/api/mcp/login/reset', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_id: id }), + }); + this._qr = null; + this._qrServerId = id; + await this._pollQr(); + this._startQrPoll(); + await this._load(); + } catch (e) { this._error = e.message; } + finally { this._busy = false; } + } + async _enableGlobal() { this._busy = true; this._error = null; try { @@ -451,6 +532,18 @@ export class ConnectorDetailPage extends LightElement {
`; } + // QR / device login (WhatsApp): the server produces a QR the user scans with + // their phone — its own panel, like OAuth. + if (e.auth_kind === 'qr' && !this._isGlobal) { + return html` +
+
+

${t('connectors.detail.qr.title')}

+
+ ${this._renderQr()} +
`; + } + return html`
@@ -561,6 +654,49 @@ export class ConnectorDetailPage extends LightElement { `; } + _renderQr() { + const active = this._act && this._act.auth_state === 'ready'; + const q = this._qr; + const st = q?.state; + const polling = !!this.__qrTimer; + + return html` +
${t('connectors.detail.qr.desc')}
+ + ${active && st !== 'need_scan' && st !== 'logged_out' ? html` +
+ ${t('connectors.detail.qr.connected')} +
` : nothing} + + ${st === 'need_scan' && q?.qr ? html` +
+
${t('connectors.detail.qr.scan')}
+ WhatsApp QR +
${t('connectors.detail.qr.hint')}
+
` : nothing} + + ${polling && st && st !== 'ready' && st !== 'need_scan' ? html` +
+ ${q?.message || t('connectors.detail.qr.connecting')} +
` : nothing} + +
+ ${!active && !polling ? html` + ` : nothing} + ${active || polling ? html` + ` : nothing} + ${this._act ? html` + ` : nothing} +
`; + } + _renderEnvFields() { if (!this._schema.length) return nothing; return this._schema.map(f => html` diff --git a/web/components/marketplace.js b/web/components/marketplace.js index 4b603bf..6308201 100644 --- a/web/components/marketplace.js +++ b/web/components/marketplace.js @@ -245,9 +245,14 @@ export class MarketplacePage extends LightElement { : html`
`}
${c.name}
-
${c.id}${c.version ? ` · v${c.version}` : ''}
+
+ ${c.id}${c.version_string ? ` · ${c.version_string}` : (c.version != null ? ` · v${c.version}` : '')} + ${c.update_available && c.installed_version != null ? html` · ${t('marketplace.card.installed_version', { v: c.installed_version })}` : nothing} +
- ${c.installed ? html`${t('marketplace.card.installed')}` : nothing} + ${c.update_available + ? html`${t('marketplace.card.update_available')}` + : c.installed ? html`${t('marketplace.card.installed')}` : nothing}
${c.user_description ? html`
${c.user_description}
` : nothing} @@ -277,9 +282,10 @@ export class MarketplacePage extends LightElement { ` : nothing}
- diff --git a/web/i18n/en.js b/web/i18n/en.js index ce7bdf5..15985c6 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -153,6 +153,22 @@ export default { 'config.loading': 'Loading…', 'config.developer': 'Developer', 'config.error_save':'Error saving "{name}": {msg}', + 'config.enabled': 'Enabled', + 'config.disabled': 'Disabled', + + 'config.set.interface.name': 'Interface', + 'config.set.interface.desc': 'Look and feel of the web interface.', + 'config.set.tic_agent.name': 'TIC Agent', + 'config.set.tic_agent.desc': 'TIC is a background agent that monitors all async events generated by connected MCP servers (new emails, calendar updates, WhatsApp messages, etc.). It reads your notification rules from data/notifications.md and your memory to decide — via an LLM call — which events are worth surfacing. Relevant notifications are forwarded to the home agent set via /sethome.', + + 'config.prop.ui_locale.name': 'Language', + 'config.prop.ui_locale.desc': 'Default interface language for the whole instance. Each user can override it on their profile.', + 'config.prop.tic__enabled.name': 'Enabled', + 'config.prop.tic__enabled.desc': 'Enable or disable the TIC agent. When disabled, no MCP events are processed.', + 'config.prop.tic__security_group.name': 'Security Group', + 'config.prop.tic__security_group.desc': 'Tool permission group applied to each TIC agent session. Leave empty to use the default group.', + 'config.prop.tic__interval_minutes.name': 'Check Interval (minutes)', + 'config.prop.tic__interval_minutes.desc': 'How often TIC runs, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).', // ── Projects ──────────────────────────────────────────────────────────────── 'projects.title': 'Projects', @@ -799,6 +815,16 @@ export default { 'connectors.detail.oauth.cancel': 'Cancel', 'connectors.detail.oauth.deactivate': 'Deactivate', + 'connectors.detail.qr.title': 'Link your phone', + 'connectors.detail.qr.desc': 'Scan a QR code with your phone to link this device. The session stays on this box — no password is stored.', + 'connectors.detail.qr.connected': 'Connected and active.', + 'connectors.detail.qr.scan': 'Scan this code with your phone:', + 'connectors.detail.qr.hint': 'WhatsApp → Settings → Linked Devices → Link a Device.', + 'connectors.detail.qr.connecting': 'Connecting…', + 'connectors.detail.qr.btn_start': 'Start sign-in', + 'connectors.detail.qr.btn_starting': 'Preparing… (this can take a minute the first time)', + 'connectors.detail.qr.btn_relink': 'Re-link (new QR)', + 'connectors.detail.test.running': 'Testing credentials…', 'connectors.detail.test.skipped': 'No verification step for this connector.', 'connectors.detail.test.ok_label': 'OK', @@ -891,6 +917,9 @@ export default { 'marketplace.grid.no_match': 'No connector matches these filters.', 'marketplace.card.installed': 'installed', + 'marketplace.card.update_available': 'update available', + 'marketplace.card.installed_version': 'installed v{v}', + 'marketplace.card.update': 'Update', 'marketplace.card.scope_global': 'global', 'marketplace.card.scope_per_user': 'per-user', 'marketplace.card.type_script': 'local script', @@ -986,28 +1015,29 @@ export default { 'catalog.action.remove': 'Remove from catalog', - 'catalog.modal.title': 'Add connector manually', - 'catalog.modal.script_warn': 'A local script runs code on this box. Nothing verifies it — unlike the marketplace path, there is no digest to check.', - 'catalog.modal.name': 'Name', - 'catalog.modal.name_hint': 'slug', - 'catalog.modal.scope': 'Scope', - 'catalog.modal.type': 'Type', - 'catalog.modal.transport': 'Transport', - 'catalog.modal.command': 'Command', - 'catalog.modal.command_ph': 'python3', - 'catalog.modal.script_path': 'Script path', - 'catalog.modal.script_path_hint': 'as /, under ./connectors', - 'catalog.modal.url': 'URL', - 'catalog.modal.args': 'Args', - 'catalog.modal.args_hint': 'one per line', - 'catalog.modal.config_schema': 'Required secret/env keys', - 'catalog.modal.config_schema_hint': 'comma/newline', - 'catalog.modal.auth': 'Auth', - 'catalog.modal.friendly': 'Friendly name', - 'catalog.modal.desc': 'Description', - 'catalog.modal.desc_hint': 'the LLM reads this when deciding to activate the connector', - 'catalog.modal.cancel': 'Cancel', - 'catalog.modal.save': 'Add to catalog', + 'catalog.new.back': 'Back', + 'catalog.new.title': 'Add connector manually', + 'catalog.new.script_warn': 'A local script runs code on this box. Nothing verifies it — unlike the marketplace path, there is no digest to check.', + 'catalog.new.name': 'Name', + 'catalog.new.name_hint': 'slug', + 'catalog.new.scope': 'Scope', + 'catalog.new.type': 'Type', + 'catalog.new.transport': 'Transport', + 'catalog.new.command': 'Command', + 'catalog.new.command_ph': 'python3', + 'catalog.new.script_path': 'Script path', + 'catalog.new.script_path_hint': 'as /, under ./connectors', + 'catalog.new.url': 'URL', + 'catalog.new.args': 'Args', + 'catalog.new.args_hint': 'one per line', + 'catalog.new.config_schema': 'Required secret/env keys', + 'catalog.new.config_schema_hint': 'comma/newline', + 'catalog.new.auth': 'Auth', + 'catalog.new.friendly': 'Friendly name', + 'catalog.new.desc': 'Description', + 'catalog.new.desc_hint': 'the LLM reads this when deciding to activate the connector', + 'catalog.new.cancel': 'Cancel', + 'catalog.new.save': 'Add to catalog', 'catalog.error.name': 'Name is required.', 'catalog.confirm.delete': 'Remove "{name}" from the catalog?\n\nAnything already activated from it keeps running.', @@ -1039,6 +1069,7 @@ export default { 'common.saving': 'Saving…', 'common.cancel': 'Cancel', 'common.loading': 'Loading…', + 'common.saved': 'Saved', // ── Shared Folders (blueprint §6) ──────────────────────────────────────────── 'nav.shared_folders': 'Shared Folders', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 69fb397..f8ed101 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -153,6 +153,22 @@ export default { 'config.loading': 'Chargement…', 'config.developer': 'Développeur', 'config.error_save':'Erreur lors de l\'enregistrement de "{name}" : {msg}', + 'config.enabled': 'Activé', + 'config.disabled': 'Désactivé', + + 'config.set.interface.name': 'Interface', + 'config.set.interface.desc': 'Aspect et style de l\'interface web.', + 'config.set.tic_agent.name': 'Agent TIC', + 'config.set.tic_agent.desc': 'TIC est un agent d\'arrière-plan qui surveille tous les événements asynchrones générés par les serveurs MCP connectés (nouveaux e-mails, mises à jour du calendrier, messages WhatsApp, etc.). Il lit vos règles de notification dans data/notifications.md et votre mémoire pour décider — via un appel LLM — quels événements méritent d\'être signalés. Les notifications pertinentes sont transmises à l\'agent d\'accueil défini via /sethome.', + + 'config.prop.ui_locale.name': 'Langue', + 'config.prop.ui_locale.desc': 'Langue d\'interface par défaut pour l\'ensemble de l\'instance. Chaque utilisateur peut la modifier dans son profil.', + 'config.prop.tic__enabled.name': 'Activé', + 'config.prop.tic__enabled.desc': 'Activer ou désactiver l\'agent TIC. Lorsqu\'il est désactivé, aucun événement MCP n\'est traité.', + 'config.prop.tic__security_group.name': 'Groupe de sécurité', + 'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque session de l\'agent TIC. Laissez vide pour utiliser le groupe par défaut.', + 'config.prop.tic__interval_minutes.name': 'Intervalle de vérification (minutes)', + 'config.prop.tic__interval_minutes.desc': 'Fréquence d\'exécution de TIC, en minutes. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).', // ── Projects ──────────────────────────────────────────────────────────────── 'projects.title': 'Projets', @@ -986,28 +1002,29 @@ export default { 'catalog.action.remove': 'Retirer du catalogue', - 'catalog.modal.title': 'Ajouter un connecteur manuellement', - 'catalog.modal.script_warn': 'Un script local exécute du code sur cette machine. Rien ne le vérifie — contrairement au Marketplace, il n\'y a pas de condensé à contrôler.', - 'catalog.modal.name': 'Nom', - 'catalog.modal.name_hint': 'slug', - 'catalog.modal.scope': 'Portée', - 'catalog.modal.type': 'Type', - 'catalog.modal.transport': 'Transport', - 'catalog.modal.command': 'Commande', - 'catalog.modal.command_ph': 'python3', - 'catalog.modal.script_path': 'Chemin du script', - 'catalog.modal.script_path_hint': 'comme /, sous ./connectors', - 'catalog.modal.url': 'URL', - 'catalog.modal.args': 'Arguments', - 'catalog.modal.args_hint': 'un par ligne', - 'catalog.modal.config_schema': 'Clés secrètes/env requises', - 'catalog.modal.config_schema_hint': 'virgule/nouvelle ligne', - 'catalog.modal.auth': 'Auth', - 'catalog.modal.friendly': 'Nom convivial', - 'catalog.modal.desc': 'Description', - 'catalog.modal.desc_hint': 'le LLM lit ceci pour décider d\'activer le connecteur', - 'catalog.modal.cancel': 'Annuler', - 'catalog.modal.save': 'Ajouter au catalogue', + 'catalog.new.back': 'Retour', + 'catalog.new.title': 'Ajouter un connecteur manuellement', + 'catalog.new.script_warn': 'Un script local exécute du code sur cette machine. Rien ne le vérifie — contrairement au Marketplace, il n\'y a pas de condensé à contrôler.', + 'catalog.new.name': 'Nom', + 'catalog.new.name_hint': 'slug', + 'catalog.new.scope': 'Portée', + 'catalog.new.type': 'Type', + 'catalog.new.transport': 'Transport', + 'catalog.new.command': 'Commande', + 'catalog.new.command_ph': 'python3', + 'catalog.new.script_path': 'Chemin du script', + 'catalog.new.script_path_hint': 'comme /, sous ./connectors', + 'catalog.new.url': 'URL', + 'catalog.new.args': 'Arguments', + 'catalog.new.args_hint': 'un par ligne', + 'catalog.new.config_schema': 'Clés secrètes/env requises', + 'catalog.new.config_schema_hint': 'virgule/nouvelle ligne', + 'catalog.new.auth': 'Auth', + 'catalog.new.friendly': 'Nom convivial', + 'catalog.new.desc': 'Description', + 'catalog.new.desc_hint': 'le LLM lit ceci pour décider d\'activer le connecteur', + 'catalog.new.cancel': 'Annuler', + 'catalog.new.save': 'Ajouter au catalogue', 'catalog.error.name': 'Le nom est requis.', 'catalog.confirm.delete': 'Retirer "{name}" du catalogue ?\n\nTout ce qui a déjà été activé continuera de fonctionner.', @@ -1039,6 +1056,7 @@ export default { 'common.saving': 'Enregistrement…', 'common.cancel': 'Annuler', 'common.loading': 'Chargement…', + 'common.saved': 'Enregistré', // ── Shared Folders (blueprint §6) ──────────────────────────────────────────── 'nav.shared_folders': 'Dossiers partagés', diff --git a/web/i18n/it.js b/web/i18n/it.js index 71daa27..f5a1f48 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -177,6 +177,22 @@ export default { 'config.loading': 'Caricamento…', 'config.developer': 'Sviluppatore', 'config.error_save':'Errore durante il salvataggio di "{name}": {msg}', + 'config.enabled': 'Attivato', + 'config.disabled': 'Disattivato', + + 'config.set.interface.name': 'Interfaccia', + 'config.set.interface.desc': 'Aspetto e stile dell\'interfaccia web.', + 'config.set.tic_agent.name': 'Agente TIC', + 'config.set.tic_agent.desc': 'TIC è un agente in background che monitora tutti gli eventi asincroni generati dai server MCP connessi (nuove email, aggiornamenti del calendario, messaggi WhatsApp, ecc.). Legge le regole di notifica da data/notifications.md e la memoria per decidere — tramite una chiamata LLM — quali eventi vale la pena segnalare. Le notifiche rilevanti vengono inoltrate all\'agente predefinito impostato tramite /sethome.', + + 'config.prop.ui_locale.name': 'Lingua', + 'config.prop.ui_locale.desc': 'Lingua predefinita per l\'intera istanza. Ogni utente può modificarla nel proprio profilo.', + 'config.prop.tic__enabled.name': 'Attivo', + 'config.prop.tic__enabled.desc': 'Attiva o disattiva l\'agente TIC. Quando disattivato, nessun evento MCP viene elaborato.', + 'config.prop.tic__security_group.name': 'Gruppo di sicurezza', + 'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni sessione dell\'agente TIC. Lascia vuoto per usare il gruppo predefinito.', + 'config.prop.tic__interval_minutes.name': 'Intervallo di controllo (minuti)', + 'config.prop.tic__interval_minutes.desc': 'Ogni quanto TIC viene eseguito, in minuti. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).', // ── Projects ──────────────────────────────────────────────────────────────── 'projects.title': 'Progetti', @@ -986,28 +1002,29 @@ export default { 'catalog.action.remove': 'Rimuovi dal catalogo', - 'catalog.modal.title': 'Aggiungi connettore manualmente', - 'catalog.modal.script_warn': 'Uno script locale esegue codice su questo computer. Niente lo verifica — a differenza del marketplace, non c\'è un digest da controllare.', - 'catalog.modal.name': 'Nome', - 'catalog.modal.name_hint': 'slug', - 'catalog.modal.scope': 'Ambito', - 'catalog.modal.type': 'Tipo', - 'catalog.modal.transport': 'Trasporto', - 'catalog.modal.command': 'Comando', - 'catalog.modal.command_ph': 'python3', - 'catalog.modal.script_path': 'Percorso script', - 'catalog.modal.script_path_hint': 'come /, sotto ./connectors', - 'catalog.modal.url': 'URL', - 'catalog.modal.args': 'Argomenti', - 'catalog.modal.args_hint': 'uno per riga', - 'catalog.modal.config_schema': 'Chiavi segrete/env richieste', - 'catalog.modal.config_schema_hint': 'virgola/nuova riga', - 'catalog.modal.auth': 'Auth', - 'catalog.modal.friendly': 'Nome visualizzato', - 'catalog.modal.desc': 'Descrizione', - 'catalog.modal.desc_hint': 'l\'LLM legge questo quando decide se attivare il connettore', - 'catalog.modal.cancel': 'Annulla', - 'catalog.modal.save': 'Aggiungi al catalogo', + 'catalog.new.back': 'Indietro', + 'catalog.new.title': 'Aggiungi connettore manualmente', + 'catalog.new.script_warn': 'Uno script locale esegue codice su questo computer. Niente lo verifica — a differenza del marketplace, non c\'è un digest da controllare.', + 'catalog.new.name': 'Nome', + 'catalog.new.name_hint': 'slug', + 'catalog.new.scope': 'Ambito', + 'catalog.new.type': 'Tipo', + 'catalog.new.transport': 'Trasporto', + 'catalog.new.command': 'Comando', + 'catalog.new.command_ph': 'python3', + 'catalog.new.script_path': 'Percorso script', + 'catalog.new.script_path_hint': 'come /, sotto ./connectors', + 'catalog.new.url': 'URL', + 'catalog.new.args': 'Argomenti', + 'catalog.new.args_hint': 'uno per riga', + 'catalog.new.config_schema': 'Chiavi segrete/env richieste', + 'catalog.new.config_schema_hint': 'virgola/nuova riga', + 'catalog.new.auth': 'Auth', + 'catalog.new.friendly': 'Nome visualizzato', + 'catalog.new.desc': 'Descrizione', + 'catalog.new.desc_hint': 'l\'LLM legge questo quando decide se attivare il connettore', + 'catalog.new.cancel': 'Annulla', + 'catalog.new.save': 'Aggiungi al catalogo', 'catalog.error.name': 'Il nome è obbligatorio.', 'catalog.confirm.delete': 'Rimuovere "{name}" dal catalogo?\n\nTutto ciò che è già stato attivato continuerà a funzionare.', @@ -1039,6 +1056,7 @@ export default { 'common.saving': 'Salvataggio…', 'common.cancel': 'Annulla', 'common.loading': 'Caricamento…', + 'common.saved': 'Salvato', // ── Cartelle condivise (blueprint §6) ──────────────────────────────────────── 'nav.shared_folders': 'Cartelle condivise',