honcho: plugin web pages with i18n, defer plugin detail to custom admin page
Nightly Build / build (push) Failing after 6m13s

This commit is contained in:
2026-07-20 15:29:26 +01:00
parent 8edac4de99
commit 6040f9a339
22 changed files with 927 additions and 2 deletions
+2
View File
@@ -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" }
+5
View File
@@ -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}"
}
+5
View File
@@ -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}"
}
+5
View File
@@ -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}"
}
+35
View File
@@ -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()
}
+58 -1
View File
@@ -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(())
}
}
+141
View File
@@ -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()
}
}
}
+46
View File
@@ -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'}`; }
}
+163
View File
@@ -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>`;
}
}
+109
View File
@@ -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 unistanza locale senza autenticazione.',
[`${P}.config.workspace`]: 'ID workspace',
[`${P}.config.workspace_hint`]:'Un solo workspace condiviso per lintera 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`]: 'LURL 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 allassistente di ricordarti tra una conversazione e laltra, 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 allamministratore di darti laccesso.',
[`${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 quil ne la 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 lespace',
[`${P}.config.workspace_hint`]:'Un seul espace partagé pour toute linstance ; 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`]: 'LURL 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 lassistant se souvenir de vous dune conversation à lautre, pour quil devienne plus utile avec le temps.',
[`${P}.memory.privacy_title`]: 'Avant dactiver',
[`${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. Nactivez que si cela vous convient. Cest désactivé tant que vous ne lactivez 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 laccès à votre administrateur.',
[`${P}.memory.soon_title`]: 'Bientôt disponible',
[`${P}.memory.soon_body`]: 'Bientôt, vous pourrez demander à Honcho ce quil retient de vous et le gérer, directement depuis cette page.',
},
};
+135
View File
@@ -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>`;
}
}