Nightly Build / build (push) Successful in 2m24s
The opt-in page gains a debug panel, once the user's saved flag is on: service status (reachability, latency, the caller's own processing queue, with the specific Honcho error when something is wrong), a full overview (peer card, derived facts with ids, summary) and one text field with two actions — search (raw ranked facts) and ask (Honcho's server-side LLM answers), plus an in-page mini-guide. Every endpoint gates on the per-user opt-in server-side, fail closed, and derives the peer from the authenticated Caller — never from the request body — since the workspace is shared. Honcho 404s are translated per-endpoint as 'no memory yet' rather than failures.
380 lines
15 KiB
JavaScript
380 lines
15 KiB
JavaScript
// Honcho user opt-in page (page_id `memory`, visible to any user with a
|
|
// `plugin_access` grant).
|
|
//
|
|
// Two halves:
|
|
//
|
|
// 1. 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 }`.
|
|
// 2. Once opted in (saved flag, not the draft toggle): the "what does Honcho
|
|
// remember about me?" debug panel, backed by this plugin's own opt-in-gated
|
|
// endpoints — `GET ${api}/status` (service health + the caller's own
|
|
// processing queue), `GET ${api}/overview` (card + facts + summary, no
|
|
// input), and one text field with two actions: `POST ${api}/search` (raw
|
|
// ranked facts) and `POST ${api}/ask` (Honcho's server-side LLM answers).
|
|
// The built-in mini-guide explains the difference, because "words → facts"
|
|
// vs "question → AI answer" is not obvious.
|
|
//
|
|
// Errors from these endpoints arrive already localized *and specific* (the
|
|
// backend forwards the real Honcho transport/HTTP detail) — they are surfaced
|
|
// verbatim, never as a generic "unavailable".
|
|
//
|
|
// 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? } for the opt-in save
|
|
_error: { state: true },
|
|
_loading: { state: true },
|
|
// Debug panel (only used once the *saved* opt-in flag is on).
|
|
_svc: { state: true }, // null | { ok, latency_ms?, queue? } | { ok:false, error }
|
|
_svcBusy: { state: true },
|
|
_ov: { state: true }, // null | { card, conclusions, summary }
|
|
_ovBusy: { state: true },
|
|
_ovErr: { state: true }, // string | null
|
|
_q: { state: true }, // query input value
|
|
_qBusy: { state: true }, // null | 'search' | 'ask'
|
|
_qRes: { state: true }, // null | { kind:'search', conclusions, empty } | { kind:'ask', answer }
|
|
_qErr: { state: true }, // string | null
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._row = null;
|
|
this._enabled = false;
|
|
this._status = {};
|
|
this._error = null;
|
|
this._loading = true;
|
|
this._svc = null;
|
|
this._svcBusy = false;
|
|
this._ov = null;
|
|
this._ovBusy = false;
|
|
this._ovErr = null;
|
|
this._q = '';
|
|
this._qBusy = null;
|
|
this._qRes = null;
|
|
this._qErr = null;
|
|
}
|
|
|
|
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;
|
|
// The panel reads the *saved* flag; when it just turned on (save → reload)
|
|
// this is also what triggers the first fetch of panel data.
|
|
if (row?.user_config?.enabled) {
|
|
this._refreshStatus();
|
|
this._refreshOverview();
|
|
}
|
|
} 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 };
|
|
}
|
|
}
|
|
|
|
// ── Debug panel: data ─────────────────────────────────────────────────────
|
|
|
|
async _refreshStatus() {
|
|
this._svcBusy = true;
|
|
try {
|
|
// 200 with { ok:false, error } when Honcho is down — the badge wants the
|
|
// specific message, not an exception. Other statuses (503, 403…) still
|
|
// throw and land in the same place.
|
|
this._svc = await jf(`${this.api}/status`);
|
|
} catch (e) {
|
|
this._svc = { ok: false, error: e.message };
|
|
} finally {
|
|
this._svcBusy = false;
|
|
}
|
|
}
|
|
|
|
async _refreshOverview() {
|
|
this._ovBusy = true;
|
|
this._ovErr = null;
|
|
try {
|
|
this._ov = await jf(`${this.api}/overview`);
|
|
} catch (e) {
|
|
this._ovErr = e.message;
|
|
} finally {
|
|
this._ovBusy = false;
|
|
}
|
|
}
|
|
|
|
async _run(kind) {
|
|
const q = this._q.trim();
|
|
if (!q || this._qBusy) return;
|
|
this._qBusy = kind;
|
|
this._qErr = null;
|
|
this._qRes = null;
|
|
try {
|
|
const r = await jf(`${this.api}/${kind}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ query: q }),
|
|
});
|
|
this._qRes = kind === 'search'
|
|
? { kind, conclusions: r?.conclusions ?? [], empty: !!r?.empty }
|
|
: { kind, answer: r?.answer ?? '' };
|
|
} catch (e) {
|
|
this._qErr = e.message;
|
|
} finally {
|
|
this._qBusy = null;
|
|
}
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
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._row?.user_config?.enabled ? this._renderPanel() : nothing}`;
|
|
}
|
|
|
|
// ── Debug panel ───────────────────────────────────────────────────────────
|
|
|
|
_sectionTitle(icon, key, extra = nothing) {
|
|
return html`
|
|
<div class="d-flex align-items-center justify-content-between mt-1">
|
|
<div style="font-size:.85rem; font-weight:600"><i class="bi ${icon} me-1"></i>${t(`${P}.${key}`)}</div>
|
|
${extra}
|
|
</div>`;
|
|
}
|
|
|
|
_renderPanel() {
|
|
return html`
|
|
<hr class="my-4" style="opacity:.15" />
|
|
${this._sectionTitle('bi-person-lines-fill', 'panel.title')}
|
|
<div class="mt-3">${this._renderGuide()}</div>
|
|
<div class="mt-3">${this._renderStatus()}</div>
|
|
<div class="mt-3">${this._renderOverview()}</div>
|
|
<div class="mt-3">${this._renderQuery()}</div>`;
|
|
}
|
|
|
|
_renderGuide() {
|
|
const row = (icon, key) => html`
|
|
<div class="d-flex gap-2" style="font-size:.8rem">
|
|
<i class="bi ${icon} mt-1" style="opacity:.6"></i>
|
|
<div>${t(`${P}.panel.${key}`)}</div>
|
|
</div>`;
|
|
return html`
|
|
<div style="border:1px solid var(--bs-border-color); border-radius:var(--radius-sm, .375rem); padding:.65rem .8rem">
|
|
<div style="font-size:.8rem; font-weight:600; margin-bottom:.35rem">${t(`${P}.panel.guide_title`)}</div>
|
|
<div class="d-flex flex-column gap-2">
|
|
${row('bi-list-stars', 'guide_overview')}
|
|
${row('bi-search', 'guide_search')}
|
|
${row('bi-chat-left-text', 'guide_ask')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
_renderStatus() {
|
|
const s = this._svc;
|
|
const refresh = html`
|
|
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._svcBusy}
|
|
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshStatus()}>
|
|
<i class="bi ${this._svcBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
|
|
</button>`;
|
|
let body;
|
|
if (!s && this._svcBusy) {
|
|
body = html`<span class="text-body-secondary" style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></span>`;
|
|
} else if (s?.ok) {
|
|
const q = s.queue ?? {};
|
|
body = html`
|
|
<div>
|
|
<span class="badge text-bg-success">${t(`${P}.panel.status_ok`)} · ${s.latency_ms ?? '?'} ms</span>
|
|
<div class="text-body-secondary" style="font-size:.75rem; margin-top:.3rem">
|
|
${t(`${P}.panel.status_queue`, { wip: q.in_progress ?? 0, pending: q.pending ?? 0, done: q.completed ?? 0 })}
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
body = html`
|
|
<div>
|
|
<span class="badge text-bg-danger">${t(`${P}.panel.status_down`)}</span>
|
|
<div class="text-danger" style="font-size:.75rem; margin-top:.3rem">${s?.error}</div>
|
|
</div>`;
|
|
}
|
|
return html`
|
|
${this._sectionTitle('bi-activity', 'panel.status_title', refresh)}
|
|
<div class="mt-2">${body}</div>`;
|
|
}
|
|
|
|
// Normalize the peer card into renderable pieces: an array (or an object with
|
|
// an array under a known key) becomes items; anything else is shown as JSON.
|
|
_cardItems(card) {
|
|
if (card == null) return null;
|
|
if (Array.isArray(card)) return card.length ? card : null;
|
|
if (typeof card === 'object') {
|
|
for (const k of ['card', 'facts', 'items']) {
|
|
if (Array.isArray(card[k]) && card[k].length) return card[k];
|
|
}
|
|
return { raw: JSON.stringify(card, null, 2) };
|
|
}
|
|
if (typeof card === 'string') return card.trim() ? [card] : null;
|
|
return null;
|
|
}
|
|
|
|
_renderOverview() {
|
|
const refresh = html`
|
|
<button class="btn btn-outline-secondary btn-sm py-0" ?disabled=${this._ovBusy}
|
|
title=${t(`${P}.panel.refresh`)} @click=${() => this._refreshOverview()}>
|
|
<i class="bi ${this._ovBusy ? 'bi-arrow-repeat' : 'bi-arrow-clockwise'}"></i>
|
|
</button>`;
|
|
let body;
|
|
if (this._ovErr) {
|
|
body = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._ovErr}</div>`;
|
|
} else if (!this._ov && this._ovBusy) {
|
|
body = html`<div class="um-empty" style="padding:.5rem"><i class="bi bi-hourglass-split"></i></div>`;
|
|
} else if (this._ov) {
|
|
const conclusions = this._ov.conclusions ?? [];
|
|
const card = this._cardItems(this._ov.card);
|
|
const summary = (this._ov.summary ?? '').trim();
|
|
if (!card && !conclusions.length && !summary) {
|
|
body = html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`;
|
|
} else {
|
|
body = html`
|
|
${card ? html`
|
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.card_title`)}</div>
|
|
${Array.isArray(card)
|
|
? html`<ul class="mb-2" style="font-size:.82rem">${card.map((c, i) => html`<li key=${i}>${typeof c === 'string' ? c : JSON.stringify(c)}</li>`)}</ul>`
|
|
: html`<pre class="mb-2" style="font-size:.72rem; white-space:pre-wrap">${card.raw}</pre>`}
|
|
` : nothing}
|
|
${conclusions.length ? html`
|
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.facts_title`)}</div>
|
|
<ul class="mb-2" style="font-size:.82rem">${conclusions.map(this._factLi)}</ul>
|
|
` : nothing}
|
|
${summary ? html`
|
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.summary_title`)}</div>
|
|
<div style="font-size:.82rem; white-space:pre-wrap">${summary}</div>
|
|
` : nothing}`;
|
|
}
|
|
} else {
|
|
body = nothing;
|
|
}
|
|
return html`
|
|
${this._sectionTitle('bi-list-stars', 'panel.overview_title', refresh)}
|
|
<div class="mt-2">${body}</div>`;
|
|
}
|
|
|
|
_factLi(c) {
|
|
const content = c?.content ?? '';
|
|
const id = c?.id;
|
|
return html`<li style="margin-bottom:.2rem">
|
|
${id ? html`<code style="font-size:.68rem; opacity:.55">${id}</code> ` : nothing}${content}
|
|
</li>`;
|
|
}
|
|
|
|
_renderQuery() {
|
|
const busy = !!this._qBusy;
|
|
let result = nothing;
|
|
if (this._qErr) {
|
|
result = html`<div class="alert alert-danger py-2" style="font-size:.8rem">${this._qErr}</div>`;
|
|
} else if (this._qRes?.kind === 'search') {
|
|
result = this._qRes.empty
|
|
? html`<div class="alert alert-info py-2" style="font-size:.8rem">${t(`${P}.panel.no_memory`)}</div>`
|
|
: this._qRes.conclusions.length
|
|
? html`<ul style="font-size:.82rem">${this._qRes.conclusions.map(this._factLi)}</ul>`
|
|
: html`<div class="text-body-secondary" style="font-size:.8rem">${t(`${P}.panel.search_empty`)}</div>`;
|
|
} else if (this._qRes?.kind === 'ask') {
|
|
result = html`
|
|
<div style="font-size:.75rem; font-weight:600" class="text-body-secondary">${t(`${P}.panel.answer_title`)}</div>
|
|
<div style="font-size:.85rem; white-space:pre-wrap">${this._qRes.answer}</div>`;
|
|
}
|
|
return html`
|
|
${this._sectionTitle('bi-chat-left-text', 'panel.query_title')}
|
|
<input class="form-control form-control-sm mt-2" type="text"
|
|
placeholder=${t(`${P}.panel.query_hint`)} .value=${this._q}
|
|
@input=${(e) => { this._q = e.target.value; }}
|
|
@keydown=${(e) => { if (e.key === 'Enter') this._run('search'); }} />
|
|
<div class="d-flex align-items-center gap-2 mt-2">
|
|
<button class="btn btn-outline-primary btn-sm" ?disabled=${busy || !this._q.trim()}
|
|
@click=${() => this._run('search')}>
|
|
<i class="bi bi-search me-1"></i>${this._qBusy === 'search' ? t(`${P}.panel.searching`) : t(`${P}.panel.search_btn`)}
|
|
</button>
|
|
<button class="btn btn-primary btn-sm" ?disabled=${busy || !this._q.trim()}
|
|
@click=${() => this._run('ask')}>
|
|
<i class="bi bi-chat-left-dots me-1"></i>${this._qBusy === 'ask' ? t(`${P}.panel.asking`) : t(`${P}.panel.ask_btn`)}
|
|
</button>
|
|
${this._qBusy ? html`<i class="bi bi-hourglass-split text-body-secondary"></i>` : nothing}
|
|
</div>
|
|
${this._qRes || this._qErr ? html`<div class="mt-3">${result}</div>` : nothing}`;
|
|
}
|
|
}
|