feat(agent): LLM proxy integration shape (Pi-AI compatible)

src/lib/llmClient.ts: browser POSTs to /internal/canvas-llm/chat with
{messages, max_tokens}. Mirrors the normalized request shape used by
earendil-works/pi's @earendil-works/pi-ai. Returns:
 - ok:true with content+provider when configured and successful
 - ok:false, configured:false when proxy 404/503/unreachable
 - ok:false, configured:true when provider returned an error

routeAgentInput now falls back to the LLM with a system prompt that
includes the user identity, tool catalogue, and the top-3 vault recall
hits when no deterministic tool matches. When the proxy is not
configured the deterministic vault-hint reply is used (no regression
in the demo tier).
This commit is contained in:
2026-06-14 14:55:34 +04:00
parent 9884ddf4ac
commit 15ab8ededa
3 changed files with 146 additions and 14 deletions
+63
View File
@@ -0,0 +1,63 @@
/**
* Browser-side LLM client. Speaks to a backend proxy that brokers to the
* configured provider (Anthropic, OpenAI, etc.) using the same request
* shape as pi-ai's normalized completions. When the proxy is unreachable
* or unconfigured the client returns { configured: false } and the caller
* is expected to fall back to a deterministic path.
*/
import { api } from "./api";
export interface LlmMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LlmRequest {
messages: LlmMessage[];
max_tokens?: number;
}
export interface LlmReply {
ok: true;
content: string;
provider?: string;
}
export interface LlmUnconfigured {
ok: false;
configured: false;
reason: string;
}
export interface LlmError {
ok: false;
configured: true;
error: string;
}
export type LlmResponse = LlmReply | LlmUnconfigured | LlmError;
export const llmClient = {
async chat(req: LlmRequest, signal?: AbortSignal): Promise<LlmResponse> {
try {
const res = await fetch(`${api.config.baseUrl}/internal/canvas-llm/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
},
body: JSON.stringify({ messages: req.messages, max_tokens: req.max_tokens ?? 512 }),
signal,
});
if (res.status === 404) return { ok: false, configured: false, reason: "LLM proxy not wired" };
if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured" };
if (!res.ok) return { ok: false, configured: true, error: `Provider error ${res.status}` };
const body = await res.json();
if (typeof body?.content !== "string") return { ok: false, configured: true, error: "Provider returned unparseable body" };
return { ok: true, content: body.content, provider: body.provider };
} catch (e) {
return { ok: false, configured: false, reason: `LLM proxy unreachable: ${(e as Error).message}` };
}
},
};