From 6040f9a33994ebfed7b8afb97167b80d6b781c1d Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 15:29:26 +0100 Subject: [PATCH] honcho: plugin web pages with i18n, defer plugin detail to custom admin page --- .gitignore | 2 + Cargo.lock | 2 + SKALD.md | 39 +++++- ci/package-macos.sh | 117 ++++++++++++++++ crates/core-api/src/user_channel.rs | 10 ++ crates/plugin-honcho/Cargo.toml | 2 + crates/plugin-honcho/i18n/en.json | 5 + crates/plugin-honcho/i18n/fr.json | 5 + crates/plugin-honcho/i18n/it.json | 5 + crates/plugin-honcho/src/i18n.rs | 35 +++++ crates/plugin-honcho/src/lib.rs | 59 +++++++- crates/plugin-honcho/src/router.rs | 141 ++++++++++++++++++++ crates/plugin-honcho/web/common.js | 46 +++++++ crates/plugin-honcho/web/config.js | 163 +++++++++++++++++++++++ crates/plugin-honcho/web/i18n.js | 109 +++++++++++++++ crates/plugin-honcho/web/memory.js | 135 +++++++++++++++++++ crates/skald-core/src/plugin/mod.rs | 5 + crates/skald-core/src/skald/accessors.rs | 12 ++ web/components/plugin-detail.js | 31 +++++ web/i18n/en.js | 2 + web/i18n/fr.js | 2 + web/i18n/it.js | 2 + 22 files changed, 927 insertions(+), 2 deletions(-) create mode 100755 ci/package-macos.sh create mode 100644 crates/plugin-honcho/i18n/en.json create mode 100644 crates/plugin-honcho/i18n/fr.json create mode 100644 crates/plugin-honcho/i18n/it.json create mode 100644 crates/plugin-honcho/src/i18n.rs create mode 100644 crates/plugin-honcho/src/router.rs create mode 100644 crates/plugin-honcho/web/common.js create mode 100644 crates/plugin-honcho/web/config.js create mode 100644 crates/plugin-honcho/web/i18n.js create mode 100644 crates/plugin-honcho/web/memory.js diff --git a/.gitignore b/.gitignore index fd252fa..dca9f82 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ blueprint/ /target/ /deploy/ # Binary installed by ./build.sh, executed by ./run.sh +# ── Build output ────────────────────────────────────────────────────────────── +/dist/ /bin/ # ── Python environment ──────────────────────────────────────────────────────── diff --git a/Cargo.lock b/Cargo.lock index 71e6b20..80d17fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,8 +2950,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum", "core-api", "honcho-client", + "serde", "serde_json", "tokio", "tokio-util", diff --git a/SKALD.md b/SKALD.md index 866edd0..bc4b0dc 100644 --- a/SKALD.md +++ b/SKALD.md @@ -1,5 +1,8 @@ # Skald Circle — SKALD +_This file MUST be written in English. All project notes, decisions, and documentation here are in English._ + + ## Installation ### Stable release @@ -149,11 +152,45 @@ Automatic build on NiPoGi with Gitea Actions (native runner v2.1.0): - `actions/checkout@v4` works (native runner has Node.js) - macOS ARM64 supported via `install.sh` / `install-nightly.sh` (auto-detects OS, uses launchd) + +## macOS package script (`ci/package-macos.sh`) + +Script to build and deploy the macOS ARM64 package directly from the MacBook. + +| Detail | Value | +|--------|-------| +| **File** | `ci/package-macos.sh` | +| **Branch `release`** | Build + version check (curl) + upload to `releases/v{ver}/` + update LATEST | +| **Branch `main`** | Build + upload to `nightly/` (no version check) | +| **Other branches** | ❌ Abort | +| **Remote host** | `skaldserver` (SSH alias → `192.168.1.100`, user `dguiducci`, key `id_ed25519_skaldserver`) | +| **Remote path** | `/var/www/builds.skaldagent.net/` | + +### Setup SSH + +| Step | Command | +|------|---------| +| Key created | `ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_skaldserver` | +| `~/.ssh/config` alias | `Host skaldserver` → `HostName 192.168.1.100 User dguiducci IdentityFile ~/.ssh/id_ed25519_skaldserver` | +| Installed on server | `cat ~/.ssh/id_ed25519_skaldserver.pub` → `~/.ssh/authorized_keys` on the NiPoGi | +| MCP SSH registered | `mcp__ssh__add_alias` → alias `skaldserver` (auth: key, sudo: prompt) | + +### Operational notes + +- Builds with **whisper included** (no `--no-default-features` like on Linux) +- The tarball is uploaded via SCP (`scp` + `ssh` for LATEST) +- `install.sh` / `install-nightly.sh` already support macOS ARM64 (launchd) +- Service homepage at `http://192.168.1.100:8086` — updated with **📦 Builds** card + → after editing the file, run `docker restart homepage` (bind mount `:ro` doesn't propagate live) + + ### Next steps +- [x] Script `ci/package-macos.sh` to build and deploy from MacBook (release + nightly) +- Test the script on `main` branch (nightly) +- Test the script on `release` branch (release) - Create `release` branch on Gitea with branch protection (PR via UI) - Test release workflow with a PR -- Build first macOS ARM64 binary on MacBook, upload to `builds.skaldagent.net` ## macOS support diff --git a/ci/package-macos.sh b/ci/package-macos.sh new file mode 100755 index 0000000..e943bbc --- /dev/null +++ b/ci/package-macos.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env sh +# Build, package, and deploy Skald Circle for macOS (ARM64). +# +# Usage: ./ci/package-macos.sh +# +# Behaviour depends on the current git branch: +# release → builds a release tarball, checks version uniqueness, uploads + updates LATEST +# main → builds a nightly tarball, uploads to nightly/ (no version check) +# other → aborts with an error +# +# Prerequisites: +# - macOS ARM64 (Apple Silicon) +# - SSH alias "skaldserver" configured in ~/.ssh/config pointing to the builds host +# - ssh + scp working to skaldserver (key-based auth) +# - ci/package.sh, ci/verify-version.sh in the repo + +set -eu + +cd "$(dirname "$0")/.." + +# ── Config ────────────────────────────────────────────────────────────────── +REMOTE_HOST="skaldserver" +REMOTE_BASE="/var/www/builds.skaldagent.net" +BUILDS_URL="https://builds.skaldagent.net" + +# ── Detect branch ──────────────────────────────────────────────────────────── +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +echo "[package-macos] Branch: ${BRANCH}" + +case "$BRANCH" in + release) + MODE="release" + ;; + main) + MODE="nightly" + ;; + *) + echo "[package-macos] ❌ Aborting: must be on 'release' or 'main' branch (current: ${BRANCH})" + exit 1 + ;; +esac + +# ── Version ────────────────────────────────────────────────────────────────── +if [ "$MODE" = "release" ]; then + VERSION="v$(grep '^version ' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')" + echo "[package-macos] Release version: ${VERSION}" +else + VERSION="nightly" + echo "[package-macos] Nightly build" +fi + +# ── Verify version is new (release only) ──────────────────────────────────── +if [ "$MODE" = "release" ]; then + echo "[package-macos] Checking if release ${VERSION} already exists on remote..." + REMOTE_DIR_URL="${BUILDS_URL}/releases/${VERSION}/" + if curl -I --fail --silent --output /dev/null "$REMOTE_DIR_URL" 2>/dev/null; then + echo "[package-macos] ❌ Release ${VERSION} already exists at ${REMOTE_DIR_URL}" + echo "[package-macos] Bump the version in Cargo.toml before releasing." + exit 1 + fi + echo "[package-macos] ✅ Release ${VERSION} is new — proceeding." +fi + +# ── Build ──────────────────────────────────────────────────────────────────── +echo "[package-macos] Building (this will take a while)..." +cargo build --release +cargo build --release -p skald-setup +echo "[package-macos] ✅ Build complete." + +# ── Package ────────────────────────────────────────────────────────────────── +echo "[package-macos] Packaging..." +mkdir -p dist +if [ "$MODE" = "release" ]; then + ./ci/package.sh \ + --version "$VERSION" \ + --os darwin \ + --arch arm64 \ + --target-dir target/release \ + --output dist/ +else + ./ci/package.sh \ + --version nightly \ + --os darwin \ + --arch arm64 \ + --target-dir target/release \ + --output dist/ +fi + +# ── Upload via SCP ─────────────────────────────────────────────────────────── +echo "[package-macos] Uploading to ${REMOTE_HOST}..." + +if [ "$MODE" = "release" ]; then + # Create remote directory and copy tarball + ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/releases/${VERSION}" + scp dist/skald-circle-${VERSION}-darwin-arm64.tar.gz \ + "${REMOTE_HOST}:${REMOTE_BASE}/releases/${VERSION}/" + + # Update LATEST pointer + echo "$VERSION" | ssh "$REMOTE_HOST" "cat > ${REMOTE_BASE}/releases/LATEST" + echo "[package-macos] ✅ Release ${VERSION} deployed + LATEST updated." +else + # Nightly — copy into nightly/ directory + ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/nightly" + scp dist/skald-circle-nightly-darwin-arm64.tar.gz \ + "${REMOTE_HOST}:${REMOTE_BASE}/nightly/" + echo "[package-macos] ✅ Nightly deployed." +fi + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +echo "[package-macos] ─────────────────────────────────────────────" +echo "[package-macos] Mode: ${MODE}" +echo "[package-macos] Version: ${VERSION}" +echo "[package-macos] Branch: ${BRANCH}" +echo "[package-macos] Remote: ${REMOTE_HOST}" +echo "[package-macos] ─────────────────────────────────────────────" +echo "[package-macos] ✅ Done." diff --git a/crates/core-api/src/user_channel.rs b/crates/core-api/src/user_channel.rs index a19b23b..b89cf0b 100644 --- a/crates/core-api/src/user_channel.rs +++ b/crates/core-api/src/user_channel.rs @@ -43,6 +43,16 @@ pub trait UserChannelApi: Send + Sync { /// unknown user or a lookup error returns `false`. async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool; + /// Whether `user_id` currently holds the built-in system **admin** role. + /// + /// The gate for admin-only endpoints a plugin serves from its own HTTP + /// router when the plugin does **not** `manages_own_access` — there + /// [`plugin_access`](Self::plugin_access) is `true` for any granted user, so + /// it cannot stand in for an admin check (unlike a `manages_own_access` + /// plugin, whose grants only ever land on admins). **Fail-closed**: an + /// unknown user or a lookup error returns `false`. + async fn is_admin(&self, user_id: &str) -> bool; + /// Resolves a web **session token** to its user id, or `None` if the token /// is unknown / expired. Lets a channel adapter turn a token the client /// obtained from `POST /api/auth/login` into an authenticated identity — the diff --git a/crates/plugin-honcho/Cargo.toml b/crates/plugin-honcho/Cargo.toml index 8736caa..2671796 100644 --- a/crates/plugin-honcho/Cargo.toml +++ b/crates/plugin-honcho/Cargo.toml @@ -8,7 +8,9 @@ core-api = { path = "../core-api" } honcho-client = { path = "../honcho-client" } anyhow = "1" async-trait = "0.1" +serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" +axum = { version = "0.8" } diff --git a/crates/plugin-honcho/i18n/en.json b/crates/plugin-honcho/i18n/en.json new file mode 100644 index 0000000..58c16c1 --- /dev/null +++ b/crates/plugin-honcho/i18n/en.json @@ -0,0 +1,5 @@ +{ + "plugin.honcho.err.admin_only": "Admin only.", + "plugin.honcho.err.base_url_empty": "Enter the Honcho server URL first.", + "plugin.honcho.err.test_failed": "Could not reach Honcho: {detail}" +} diff --git a/crates/plugin-honcho/i18n/fr.json b/crates/plugin-honcho/i18n/fr.json new file mode 100644 index 0000000..67f0599 --- /dev/null +++ b/crates/plugin-honcho/i18n/fr.json @@ -0,0 +1,5 @@ +{ + "plugin.honcho.err.admin_only": "Administrateur uniquement.", + "plugin.honcho.err.base_url_empty": "Saisissez d'abord l'URL du serveur Honcho.", + "plugin.honcho.err.test_failed": "Impossible de joindre Honcho : {detail}" +} diff --git a/crates/plugin-honcho/i18n/it.json b/crates/plugin-honcho/i18n/it.json new file mode 100644 index 0000000..771c248 --- /dev/null +++ b/crates/plugin-honcho/i18n/it.json @@ -0,0 +1,5 @@ +{ + "plugin.honcho.err.admin_only": "Solo amministratore.", + "plugin.honcho.err.base_url_empty": "Inserisci prima l'URL del server Honcho.", + "plugin.honcho.err.test_failed": "Impossibile raggiungere Honcho: {detail}" +} diff --git a/crates/plugin-honcho/src/i18n.rs b/crates/plugin-honcho/src/i18n.rs new file mode 100644 index 0000000..c8eb64a --- /dev/null +++ b/crates/plugin-honcho/src/i18n.rs @@ -0,0 +1,35 @@ +//! Backend translation bundles for the Honcho plugin. +//! +//! These are the plugin's **backend** strings — the error text its router +//! returns, resolved to the caller's language via `PluginContext.i18n` (see +//! `core_api::i18n`). The frontend fragments' UI strings live separately in +//! `web/i18n.js` (registered client-side); the two sets barely overlap, so each +//! side owns its own table rather than sharing one over an endpoint. +//! +//! The tables ship as JSON embedded at compile time — one file per locale, keys +//! namespaced `plugin.honcho.*`. A malformed file is skipped (its locale simply +//! falls back to English) rather than failing the build path. + +use std::collections::HashMap; + +use core_api::i18n::LocaleBundle; + +/// Every locale bundle this plugin contributes, parsed from the embedded JSON. +pub fn bundles() -> Vec { + [ + ("en", include_str!("../i18n/en.json")), + ("it", include_str!("../i18n/it.json")), + ("fr", include_str!("../i18n/fr.json")), + ] + .into_iter() + .filter_map(|(locale, raw)| { + match serde_json::from_str::>(raw) { + Ok(strings) => Some(LocaleBundle::new(locale, strings)), + Err(e) => { + tracing::warn!(locale, error = %e, "honcho i18n bundle failed to parse"); + None + } + } + }) + .collect() +} diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index bd32991..42af13c 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -44,6 +44,9 @@ //! same mapping without duplication. Keying on `user_id` too is required: local //! session ids are pool-local and collide across users. +mod i18n; +mod router; + use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -58,7 +61,9 @@ use tracing::{debug, info, trace, warn}; use core_api::bus::{BusEvent, ChatEvent, ChatEventRole, RecvError}; use core_api::memory::Memory; -use core_api::plugin::PluginContext; +use core_api::plugin::{PluginContext, PluginPage}; + +use router::{HonchoWeb, WebCell}; use core_api::tool::{ SimpleExecution, Tool, ToolCategory, ToolContext, ToolExecution, ToolResult, }; @@ -759,6 +764,11 @@ pub struct HonchoPlugin { handle: Mutex>>, /// Shared Memory implementation — created once, updated on start/stop. honcho_memory: Arc, + /// Deps the HTTP router (config/opt-in pages + `POST /admin/test`) needs at + /// request time. Handed to the router once at boot as a shared cell; `start` + /// fills it and `stop` clears it, so handlers resolve the current wiring and + /// answer 503 while the plugin is enabled but not running. + web: WebCell, } impl HonchoPlugin { @@ -771,6 +781,7 @@ impl HonchoPlugin { cancel: Mutex::new(None), handle: Mutex::new(None), honcho_memory, + web: Arc::new(Mutex::new(None)), } } } @@ -839,6 +850,45 @@ impl core_api::plugin::Plugin for HonchoPlugin { }) } + /// 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 + /// admin page is `admin_only`; the opt-in page is visible to any user with a + /// `plugin_access` grant — the correct audience for a per-user consent. + fn web_pages(&self) -> Vec { + vec![ + PluginPage { + page_id: "config", + title: "Honcho".into(), + icon: "gear", + entry: "web/config.js".into(), + admin_only: true, + priority: 10, + }, + PluginPage { + page_id: "memory", + title: "Long-term memory".into(), + icon: "stars", + entry: "web/memory.js".into(), + admin_only: false, + priority: 10, + }, + ] + } + + /// Serves the page fragments + the admin `POST /admin/test`. Built once at + /// boot from the shared `web` cell, which `start`/`stop` fill and clear, so + /// the handlers always see the current wiring (and 503 while stopped). + fn http_router(&self) -> Option { + Some(router::build(Arc::clone(&self.web))) + } + + /// Backend translation tables — the router's error strings, namespaced + /// `plugin.honcho.*`. See [`crate::i18n`]. + fn i18n(&self) -> Vec { + crate::i18n::bundles() + } + fn as_any(&self) -> &dyn std::any::Any { self } fn as_arc_any(self: Arc) -> Arc { self } @@ -889,6 +939,12 @@ impl core_api::plugin::Plugin for HonchoPlugin { let workspace_id = cfg.workspace_id.clone(); let user_config = Arc::clone(&ctx.user_config); + // Wire the HTTP router (config/opt-in pages + admin test endpoint). + *self.web.lock().await = Some(HonchoWeb { + user_channel: Arc::clone(&ctx.user_channel), + i18n: Arc::clone(&ctx.i18n), + }); + self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config)); let session_map = Arc::clone(&self.honcho_memory.session_map); @@ -949,6 +1005,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { } self.running.store(false, Ordering::Relaxed); self.honcho_memory.deactivate(); + *self.web.lock().await = None; Ok(()) } } diff --git a/crates/plugin-honcho/src/router.rs b/crates/plugin-honcho/src/router.rs new file mode 100644 index 0000000..75cb6d7 --- /dev/null +++ b/crates/plugin-honcho/src/router.rs @@ -0,0 +1,141 @@ +//! Honcho's HTTP surface, mounted by the main `WebFrontend` under +//! `/api/plugin/honcho/` behind Skald's normal auth + enabled-gate. +//! +//! Deliberately small. It serves the two page fragments (the admin config page +//! and the user opt-in page) and one admin action, `POST /admin/test`, a +//! connectivity check against a candidate config. The opt-in toggle and the +//! config save reuse the **core** plugin endpoints (`PUT /api/plugins/honcho` +//! and `/api/plugins/honcho/my-config`), so nothing about persistence lives +//! here. +//! +//! Honcho does **not** `manages_own_access`, so — unlike mobile-connector — the +//! `plugin_access` grant is *not* an admin check (it is `true` for every granted +//! user). The admin endpoint therefore gates on the real +//! [`UserChannelApi::is_admin`]. +//! +//! Every request resolves the *current* wiring through the shared [`WebCell`] +//! (filled on `start`, cleared on `stop`), so a reconfigure is transparent and a +//! request that arrives while the plugin is enabled-but-not-running gets a clean +//! 503 rather than a stale snapshot. + +use std::sync::Arc; + +use axum::extract::{Extension, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +use core_api::i18n::I18nApi; +use core_api::plugin::Caller; +use core_api::user_channel::UserChannelApi; +use honcho_client::HonchoClient; +use honcho_client::models::{PageParams, WorkspaceGet}; + +// Namespaced i18n keys for the router's user-facing strings (backend tables in +// `../i18n/*.json`), resolved to the caller's language via `web.i18n`. +const KEY_ADMIN_ONLY: &str = "plugin.honcho.err.admin_only"; +const KEY_BASE_URL_EMPTY: &str = "plugin.honcho.err.base_url_empty"; +const KEY_TEST_FAILED: &str = "plugin.honcho.err.test_failed"; + +/// Deps the router needs at request time. +#[derive(Clone)] +pub struct HonchoWeb { + pub user_channel: Arc, + pub i18n: Arc, +} + +/// Shared cell: an `Arc` to a `Mutex` holding the (optional) live wiring. Cloned +/// cheaply and shared between the plugin (`start`/`stop`) and the router. +pub type WebCell = Arc>>; + +/// Build the plugin's router. Takes the shared cell so each request resolves the +/// *current* wiring — not a snapshot from startup. +pub fn build(cell: WebCell) -> Router { + Router::new() + // Page fragments (served as ES modules to the browser). + .route("/web/config.js", get(|| async { serve_js(include_str!("../web/config.js")) })) + .route("/web/memory.js", get(|| async { serve_js(include_str!("../web/memory.js")) })) + .route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) })) + .route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) })) + // Admin: validate a candidate connection before saving it. + .route("/admin/test", post(admin_test)) + // Predisposition for the user page's future "what does Honcho know about + // me?" panel: a `GET /whoami` here would resolve the `Caller`'s user id, + // gate on `opted_in`, and call the live `HonchoMemory` client's + // `peer_chat` (Dialectic) / `peer_context` for that user's peer. Not + // shipped in v1 — the opt-in page needs no backend of its own. + .with_state(cell) +} + +fn serve_js(body: &'static str) -> Response { + ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response() +} + +/// Resolve the live wiring, or `503` while the plugin is enabled but not running. +async fn web_or_503(cell: &WebCell) -> Result { + cell.lock().await.clone().ok_or_else(|| { + (StatusCode::SERVICE_UNAVAILABLE, "honcho is not running").into_response() + }) +} + +/// Fail-closed admin gate for the built-in admin role. +async fn require_admin(web: &HonchoWeb, caller: &Caller) -> Result<(), Response> { + if web.user_channel.is_admin(&caller.user_id).await { + Ok(()) + } else { + let msg = web.i18n.for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await; + Err((StatusCode::FORBIDDEN, msg).into_response()) + } +} + +// ── POST /admin/test ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct TestBody { + #[serde(default)] + base_url: String, + #[serde(default)] + api_key: String, +} + +/// Admin connectivity check against a *candidate* config (the unsaved draft), so +/// an admin can validate a URL/key before saving. Builds a throwaway client and +/// lists workspaces — verifies the URL is reachable and the key is accepted +/// without creating or mutating anything on the server. +async fn admin_test( + State(cell): State, + Extension(caller): Extension, + Json(body): Json, +) -> Response { + let web = match web_or_503(&cell).await { + Ok(w) => w, + Err(r) => return r, + }; + if let Err(r) = require_admin(&web, &caller).await { + return r; + } + + let base_url = body.base_url.trim(); + if base_url.is_empty() { + let msg = web.i18n.for_user(&caller.user_id, KEY_BASE_URL_EMPTY, &[]).await; + return (StatusCode::BAD_REQUEST, msg).into_response(); + } + + let client = HonchoClient::with_base_url(base_url, body.api_key.trim()); + match client + .list_workspaces(&PageParams::default(), &WorkspaceGet::default()) + .await + { + Ok(page) => Json(json!({ "ok": true, "workspaces": page.total })).into_response(), + Err(e) => { + let msg = web + .i18n + .for_user(&caller.user_id, KEY_TEST_FAILED, &[("detail", &e.to_string())]) + .await; + (StatusCode::BAD_GATEWAY, msg).into_response() + } + } +} diff --git a/crates/plugin-honcho/web/common.js b/crates/plugin-honcho/web/common.js new file mode 100644 index 0000000..88894cb --- /dev/null +++ b/crates/plugin-honcho/web/common.js @@ -0,0 +1,46 @@ +// Shared helpers for the Honcho page fragments. +// +// Served at `/api/plugin/honcho/web/common.js` and imported by the two page +// fragments via a relative `./common.js` specifier. Everything the fragments +// need is self-contained here — the host injects no APIs (see the +// `Plugin::web_pages` contract): they talk only to `/api/plugin/honcho/…` and, +// for save/opt-in, the host's core plugin endpoints `/api/plugins/…` (the +// fragment runs with the logged-in user's full session privileges). +// +// i18n: the plugin ships its own dictionary (`./i18n.js`) and registers it into +// the host's shared strings via `addStrings` (imported from the app root by the +// absolute `/lib/i18n.js` specifier — the same module the host app uses, so +// `t()` and `locale-changed` are shared). `HonchoBase` mixes in `I18nMixin` so +// every fragment re-renders on a language switch. Register once, at module load. +import { LitElement } from 'lit'; +import { t, addStrings, I18nMixin } from '/lib/i18n.js'; +import STRINGS from './i18n.js'; + +addStrings(STRINGS); + +export { t }; + +/// 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 (the backend +/// resolves the caller's locale), so it is safe to surface directly. +export 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(); +} + +/// Base for the Honcho fragments: renders into light DOM (so Bootstrap classes +/// and the app's theme CSS variables apply), re-renders on locale change, and +/// exposes the plugin's API root from the host-set `plugin-id` attribute. +export class HonchoBase extends I18nMixin(LitElement) { + createRenderRoot() { return this; } + get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'honcho'}`; } +} diff --git a/crates/plugin-honcho/web/config.js b/crates/plugin-honcho/web/config.js new file mode 100644 index 0000000..0296e75 --- /dev/null +++ b/crates/plugin-honcho/web/config.js @@ -0,0 +1,163 @@ +// Honcho admin config page (page_id `config`, admin_only). +// +// The plugin's dedicated admin surface, richer than the generic +// `#plugin-detail` form: connection config + a "Test connection" check against +// the *current draft* before saving. Persistence reuses the core plugin +// endpoints — `GET /api/plugins` to read the row, `PUT /api/plugins/honcho` to +// save `{enabled, config}` — so nothing is stored through this fragment's own +// backend. Default-exports the element class; the host registers it. +import { html, nothing } from 'lit'; +import { HonchoBase, jf, t } from './common.js'; + +const P = 'plugin.honcho'; +const ID = 'honcho'; + +export default class HonchoConfigPage extends HonchoBase { + static get properties() { + return { + _plugin: { state: true }, // PluginInfo | null + _draft: { state: true }, // { base_url, api_key, workspace_id } + _status: { state: true }, // { ok?, err? } for save + _test: { state: true }, // { busy?, ok?, err? } for the connection test + _error: { state: true }, + _loading: { state: true }, + }; + } + + constructor() { + super(); + this._plugin = null; + this._draft = { base_url: '', api_key: '', workspace_id: '' }; + this._status = {}; + this._test = {}; + this._error = null; + this._loading = true; + } + + connectedCallback() { + super.connectedCallback(); + this._load(); + } + + async _load() { + this._loading = true; + this._error = null; + try { + const all = await jf('/api/plugins'); + const p = (all ?? []).find(x => x.id === ID) ?? null; + if (!p) { this._error = t(`${P}.config.not_found`); this._plugin = null; return; } + this._plugin = p; + this._draft = { + base_url: p.config?.base_url ?? '', + api_key: p.config?.api_key ?? '', + workspace_id: p.config?.workspace_id ?? '', + }; + } catch (e) { + this._error = e.message; + } finally { + this._loading = false; + } + } + + _set(key, value) { + this._draft = { ...this._draft, [key]: value }; + this._status = {}; + this._test = {}; + } + + async _save(enabled) { + this._status = {}; + if (!this._draft.base_url?.trim()) { + this._status = { err: t(`${P}.config.required`) }; + return; + } + try { + await jf(`/api/plugins/${ID}`, { + method: 'PUT', + body: JSON.stringify({ enabled, config: this._draft }), + }); + this._status = { ok: t(`${P}.config.saved`) }; + await this._load(); + window.dispatchEvent(new CustomEvent('plugins-changed')); + } catch (e) { + this._status = { err: e.message }; + } + } + + async _testConnection() { + this._test = { busy: true }; + try { + const r = await jf(`${this.api}/admin/test`, { + method: 'POST', + body: JSON.stringify({ base_url: this._draft.base_url, api_key: this._draft.api_key }), + }); + this._test = { ok: t(`${P}.config.test_ok`, { n: r?.workspaces ?? 0 }) }; + } catch (e) { + this._test = { err: e.message }; + } + } + + render() { + const p = this._plugin; + return html` +
+
+

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

+
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${this._loading + ? html`
${t(`${P}.config.loading`)}
` + : p ? this._renderForm(p) : nothing} +
+
`; + } + + _renderForm(p) { + const d = this._draft; + return html` +

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

+ +
+ this._save(e.target.checked)} /> + +
+ +
+ + this._set('base_url', e.target.value)} /> +
${t(`${P}.config.base_url_hint`)}
+
+ +
+ + this._set('api_key', e.target.value)} /> +
${t(`${P}.config.api_key_hint`)}
+
+ +
+ + this._set('workspace_id', e.target.value)} /> +
${t(`${P}.config.workspace_hint`)}
+
+ + ${this._status.err ? html`
${this._status.err}
` : nothing} + ${this._status.ok ? html`
${this._status.ok}
` : nothing} + ${this._test.err ? html`
${this._test.err}
` : nothing} + ${this._test.ok ? html`
${this._test.ok}
` : nothing} + +
+ + +
`; + } +} diff --git a/crates/plugin-honcho/web/i18n.js b/crates/plugin-honcho/web/i18n.js new file mode 100644 index 0000000..783cf86 --- /dev/null +++ b/crates/plugin-honcho/web/i18n.js @@ -0,0 +1,109 @@ +// Frontend translations for the Honcho page fragments. +// +// Served at `/api/plugin/honcho/web/i18n.js` and imported by `common.js`, which +// registers it into the host's shared dictionaries via `addStrings` (see +// `web/lib/i18n.js`). Keys are namespaced `plugin.honcho.*` so they never +// collide with core keys. These are the *frontend* UI strings; the plugin's +// backend error strings live in `../i18n/*.json` and reach the browser already +// translated as HTTP response text. +const P = 'plugin.honcho'; + +export default { + en: { + // Admin config page + [`${P}.config.title`]: 'Honcho — Long-term memory', + [`${P}.config.intro`]: 'Connect the Honcho memory server. When enabled, each user can opt in from their own Long-term memory page; nothing leaves the box until they do.', + [`${P}.config.enabled`]: 'Plugin enabled', + [`${P}.config.base_url`]: 'Server URL', + [`${P}.config.base_url_hint`]: 'e.g. http://localhost:8000', + [`${P}.config.api_key`]: 'API key', + [`${P}.config.api_key_hint`]: 'Leave empty for a local, unauthenticated instance.', + [`${P}.config.workspace`]: 'Workspace ID', + [`${P}.config.workspace_hint`]:'One shared workspace for the whole instance; each user is a separate peer inside it.', + [`${P}.config.save`]: 'Save', + [`${P}.config.saved`]: 'Saved.', + [`${P}.config.test`]: 'Test connection', + [`${P}.config.testing`]: 'Testing…', + [`${P}.config.test_ok`]: 'Connected — {n} workspace(s) reachable.', + [`${P}.config.required`]: 'The server URL is required.', + [`${P}.config.loading`]: 'Loading…', + [`${P}.config.not_found`]: 'Honcho plugin not found.', + + // User opt-in page + [`${P}.memory.title`]: 'Long-term memory', + [`${P}.memory.intro`]: 'Let the assistant remember you across conversations, so it gets more helpful over time.', + [`${P}.memory.privacy_title`]: 'Before you turn this on', + [`${P}.memory.privacy_body`]: 'Your messages are stored in cleartext on the Honcho memory server, outside your encrypted database. Turn this on only if you are comfortable with that. It is off unless you enable it, and you can turn it off at any time.', + [`${P}.memory.toggle`]: 'Remember me across conversations', + [`${P}.memory.save`]: 'Save', + [`${P}.memory.saved`]: 'Saved.', + [`${P}.memory.loading`]: 'Loading…', + [`${P}.memory.unavailable`]: 'Long-term memory is not available to you yet. Ask your administrator to grant access.', + [`${P}.memory.soon_title`]: 'Coming soon', + [`${P}.memory.soon_body`]: 'Soon you will be able to ask Honcho what it remembers about you, and manage it, right from this page.', + }, + + it: { + [`${P}.config.title`]: 'Honcho — Memoria a lungo termine', + [`${P}.config.intro`]: 'Collega il server di memoria Honcho. Quando è attivo, ogni utente può dare il consenso dalla propria pagina Memoria a lungo termine; finché non lo fa, nulla lascia il box.', + [`${P}.config.enabled`]: 'Plugin attivo', + [`${P}.config.base_url`]: 'URL del server', + [`${P}.config.base_url_hint`]: 'es. http://localhost:8000', + [`${P}.config.api_key`]: 'Chiave API', + [`${P}.config.api_key_hint`]: 'Lascia vuoto per un’istanza locale senza autenticazione.', + [`${P}.config.workspace`]: 'ID workspace', + [`${P}.config.workspace_hint`]:'Un solo workspace condiviso per l’intera istanza; ogni utente è un peer separato al suo interno.', + [`${P}.config.save`]: 'Salva', + [`${P}.config.saved`]: 'Salvato.', + [`${P}.config.test`]: 'Prova connessione', + [`${P}.config.testing`]: 'Verifica…', + [`${P}.config.test_ok`]: 'Connesso — {n} workspace raggiungibili.', + [`${P}.config.required`]: 'L’URL del server è obbligatorio.', + [`${P}.config.loading`]: 'Caricamento…', + [`${P}.config.not_found`]: 'Plugin Honcho non trovato.', + + [`${P}.memory.title`]: 'Memoria a lungo termine', + [`${P}.memory.intro`]: 'Permetti all’assistente di ricordarti tra una conversazione e l’altra, così diventa più utile nel tempo.', + [`${P}.memory.privacy_title`]: 'Prima di attivarla', + [`${P}.memory.privacy_body`]: 'I tuoi messaggi vengono memorizzati in chiaro sul server di memoria Honcho, fuori dal tuo database cifrato. Attivala solo se ti sta bene. È disattivata finché non la abiliti, e puoi disattivarla in qualsiasi momento.', + [`${P}.memory.toggle`]: 'Ricordami tra le conversazioni', + [`${P}.memory.save`]: 'Salva', + [`${P}.memory.saved`]: 'Salvato.', + [`${P}.memory.loading`]: 'Caricamento…', + [`${P}.memory.unavailable`]: 'La memoria a lungo termine non è ancora disponibile per te. Chiedi all’amministratore di darti l’accesso.', + [`${P}.memory.soon_title`]: 'In arrivo', + [`${P}.memory.soon_body`]: 'Presto potrai chiedere a Honcho cosa ricorda di te e gestirlo, direttamente da questa pagina.', + }, + + fr: { + [`${P}.config.title`]: 'Honcho — Mémoire à long terme', + [`${P}.config.intro`]: 'Connectez le serveur de mémoire Honcho. Une fois activé, chaque utilisateur peut consentir depuis sa page Mémoire à long terme ; rien ne quitte la machine tant qu’il ne l’a pas fait.', + [`${P}.config.enabled`]: 'Plugin activé', + [`${P}.config.base_url`]: 'URL du serveur', + [`${P}.config.base_url_hint`]: 'ex. http://localhost:8000', + [`${P}.config.api_key`]: 'Clé API', + [`${P}.config.api_key_hint`]: 'Laissez vide pour une instance locale sans authentification.', + [`${P}.config.workspace`]: 'ID de l’espace', + [`${P}.config.workspace_hint`]:'Un seul espace partagé pour toute l’instance ; chaque utilisateur y est un peer distinct.', + [`${P}.config.save`]: 'Enregistrer', + [`${P}.config.saved`]: 'Enregistré.', + [`${P}.config.test`]: 'Tester la connexion', + [`${P}.config.testing`]: 'Test…', + [`${P}.config.test_ok`]: 'Connecté — {n} espace(s) accessibles.', + [`${P}.config.required`]: 'L’URL du serveur est obligatoire.', + [`${P}.config.loading`]: 'Chargement…', + [`${P}.config.not_found`]: 'Plugin Honcho introuvable.', + + [`${P}.memory.title`]: 'Mémoire à long terme', + [`${P}.memory.intro`]: 'Laissez l’assistant se souvenir de vous d’une conversation à l’autre, pour qu’il devienne plus utile avec le temps.', + [`${P}.memory.privacy_title`]: 'Avant d’activer', + [`${P}.memory.privacy_body`]: 'Vos messages sont stockés en clair sur le serveur de mémoire Honcho, en dehors de votre base chiffrée. N’activez que si cela vous convient. C’est désactivé tant que vous ne l’activez pas, et vous pouvez le désactiver à tout moment.', + [`${P}.memory.toggle`]: 'Se souvenir de moi entre les conversations', + [`${P}.memory.save`]: 'Enregistrer', + [`${P}.memory.saved`]: 'Enregistré.', + [`${P}.memory.loading`]: 'Chargement…', + [`${P}.memory.unavailable`]: 'La mémoire à long terme ne vous est pas encore accessible. Demandez l’accès à votre administrateur.', + [`${P}.memory.soon_title`]: 'Bientôt disponible', + [`${P}.memory.soon_body`]: 'Bientôt, vous pourrez demander à Honcho ce qu’il retient de vous et le gérer, directement depuis cette page.', + }, +}; diff --git a/crates/plugin-honcho/web/memory.js b/crates/plugin-honcho/web/memory.js new file mode 100644 index 0000000..6776838 --- /dev/null +++ b/crates/plugin-honcho/web/memory.js @@ -0,0 +1,135 @@ +// Honcho user opt-in page (page_id `memory`, visible to any user with a +// `plugin_access` grant). +// +// The per-user consent to long-term memory. Reuses the core per-user config +// endpoints — `GET /api/plugins/mine` to read the current flag, +// `PUT /api/plugins/honcho/my-config` to save `{ enabled }` — so this fragment +// needs no backend of its own. Structured in sections so the future "what does +// Honcho know about me?" panel is a drop-in addition (see the `soon` section). +// Default-exports the element class; the host registers it. +import { html, nothing } from 'lit'; +import { HonchoBase, jf, t } from './common.js'; + +const P = 'plugin.honcho'; +const ID = 'honcho'; + +export default class HonchoMemoryPage extends HonchoBase { + static get properties() { + return { + _row: { state: true }, // UserPluginView | null (null once loaded = not granted) + _enabled: { state: true }, // draft toggle + _status: { state: true }, // { ok?, err? } + _error: { state: true }, + _loading: { state: true }, + }; + } + + constructor() { + super(); + this._row = null; + this._enabled = false; + 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'); + const row = (mine ?? []).find(x => x.id === ID) ?? null; + this._row = row; + this._enabled = !!row?.user_config?.enabled; + } 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({ enabled: this._enabled }), + }); + this._status = { ok: t(`${P}.memory.saved`) }; + await this._load(); + } catch (e) { + this._status = { err: e.message }; + } + } + + render() { + return html` +
+
+

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

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

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

+
`; + } + + _renderBody() { + return html` +

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

+ +
+
+ ${t(`${P}.memory.privacy_title`)} +
+
+ ${t(`${P}.memory.privacy_body`)} +
+
+ +
+ { this._enabled = e.target.checked; this._status = {}; }} /> + +
+ + ${this._status.err ? html`
${this._status.err}
` : nothing} + ${this._status.ok ? html`
${this._status.ok}
` : nothing} + + + + ${this._renderSoon()}`; + } + + // Placeholder for the future "what does Honcho know about me?" panel. When + // built, this section gains a button that calls a new `GET ${this.api}/whoami` + // (opt-in-gated) and renders the returned summary; only this method + that one + // route change. + _renderSoon() { + if (!this._enabled) return nothing; + return html` +
+
+
${t(`${P}.memory.soon_title`)}
+
${t(`${P}.memory.soon_body`)}
+
`; + } +} diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index b8901a9..13909a4 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -66,6 +66,10 @@ pub struct PluginPageInfo { pub icon: String, pub priority: i32, pub entry_url: String, + /// Mirrors [`core_api::plugin::PluginPage::admin_only`]. Lets the admin + /// Plugins UI recognise a plugin's own config page and defer to it (hide the + /// generic `config_schema` form, link out instead). + pub admin_only: bool, /// Fragment-contract version the host speaks. Always 1 for now — bump when /// the contract changes so old hosts can refuse new fragments cleanly. pub api_version: u32, @@ -493,6 +497,7 @@ impl PluginManager { icon: page.icon.to_string(), priority: page.priority, entry_url: format!("/api/plugin/{}/{}", plugin.id(), page.entry), + admin_only: page.admin_only, api_version: 1, }); } diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index d780f3a..1ec6e19 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -186,6 +186,18 @@ impl UserChannelApi for Skald { .unwrap_or(false) } + async fn is_admin(&self, user_id: &str) -> bool { + // Built-in admin role; an unknown user or a lookup error fails closed. + sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(self.db().as_ref()) + .await + .ok() + .flatten() + .map(|(r,)| r == crate::db::roles::ADMIN_ROLE_ID) + .unwrap_or(false) + } + async fn user_for_session(&self, token: &str) -> Option { self.sessions().user_of(token) } diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js index 3a11758..5e7abc2 100644 --- a/web/components/plugin-detail.js +++ b/web/components/plugin-detail.js @@ -28,6 +28,7 @@ export class PluginDetailPage extends LightElement { _open: { state: true }, _id: { state: true }, _plugin: { state: true }, // PluginInfo + _customPage: { state: true }, // this plugin's own admin page, or null _error: { state: true }, _draft: { state: true }, // config form draft _status: { state: true }, // { ok?: string, err?: string } @@ -47,6 +48,7 @@ export class PluginDetailPage extends LightElement { _reset() { this._id = null; this._plugin = null; + this._customPage = null; this._error = null; this._draft = null; this._status = {}; @@ -95,6 +97,12 @@ export class PluginDetailPage extends LightElement { return; } this._plugin = p; + // If the plugin ships its own admin page (an `admin_only` web-page), the + // generic config form defers to it — see `_renderConfig`. + try { + const pages = await jf('/api/plugins/pages'); + this._customPage = (pages ?? []).find(pg => pg.plugin_id === this._id && pg.admin_only) ?? null; + } catch { this._customPage = null; } // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; // Binding-managed plugins (e.g. mobile-connector) gate access through @@ -244,8 +252,31 @@ export class PluginDetailPage extends LightElement { `; } + _openCustomPage(e, route) { + e.preventDefault(); + history.pushState({ page: route }, '', '#' + route); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: route } })); + } + + _renderConfigLink() { + const cp = this._customPage; + const route = `plugin/${cp.plugin_id}/${cp.page_id}`; + return html` +
+
+

${t('plugins.detail.config.title')}

+
+
${t('plugins.detail.config.custom_page')}
+ this._openCustomPage(e, route)}> + ${t('plugins.detail.config.open')} + +
`; + } + _renderConfig() { const p = this._plugin; + // Defer to the plugin's own admin page when it ships one. + if (this._customPage) return this._renderConfigLink(); const fields = schemaFields(p.config_schema); const draft = this._draft || {}; return html` diff --git a/web/i18n/en.js b/web/i18n/en.js index 0589870..a351fbc 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -881,6 +881,8 @@ export default { 'plugins.detail.back': 'Back to catalog', '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.', + 'plugins.detail.config.open': 'Open configuration', 'plugins.detail.access.title': 'User access', 'plugins.detail.not_found': 'No plugin named "{id}".', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 34337cf..f9294de 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -871,6 +871,8 @@ export default { 'plugins.detail.back': 'Retour au catalogue', '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.', + 'plugins.detail.config.open': 'Ouvrir la configuration', 'plugins.detail.access.title': 'Accès utilisateurs', 'plugins.detail.not_found': 'Aucun plugin nommé « {id} ».', diff --git a/web/i18n/it.js b/web/i18n/it.js index 89ed995..22268ef 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -871,6 +871,8 @@ export default { 'plugins.detail.back': 'Torna al catalogo', '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.', + 'plugins.detail.config.open': 'Apri configurazione', 'plugins.detail.access.title': 'Accesso utenti', 'plugins.detail.not_found': 'Nessun plugin chiamato "{id}".',