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
+27 -14
View File
@@ -3,6 +3,7 @@ import { wizardApi } from "./wizardApi";
import { chatApi } from "./chatApi";
import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration";
import { agentMemory, type Memory } from "./agentMemory";
import { llmClient, type LlmMessage } from "./llmClient";
export interface ToolResult {
ok: boolean;
@@ -181,11 +182,24 @@ const TOOLS: ToolDef[] = [
},
];
async function llmFallback(trimmed: string, ctx: ToolContext, hints: Memory[]): Promise<ToolResult | null> {
const memBlock = hints.length ? `\nYour vault has related notes:\n${hints.map((h) => `- ${h.content}`).join("\n")}` : "";
const system: LlmMessage = {
role: "system",
content:
`You are the FlowMaster Command Assistant inside canvas.flow-master.ai for ${ctx.userEmail}. ` +
`Speak briefly (3 sentences max). You can refer to these tools by name when suggesting actions: ${TOOLS.map((t) => t.name).join(", ")}. ` +
`If the user asks something operational, recommend a specific tool. ${memBlock}`,
};
const user: LlmMessage = { role: "user", content: trimmed };
const reply = await llmClient.chat({ messages: [system, user], max_tokens: 320 });
if (reply.ok) return { ok: true, display: reply.content };
return null;
}
export async function routeAgentInput(text: string, ctx: ToolContext): Promise<ToolResult> {
const trimmed = text.trim();
if (!trimmed) return { ok: false, display: "Tell me what to do." };
// Best-effort: every turn becomes an episodic memory in the vault. We never
// block the response on it so a vault outage doesn't break the assistant.
agentMemory.remember(ctx.userEmail, trimmed, ["turn"]).catch(() => {});
for (const tool of TOOLS) {
if (tool.matcher.test(trimmed)) {
@@ -196,18 +210,17 @@ export async function routeAgentInput(text: string, ctx: ToolContext): Promise<T
}
}
}
// If nothing matched, try to recall related context from the vault before
// falling back to the help message.
try {
const hints: Memory[] = await agentMemory.recall(ctx.userEmail, trimmed, 3);
if (hints.length > 0) {
const lines = hints.map((h) => `${h.content}`).join("\n");
return {
ok: true,
display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`,
};
}
} catch { /* ignore */ }
let hints: Memory[] = [];
try { hints = await agentMemory.recall(ctx.userEmail, trimmed, 3); } catch { /* ignore */ }
const llmReply = await llmFallback(trimmed, ctx, hints);
if (llmReply) return llmReply;
if (hints.length > 0) {
const lines = hints.map((h) => `${h.content}`).join("\n");
return {
ok: true,
display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`,
};
}
return {
ok: false,
display: