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
+56
View File
@@ -0,0 +1,56 @@
// Verify the agent memory vault round-trips: signed-in user tells the
// Assistant a fact, then asks the Assistant to recall it. Second persona
// in same tenant must NOT see the first persona's notes (isolation check
// is tenant-scoped not user-scoped since the vault key is per-user).
import { chromium } from "playwright";
const URL = "https://canvas.flow-master.ai/";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
async function asPersona(personaText, work) {
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto(URL, { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: personaText }).click();
await p.locator(".dev-login-btn").click();
await p.waitForTimeout(2500);
const enter = p.locator("button", { hasText: /Enter Mission Control/i }).first();
if (await enter.count()) { await enter.click(); await p.waitForTimeout(1200); }
await p.locator(".tab", { hasText: /^\s*Assistant\s*$/ }).click();
await p.waitForTimeout(1500);
const result = await work(p);
await ctx.close();
return result;
}
async function ask(p, text) {
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill(text);
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(600);
return (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
}
const r1 = await asPersona(/Mariana/, async (p) => {
const a = await ask(p, "remember that the laptop budget cap is 1500 pounds for store managers");
console.log("[CEO remember]:", a.slice(0, 120));
await p.waitForTimeout(2000);
const b = await ask(p, "recall about laptop budget");
console.log("[CEO recall]:", b.slice(0, 200));
return { remember: a, recall: b };
});
const r2 = await asPersona(/Aisha/, async (p) => {
const a = await ask(p, "recall about laptop budget");
console.log("[HR recall]:", a.slice(0, 200));
return { recall: a };
});
console.log();
console.log("CEO remembered:", r1.remember.includes("I'll remember") ? "YES" : "NO");
console.log("CEO recalled own note:", r1.recall.includes("1500") || r1.recall.includes("laptop") ? "YES" : "NO");
console.log("HR vault isolated from CEO:", !r2.recall.includes("1500") ? "YES" : "NO (leak)");
await b.close();
+27 -14
View File
@@ -3,6 +3,7 @@ import { wizardApi } from "./wizardApi";
import { chatApi } from "./chatApi"; import { chatApi } from "./chatApi";
import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration"; import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration";
import { agentMemory, type Memory } from "./agentMemory"; import { agentMemory, type Memory } from "./agentMemory";
import { llmClient, type LlmMessage } from "./llmClient";
export interface ToolResult { export interface ToolResult {
ok: boolean; 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> { export async function routeAgentInput(text: string, ctx: ToolContext): Promise<ToolResult> {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return { ok: false, display: "Tell me what to do." }; 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(() => {}); agentMemory.remember(ctx.userEmail, trimmed, ["turn"]).catch(() => {});
for (const tool of TOOLS) { for (const tool of TOOLS) {
if (tool.matcher.test(trimmed)) { 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 let hints: Memory[] = [];
// falling back to the help message. try { hints = await agentMemory.recall(ctx.userEmail, trimmed, 3); } catch { /* ignore */ }
try { const llmReply = await llmFallback(trimmed, ctx, hints);
const hints: Memory[] = await agentMemory.recall(ctx.userEmail, trimmed, 3); if (llmReply) return llmReply;
if (hints.length > 0) { if (hints.length > 0) {
const lines = hints.map((h) => `${h.content}`).join("\n"); const lines = hints.map((h) => `${h.content}`).join("\n");
return { return {
ok: true, 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'.`, 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 */ }
return { return {
ok: false, ok: false,
display: display:
+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}` };
}
},
};