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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user