Nightly Build / build (push) Successful in 3m48s
Against a self-hosted Honcho 3.0.11 every read path came back empty while the server was healthy and full of derived facts: the plugin parsed a `conclusions`/`summary` shape the API no longer emits. - honcho-client: typed models for the real schema — `PeerContext` (`representation` markdown + `peer_card`), `SessionContext` (`summary` as an object, `peer_representation`), wrapped `PeerCard` (a bare array on PUT is a 422, which also broke `honcho_profile` writes). - plugin: the turn-time injection and `honcho_context` read the representation; `honcho_search` and the page's /search now use `conclusions/query` (observer/observed scoping inside `filters`) — a real ranked semantic search with fact ids, which `peer_context?search_query` never provided; /overview returns card + representation + conclusions. - compose: pin the Honcho image by digest (3.0.11) — ghcr publishes no v3 semver tags, and an untracked `:latest` pull is what drifted the schema. - tests: fixture tests from payloads captured on the live server, plus an env-gated live smoke test (HONCHO_E2E_URL/_WS/_PEER, `cargo test -p honcho-client -- --ignored`) — run it before any future Honcho bump.
503 lines
20 KiB
Rust
503 lines
20 KiB
Rust
//! 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:
|
|
//!
|
|
//! - `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 +
|
|
//! representation digest + derived conclusions with ids). Cheap, 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`]; 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
|
|
//! 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 std::time::Instant;
|
|
|
|
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, 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::error::HonchoError;
|
|
use honcho_client::models::{
|
|
ConclusionGet, ConclusionQuery, 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_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
|
|
/// 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))
|
|
// 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)
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
#[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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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?": the peer card (curated key facts), the pre-rendered
|
|
/// markdown `representation` (what the assistant actually receives), and the
|
|
/// individual conclusions with their ids (for targeted deletion). Cheap, no
|
|
/// LLM synthesis.
|
|
///
|
|
/// Schema note (Honcho 3.0.x): `peer_context` carries `representation` +
|
|
/// `peer_card`, NOT a `conclusions` array — the ids come from a separate
|
|
/// `conclusions/list` scoped to the caller's peer.
|
|
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 the context call is "no peer yet", not a failure — the peer is
|
|
// created lazily by the write path on the user's first forwarded turn.
|
|
// The card response wraps the list: `{"peer_card": [...] | null}` — unwrap
|
|
// it so the page receives the bare value.
|
|
let card = match web.client.get_peer_card(&web.workspace_id, &peer, None).await {
|
|
Ok(c) => json!(c.peer_card),
|
|
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 representation = match web.client.peer_context(
|
|
&web.workspace_id,
|
|
&peer,
|
|
&PeerRepresentationGet {
|
|
max_conclusions: Some(OVERVIEW_MAX_CONCLUSIONS),
|
|
..Default::default()
|
|
},
|
|
).await {
|
|
Ok(ctx) => ctx.representation,
|
|
Err(e @ HonchoError::Http { status: 404, .. }) => {
|
|
debug!("honcho /overview: no context for peer '{peer}' yet: {e}");
|
|
None
|
|
}
|
|
Err(e) => return honcho_error(&web, &caller, &e).await,
|
|
};
|
|
|
|
let conclusions = match web.client.list_conclusions(
|
|
&web.workspace_id,
|
|
&PageParams { size: Some(OVERVIEW_MAX_CONCLUSIONS as u64), ..Default::default() },
|
|
&ConclusionGet {
|
|
filters: Some(json!({ "observer_id": peer, "observed_id": peer })),
|
|
},
|
|
).await {
|
|
Ok(page) => page.items,
|
|
Err(e) => return honcho_error(&web, &caller, &e).await,
|
|
};
|
|
|
|
Json(json!({
|
|
"card": card,
|
|
"representation": representation,
|
|
"conclusions": conclusions,
|
|
})).into_response()
|
|
}
|
|
|
|
// ── POST /search ──────────────────────────────────────────────────────────────
|
|
|
|
/// Semantic search over the caller's derived facts: `conclusions/query`,
|
|
/// ranked raw excerpts with their ids — no LLM synthesis. The same path as the
|
|
/// `honcho_search` tool; the observer/observed scoping lives in `filters`
|
|
/// (and `peer_context` would not do: it returns the whole representation with
|
|
/// no ids once it fits the budget).
|
|
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.query_conclusions(
|
|
&web.workspace_id,
|
|
&ConclusionQuery {
|
|
query,
|
|
top_k: Some(SEARCH_TOP_K),
|
|
distance: None,
|
|
filters: Some(json!({ "observer_id": peer, "observed_id": peer })),
|
|
},
|
|
).await {
|
|
Ok(conclusions) => Json(json!({ "conclusions": conclusions })).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,
|
|
}
|
|
}
|