honcho: plugin web pages with i18n, defer plugin detail to custom admin page
Nightly Build / build (push) Failing after 6m13s
Nightly Build / build (push) Failing after 6m13s
This commit is contained in:
@@ -25,6 +25,8 @@ blueprint/
|
||||
/target/
|
||||
/deploy/
|
||||
# Binary installed by ./build.sh, executed by ./run.sh
|
||||
# ── Build output ──────────────────────────────────────────────────────────────
|
||||
/dist/
|
||||
/bin/
|
||||
|
||||
# ── Python environment ────────────────────────────────────────────────────────
|
||||
|
||||
Generated
+2
@@ -2950,8 +2950,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"core-api",
|
||||
"honcho-client",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+117
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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<LocaleBundle> {
|
||||
[
|
||||
("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::<HashMap<String, String>>(raw) {
|
||||
Ok(strings) => Some(LocaleBundle::new(locale, strings)),
|
||||
Err(e) => {
|
||||
tracing::warn!(locale, error = %e, "honcho i18n bundle failed to parse");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -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<Option<JoinHandle<()>>>,
|
||||
/// Shared Memory implementation — created once, updated on start/stop.
|
||||
honcho_memory: Arc<HonchoMemory>,
|
||||
/// 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<PluginPage> {
|
||||
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<axum::Router> {
|
||||
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<core_api::i18n::LocaleBundle> {
|
||||
crate::i18n::bundles()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn UserChannelApi>,
|
||||
pub i18n: Arc<dyn I18nApi>,
|
||||
}
|
||||
|
||||
/// 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<tokio::sync::Mutex<Option<HonchoWeb>>>;
|
||||
|
||||
/// 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<HonchoWeb, Response> {
|
||||
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<WebCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<TestBody>,
|
||||
) -> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}`; }
|
||||
}
|
||||
@@ -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`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-stars me-2"></i>${t(`${P}.config.title`)}</h2>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 2rem; max-width:640px; overflow:auto">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${this._loading
|
||||
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.config.loading`)}</div>`
|
||||
: p ? this._renderForm(p) : nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderForm(p) {
|
||||
const d = this._draft;
|
||||
return html`
|
||||
<p class="text-body-secondary" style="font-size:.9rem">${t(`${P}.config.intro`)}</p>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="honcho-enabled"
|
||||
.checked=${!!p.enabled} @change=${(e) => this._save(e.target.checked)} />
|
||||
<label class="form-check-label" for="honcho-enabled" style="font-size:.85rem">${t(`${P}.config.enabled`)}</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t(`${P}.config.base_url`)}<span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="text" .value=${d.base_url}
|
||||
@input=${(e) => this._set('base_url', e.target.value)} />
|
||||
<div class="form-text" style="font-size:.72rem">${t(`${P}.config.base_url_hint`)}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t(`${P}.config.api_key`)}</label>
|
||||
<input class="form-control" type="password" autocomplete="off" .value=${d.api_key}
|
||||
@input=${(e) => this._set('api_key', e.target.value)} />
|
||||
<div class="form-text" style="font-size:.72rem">${t(`${P}.config.api_key_hint`)}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t(`${P}.config.workspace`)}</label>
|
||||
<input class="form-control" type="text" .value=${d.workspace_id}
|
||||
@input=${(e) => this._set('workspace_id', e.target.value)} />
|
||||
<div class="form-text" style="font-size:.72rem">${t(`${P}.config.workspace_hint`)}</div>
|
||||
</div>
|
||||
|
||||
${this._status.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._status.err}</div>` : nothing}
|
||||
${this._status.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${this._status.ok}</div>` : nothing}
|
||||
${this._test.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._test.err}</div>` : nothing}
|
||||
${this._test.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem"><i class="bi bi-check-circle me-1"></i>${this._test.ok}</div>` : nothing}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click=${() => this._save(p.enabled)}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t(`${P}.config.save`)}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" ?disabled=${this._test.busy}
|
||||
@click=${() => this._testConnection()}>
|
||||
<i class="bi bi-plug me-1"></i>${this._test.busy ? t(`${P}.config.testing`) : t(`${P}.config.test`)}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
@@ -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`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-stars me-2"></i>${t(`${P}.memory.title`)}</h2>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 2rem; max-width:640px; overflow:auto">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${this._loading
|
||||
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.memory.loading`)}</div>`
|
||||
: this._row ? this._renderBody() : this._renderUnavailable()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderUnavailable() {
|
||||
return html`
|
||||
<div class="um-empty" style="padding:1rem">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
<p>${t(`${P}.memory.unavailable`)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderBody() {
|
||||
return html`
|
||||
<p class="text-body-secondary" style="font-size:.9rem">${t(`${P}.memory.intro`)}</p>
|
||||
|
||||
<div class="connector-card" style="cursor:default; border-color:var(--warning, #e0a800)">
|
||||
<div class="connector-card-name" style="font-size:.9rem">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${t(`${P}.memory.privacy_title`)}
|
||||
</div>
|
||||
<div class="connector-card-desc" style="-webkit-line-clamp:initial; margin-top:.35rem">
|
||||
${t(`${P}.memory.privacy_body`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch my-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="honcho-optin"
|
||||
.checked=${this._enabled} @change=${(e) => { this._enabled = e.target.checked; this._status = {}; }} />
|
||||
<label class="form-check-label" for="honcho-optin" style="font-size:.9rem">${t(`${P}.memory.toggle`)}</label>
|
||||
</div>
|
||||
|
||||
${this._status.err ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._status.err}</div>` : nothing}
|
||||
${this._status.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${this._status.ok}</div>` : nothing}
|
||||
|
||||
<button class="btn btn-primary btn-sm" @click=${() => this._save()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t(`${P}.memory.save`)}
|
||||
</button>
|
||||
|
||||
${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`
|
||||
<hr class="my-4" style="opacity:.15" />
|
||||
<div style="opacity:.7">
|
||||
<div style="font-size:.85rem; font-weight:600"><i class="bi bi-hourglass-split me-1"></i>${t(`${P}.memory.soon_title`)}</div>
|
||||
<div class="text-body-secondary" style="font-size:.82rem; margin-top:.25rem">${t(`${P}.memory.soon_body`)}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
self.sessions().user_of(token)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div style="margin-top:1.5rem">
|
||||
<div class="um-header" style="padding:0 0 .5rem">
|
||||
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-sliders me-2"></i>${t('plugins.detail.config.title')}</h3>
|
||||
</div>
|
||||
<div class="text-muted mb-2" style="font-size:.82rem">${t('plugins.detail.config.custom_page')}</div>
|
||||
<a class="btn btn-sm btn-primary" href="#${route}" @click=${(e) => this._openCustomPage(e, route)}>
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>${t('plugins.detail.config.open')}
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_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`
|
||||
|
||||
@@ -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}".',
|
||||
|
||||
|
||||
@@ -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} ».',
|
||||
|
||||
|
||||
@@ -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}".',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user