diff --git a/src/lib/agentTools.ts b/src/lib/agentTools.ts index f3d037b..80ccb77 100644 --- a/src/lib/agentTools.ts +++ b/src/lib/agentTools.ts @@ -107,7 +107,7 @@ const TOOLS: ToolDef[] = [ }; const recipient = Object.entries(directory).find(([k]) => target.includes(k))?.[1]; if (!recipient) return { ok: false, display: `I don't know who "${target}" is. Try Mariana, Aisha, or Rohan.` }; - const threads = await chatApi.listThreads(); + const threads = await chatApi.listThreads(ctx.userEmail); const existing = threads.find((t) => t.participants.includes(recipient) && t.participants.includes(ctx.userEmail)); const threadKey = existing?._key || (await chatApi.createThread(`${ctx.userEmail} ↔ ${recipient}`, [ctx.userEmail, recipient])); await chatApi.sendMessage(threadKey, body, ctx.userEmail); diff --git a/src/lib/chatApi.ts b/src/lib/chatApi.ts index 232b41f..d025002 100644 --- a/src/lib/chatApi.ts +++ b/src/lib/chatApi.ts @@ -31,6 +31,49 @@ function newRequestId(): string { return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; } +function inboxKeyFor(email: string): string { + const slug = email.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 60); + return `inbox_${slug}`; +} + +async function ensureInbox(inboxKey: string, email: string, signal?: AbortSignal): Promise { + try { + const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${inboxKey}`, { headers: authHeaders(), signal }); + if (head.ok) return true; + } catch { /* fall through */ } + const created = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + _key: inboxKey, + kind: "value", + status: "published", + name: inboxKey, + display_name: `Inbox · ${email}`, + description: `Chat inbox anchor for ${email}`, + source_context: "CANVAS_CHAT_INBOX", + config: { chat: { owner_email: email } }, + }), + signal, + }); + return created.ok || created.status === 409; +} + +async function linkThreadToInbox(inboxKey: string, threadKey: string, signal?: AbortSignal): Promise { + const body = { + request_id: newRequestId(), + ops: [ + { op: "create_edge", edge_coll: "defines", from: `flow/${inboxKey}`, to: `flow/${threadKey}`, role: "presentation" }, + ], + }; + await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal, + }); +} + function authHeaders(): Record { return { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, @@ -48,19 +91,36 @@ async function jsonFetch(path: string, init?: RequestInit) { } export const chatApi = { - /** List existing chat-thread flow docs (kind=value, source_context=CANVAS_CHAT_THREAD). */ - async listThreads(signal?: AbortSignal): Promise { - const res = await jsonFetch(`/api/ea2/flow/processes?limit=500&kind=value`, { signal }); - const items = (res?.items || []) as any[]; - return items - .filter((it) => it?.source_context === "CANVAS_CHAT_THREAD") - .map((it) => ({ - _key: it._key, - display_name: it.display_name || "Chat", - description: it.description || "", - created_at: it.created_at, - participants: it.config?.chat?.participants || [], - })); + /** + * List chat threads via the per-user inbox anchor and defines edges. Per-tenant + * tenant_id of the inbox is implicit from the Bearer token. + */ + async listThreads(userEmail: string, signal?: AbortSignal): Promise { + const inboxKey = inboxKeyFor(userEmail); + const inbox = await ensureInbox(inboxKey, userEmail, signal); + if (!inbox) return []; + const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${inboxKey}&limit=200`, { signal }); + const threadKeys = ((edges?.items || []) as any[]) + .filter((e) => e?.role === "presentation") + .map((e) => (e._to || "").split("/").pop()) + .filter(Boolean) as string[]; + const threads = await Promise.all( + threadKeys.map(async (k) => { + try { + const doc = await jsonFetch(`/api/ea2/flow/${k}`, { signal }); + return { + _key: doc._key, + display_name: doc.display_name || "Chat", + description: doc.description || "", + created_at: doc.created_at, + participants: doc.config?.chat?.participants || [], + } as ChatThread; + } catch { + return null; + } + }) + ); + return threads.filter((t): t is ChatThread => !!t).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")); }, /** @@ -85,7 +145,15 @@ export const chatApi = { }); if (!res.ok) throw new Error(`createThread ${res.status}`); const body = await res.json(); - return body._key; + const threadKey = body._key as string; + await Promise.all( + participants.map(async (email) => { + const inboxKey = inboxKeyFor(email); + await ensureInbox(inboxKey, email, signal); + await linkThreadToInbox(inboxKey, threadKey, signal); + }) + ); + return threadKey; }, /** Read every message under a thread via the edges endpoint. */ diff --git a/src/scenes/Chat.tsx b/src/scenes/Chat.tsx index 8e74fb1..2943df0 100644 --- a/src/scenes/Chat.tsx +++ b/src/scenes/Chat.tsx @@ -34,7 +34,7 @@ export default function Chat() { let cancelled = false; setLoadingThreads(true); chatApi - .listThreads() + .listThreads(me) .then((rows) => { if (cancelled) return; setThreads(rows); @@ -73,7 +73,7 @@ export default function Chat() { try { const otherLabel = personaLabel(recipient); const key = await chatApi.createThread(`${personaLabel(me)} ↔ ${otherLabel}`, [me, recipient]); - const next = await chatApi.listThreads(); + const next = await chatApi.listThreads(me); setThreads(next); setActiveKey(key); pushToast("ok", `Thread opened with ${otherLabel}`);