feat(plugin-honcho): show what Honcho remembers about you
Nightly Build / build (push) Successful in 2m24s
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.
This commit is contained in:
@@ -764,10 +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.
|
||||
/// Deps the HTTP router (config/opt-in pages, `POST /admin/test`, and the
|
||||
/// opt-in-gated introspection endpoints) 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,
|
||||
}
|
||||
|
||||
@@ -920,10 +921,14 @@ 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).
|
||||
// Wire the HTTP router (config/opt-in pages + the admin test and the
|
||||
// opt-in-gated introspection endpoints).
|
||||
*self.web.lock().await = Some(HonchoWeb {
|
||||
user_channel: Arc::clone(&ctx.user_channel),
|
||||
i18n: Arc::clone(&ctx.i18n),
|
||||
client: Arc::clone(&client),
|
||||
workspace_id: workspace_id.clone(),
|
||||
user_config: Arc::clone(&user_config),
|
||||
});
|
||||
|
||||
self.honcho_memory.activate(Arc::clone(&client), workspace_id.clone(), Arc::clone(&user_config));
|
||||
|
||||
@@ -2,16 +2,43 @@
|
||||
//! `/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.
|
||||
//! and the user opt-in page) and:
|
||||
//!
|
||||
//! - `POST /admin/test` — admin connectivity check against a candidate config.
|
||||
//! - `GET /status` — user-facing service health (reachability + the
|
||||
//! caller's own processing queue).
|
||||
//! - `GET /overview` — the caller's full memory snapshot (peer card +
|
||||
//! conclusions + summary). Cheap GETs, no LLM.
|
||||
//! - `POST /search` — semantic search over the caller's derived facts.
|
||||
//! - `POST /ask` — Dialectic: Honcho's server-side LLM answers a
|
||||
//! natural-language question from the caller's memory.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! # Multi-user boundary (the workspace is shared)
|
||||
//!
|
||||
//! Every introspection handler derives the Honcho peer from the authenticated
|
||||
//! [`Caller`]'s user id via [`require_peer`] — never from the request body —
|
||||
//! and the workspace id from server config. A client can therefore never name
|
||||
//! another user's peer, and a bug in a handler can't either: the peer id is
|
||||
//! handed to the handler already resolved.
|
||||
//!
|
||||
//! # Error reporting
|
||||
//!
|
||||
//! This page exists to *debug* the integration, so errors are specific, not
|
||||
//! "service unavailable": transport failures and Honcho HTTP errors are
|
||||
//! localized with the real detail forwarded (see [`honcho_error`] — the body is
|
||||
//! truncated, not swallowed). The one status code that is *not* an error is
|
||||
//! Honcho's 404: for a just-opted-in user with no traffic yet it means "no
|
||||
//! memory about you yet", and each handler translates it accordingly.
|
||||
//!
|
||||
//! 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`].
|
||||
//! [`UserChannelApi::is_admin`]; the introspection endpoints gate on the
|
||||
//! per-user **opt-in** flag instead (fail closed, like the tools).
|
||||
//!
|
||||
//! Every request resolves the *current* wiring through the shared [`WebCell`]
|
||||
//! (filled on `start`, cleared on `stop`), so a reconfigure is transparent and a
|
||||
@@ -19,6 +46,7 @@
|
||||
//! 503 rather than a stale snapshot.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
@@ -26,25 +54,49 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::debug;
|
||||
|
||||
use core_api::i18n::I18nApi;
|
||||
use core_api::plugin::Caller;
|
||||
use core_api::user_channel::UserChannelApi;
|
||||
use core_api::user_plugin_config::PluginUserConfigApi;
|
||||
use honcho_client::HonchoClient;
|
||||
use honcho_client::models::{PageParams, WorkspaceGet};
|
||||
use honcho_client::error::HonchoError;
|
||||
use honcho_client::models::{DialecticOptions, PageParams, PeerRepresentationGet, 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";
|
||||
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";
|
||||
const KEY_NOT_OPTED_IN: &str = "plugin.honcho.err.not_opted_in";
|
||||
const KEY_QUERY_REQUIRED: &str = "plugin.honcho.err.query_required";
|
||||
const KEY_HONCHO_UNREACHABLE: &str = "plugin.honcho.err.honcho_unreachable";
|
||||
const KEY_HONCHO_ERROR: &str = "plugin.honcho.err.honcho_error";
|
||||
const KEY_NO_DATA: &str = "plugin.honcho.err.no_data";
|
||||
|
||||
/// Max characters of a Honcho error body forwarded to the user — enough to stay
|
||||
/// specific, short enough not to flood the page with a server stack dump.
|
||||
const ERR_DETAIL_MAX: usize = 300;
|
||||
|
||||
/// Conclusions shown in the overview snapshot (the debug page wants more than
|
||||
/// the read-path's token-budgeted subset).
|
||||
const OVERVIEW_MAX_CONCLUSIONS: u32 = 50;
|
||||
/// Facts returned by `/search` (ranked, raw excerpts).
|
||||
const SEARCH_TOP_K: u32 = 20;
|
||||
|
||||
/// Deps the router needs at request time.
|
||||
#[derive(Clone)]
|
||||
pub struct HonchoWeb {
|
||||
pub user_channel: Arc<dyn UserChannelApi>,
|
||||
pub i18n: Arc<dyn I18nApi>,
|
||||
/// Live Honcho client — the same one the memory read/write paths use.
|
||||
pub client: Arc<HonchoClient>,
|
||||
/// The instance's shared workspace id, from server config.
|
||||
pub workspace_id: String,
|
||||
/// Per-user opt-in store; gates every introspection endpoint.
|
||||
pub user_config: Arc<dyn PluginUserConfigApi>,
|
||||
}
|
||||
|
||||
/// Shared cell: an `Arc` to a `Mutex` holding the (optional) live wiring. Cloned
|
||||
@@ -62,11 +114,11 @@ pub fn build(cell: WebCell) -> Router {
|
||||
.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.
|
||||
// User-facing introspection (all gated on the per-user opt-in).
|
||||
.route("/status", get(user_status))
|
||||
.route("/overview", get(user_overview))
|
||||
.route("/search", post(user_search))
|
||||
.route("/ask", post(user_ask))
|
||||
.with_state(cell)
|
||||
}
|
||||
|
||||
@@ -91,7 +143,104 @@ async fn require_admin(web: &HonchoWeb, caller: &Caller) -> Result<(), Response>
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /admin/test ────────────────────────────────────────────────────────────
|
||||
/// The opt-in gate for every introspection endpoint — same privacy control as
|
||||
/// the tools and the write path, resolved server-side, fail closed.
|
||||
///
|
||||
/// Returns the **caller's** peer id: in the shared workspace the peer id *is*
|
||||
/// the multi-user boundary, so it is derived here, from the authenticated user,
|
||||
/// and handed to the handler already resolved — a client-supplied peer can never
|
||||
/// reach Honcho.
|
||||
async fn require_peer(web: &HonchoWeb, caller: &Caller) -> Result<String, Response> {
|
||||
if crate::opted_in(&web.user_config, &caller.user_id).await {
|
||||
Ok(caller.user_id.clone())
|
||||
} else {
|
||||
let msg = web.i18n.for_user(&caller.user_id, KEY_NOT_OPTED_IN, &[]).await;
|
||||
Err((StatusCode::FORBIDDEN, msg).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized, *specific* message for a Honcho failure — transport cause or HTTP
|
||||
/// status + body — because "service unavailable" is exactly what this page must
|
||||
/// not say. Shared by the JSON-200 `/status` (message only) and the error
|
||||
/// responses of the other handlers.
|
||||
async fn honcho_error_text(web: &HonchoWeb, caller: &Caller, e: &HonchoError) -> String {
|
||||
match e {
|
||||
HonchoError::Http { status, body } => web.i18n
|
||||
.for_user(&caller.user_id, KEY_HONCHO_ERROR, &[
|
||||
("status", &status.to_string()),
|
||||
("detail", &truncate_detail(body)),
|
||||
])
|
||||
.await,
|
||||
// `Request`'s Display walks the whole source chain, so the real cause
|
||||
// ("connection refused", "dns error", …) is already in here.
|
||||
e @ (HonchoError::Request(_) | HonchoError::Json(_)) => web.i18n
|
||||
.for_user(&caller.user_id, KEY_HONCHO_UNREACHABLE, &[("detail", &e.to_string())])
|
||||
.await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Error response built from [`honcho_error_text`]. 502: the failure happened
|
||||
/// on the Honcho side, not in this handler.
|
||||
async fn honcho_error(web: &HonchoWeb, caller: &Caller, e: &HonchoError) -> Response {
|
||||
(StatusCode::BAD_GATEWAY, honcho_error_text(web, caller, e).await).into_response()
|
||||
}
|
||||
|
||||
/// Char-boundary-safe truncation of an error body for display.
|
||||
fn truncate_detail(s: &str) -> String {
|
||||
if s.len() <= ERR_DETAIL_MAX {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = ERR_DETAIL_MAX;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::truncate_detail;
|
||||
|
||||
#[test]
|
||||
fn truncate_keeps_short_bodies_intact() {
|
||||
assert_eq!(truncate_detail("boom"), "boom");
|
||||
// Exactly at the limit is kept whole; one over is cut at the limit.
|
||||
assert_eq!(truncate_detail(&"x".repeat(300)), "x".repeat(300));
|
||||
assert_eq!(truncate_detail(&"x".repeat(301)), format!("{}…", "x".repeat(300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_never_lands_inside_a_multibyte_char() {
|
||||
// 300 'è' = 600 bytes: a naive byte cut at 300 would split a codepoint,
|
||||
// but byte 300 happens to fall on a boundary — the cut is 150 whole
|
||||
// chars plus the ellipsis.
|
||||
let long = "è".repeat(300);
|
||||
let out = truncate_detail(&long);
|
||||
assert!(out.ends_with('…'));
|
||||
assert!(out.chars().all(|c| c == 'è' || c == '…'));
|
||||
assert_eq!(out.chars().count(), 151);
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of `/search` and `/ask`.
|
||||
#[derive(Deserialize)]
|
||||
struct QueryBody {
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
}
|
||||
|
||||
/// Reject an empty/whitespace query with a localized 400.
|
||||
async fn require_query(web: &HonchoWeb, caller: &Caller, body: &QueryBody) -> Result<String, Response> {
|
||||
let q = body.query.trim();
|
||||
if q.is_empty() {
|
||||
let msg = web.i18n.for_user(&caller.user_id, KEY_QUERY_REQUIRED, &[]).await;
|
||||
Err((StatusCode::BAD_REQUEST, msg).into_response())
|
||||
} else {
|
||||
Ok(q.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /admin/test ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestBody {
|
||||
@@ -111,7 +260,7 @@ async fn admin_test(
|
||||
Json(body): Json<TestBody>,
|
||||
) -> Response {
|
||||
let web = match web_or_503(&cell).await {
|
||||
Ok(w) => w,
|
||||
Ok(w) => w,
|
||||
Err(r) => return r,
|
||||
};
|
||||
if let Err(r) = require_admin(&web, &caller).await {
|
||||
@@ -139,3 +288,200 @@ async fn admin_test(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /status ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Service health for the debug panel: one cheap GET (`queue/status`) that
|
||||
/// proves the server is reachable, the key is accepted and the workspace
|
||||
/// exists, plus the caller's own processing queue and the round-trip latency.
|
||||
///
|
||||
/// Scoped to the caller's observer id — the workspace is shared, and one user's
|
||||
/// page must not surface the whole instance's queue.
|
||||
///
|
||||
/// Failures are reported as `{ ok: false, error }` with HTTP 200: the *endpoint*
|
||||
/// worked, and the badge needs the specific message rather than an exception.
|
||||
async fn user_status(
|
||||
State(cell): State<WebCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let web = match web_or_503(&cell).await {
|
||||
Ok(w) => w,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let peer = match require_peer(&web, &caller).await {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
match web.client.queue_status(&web.workspace_id, Some(&peer), None, None).await {
|
||||
Ok(q) => Json(json!({
|
||||
"ok": true,
|
||||
"latency_ms": started.elapsed().as_millis() as u64,
|
||||
"queue": {
|
||||
"pending": q.pending_work_units,
|
||||
"in_progress": q.in_progress_work_units,
|
||||
"completed": q.completed_work_units,
|
||||
},
|
||||
})).into_response(),
|
||||
Err(e) => Json(json!({
|
||||
"ok": false,
|
||||
"error": honcho_error_text(&web, &caller, &e).await,
|
||||
})).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /overview ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The caller's full memory snapshot — the direct answer to "what does Honcho
|
||||
/// know about me?": peer card (curated key facts) + conclusions (derived facts,
|
||||
/// with their ids) + summary. Two cheap GETs, no LLM synthesis.
|
||||
async fn user_overview(
|
||||
State(cell): State<WebCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let web = match web_or_503(&cell).await {
|
||||
Ok(w) => w,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let peer = match require_peer(&web, &caller).await {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// A 404 on either call is "no peer/card yet", not a failure — the peer is
|
||||
// created lazily by the write path on the user's first forwarded turn.
|
||||
let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await {
|
||||
Ok(v) => v,
|
||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||
debug!("honcho /overview: no card for peer '{peer}' yet: {e}");
|
||||
Value::Null
|
||||
}
|
||||
Err(e) => return honcho_error(&web, &caller, &e).await,
|
||||
};
|
||||
|
||||
let ctx = match web.client.peer_context(
|
||||
&web.workspace_id,
|
||||
&peer,
|
||||
&PeerRepresentationGet {
|
||||
max_conclusions: Some(OVERVIEW_MAX_CONCLUSIONS),
|
||||
..Default::default()
|
||||
},
|
||||
).await {
|
||||
Ok(v) => v,
|
||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||
debug!("honcho /overview: no context for peer '{peer}' yet: {e}");
|
||||
Value::Null
|
||||
}
|
||||
Err(e) => return honcho_error(&web, &caller, &e).await,
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"card": card,
|
||||
"conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"summary": ctx.get("summary").and_then(Value::as_str),
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
// ── POST /search ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Semantic search over the caller's derived facts: `peer_context` with a
|
||||
/// `search_query`, ranked raw excerpts with their ids — no LLM synthesis. The
|
||||
/// same proven path as the `honcho_search` tool (the direct
|
||||
/// `conclusions/query` endpoint needs observer/observed filters and is not it).
|
||||
async fn user_search(
|
||||
State(cell): State<WebCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<QueryBody>,
|
||||
) -> Response {
|
||||
let web = match web_or_503(&cell).await {
|
||||
Ok(w) => w,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let peer = match require_peer(&web, &caller).await {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let query = match require_query(&web, &caller, &body).await {
|
||||
Ok(q) => q,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
match web.client.peer_context(
|
||||
&web.workspace_id,
|
||||
&peer,
|
||||
&PeerRepresentationGet {
|
||||
search_query: Some(query),
|
||||
search_top_k: Some(SEARCH_TOP_K),
|
||||
..Default::default()
|
||||
},
|
||||
).await {
|
||||
Ok(ctx) => Json(json!({
|
||||
"conclusions": ctx.get("conclusions").cloned().unwrap_or(Value::Array(vec![])),
|
||||
})).into_response(),
|
||||
// No peer in Honcho yet ⇒ nothing was ever derived: distinguish it from
|
||||
// "nothing matches" so the debug page can say which of the two it is.
|
||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||
debug!("honcho /search: no context for peer '{peer}' yet: {e}");
|
||||
Json(json!({ "conclusions": [], "empty": true })).into_response()
|
||||
}
|
||||
Err(e) => honcho_error(&web, &caller, &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /ask ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Dialectic query: Honcho's **server-side** LLM reads the caller's memory and
|
||||
/// synthesizes an answer in natural language. Slower and costlier than
|
||||
/// `/search` (an LLM round-trip inside Honcho) — that is why it is a separate
|
||||
/// action in the UI, and why it runs at `reasoning_level: low`.
|
||||
async fn user_ask(
|
||||
State(cell): State<WebCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<QueryBody>,
|
||||
) -> Response {
|
||||
let web = match web_or_503(&cell).await {
|
||||
Ok(w) => w,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let peer = match require_peer(&web, &caller).await {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let query = match require_query(&web, &caller, &body).await {
|
||||
Ok(q) => q,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let opts = DialecticOptions {
|
||||
query,
|
||||
session_id: None,
|
||||
target: None,
|
||||
stream: Some(false),
|
||||
reasoning_level: Some("low".to_string()),
|
||||
};
|
||||
match web.client.peer_chat(&web.workspace_id, &peer, &opts).await {
|
||||
Ok(response) => {
|
||||
// Same extraction as the `memory_query` tool: known content fields,
|
||||
// falling back to pretty-printed JSON so nothing is ever hidden.
|
||||
let answer = response.get("content")
|
||||
.or_else(|| response.get("response"))
|
||||
.or_else(|| response.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::to_string_pretty(&response)
|
||||
.unwrap_or_else(|_| response.to_string())
|
||||
});
|
||||
Json(json!({ "answer": answer })).into_response()
|
||||
}
|
||||
// No peer in Honcho yet: not an error — answer with the localized
|
||||
// "no memory yet" line so it reads naturally in the panel.
|
||||
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
||||
debug!("honcho /ask: no peer '{peer}' yet: {e}");
|
||||
let msg = web.i18n.for_user(&caller.user_id, KEY_NO_DATA, &[]).await;
|
||||
Json(json!({ "answer": msg })).into_response()
|
||||
}
|
||||
Err(e) => honcho_error(&web, &caller, &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user