import { api } from "./api"; import { wizardApi, type BatchOperation } from "./wizardApi"; /** * EA2-backed 1:1 chat. Each thread is a `flow` doc (kind=definition, * source_context=EA2_CHAT_THREAD) and each message is a `flow` doc * (kind=definition, source_context=EA2_CHAT_MSG) linked to its thread by a * `defines` edge with role=next. Persona identities are the seeded users * (hr-head/ceo-head/it-head/dev@flow-master.ai). */ export interface ChatThread { _key: string; display_name: string; description: string; created_at: string; participants: string[]; } export interface ChatMessage { _key: string; display_name: string; body: string; author_email: string; thread_key: string; created_at: string; } function newRequestId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); 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 body = { request_id: newRequestId(), ops: [ { op: "create", coll: "flow", data: { _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 } }, }, }, ], }; const created = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { method: "POST", headers: authHeaders(), body: JSON.stringify(body), 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")}`, "Content-Type": "application/json", }; } async function jsonFetch(path: string, init?: RequestInit) { const res = await fetch(`${api.config.baseUrl}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`${path} ${res.status}: ${text.slice(0, 200)}`); } return res.json(); } export const chatApi = { /** * 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 || "")); }, /** * Create a thread as flow.kind=value so it is invisible to process-definition * surfaces by EA2 schema rather than by source_context filtering. */ async createThread(displayName: string, participants: string[], signal?: AbortSignal): Promise { const payload = { kind: "value", status: "published", name: `chat_${Date.now()}`, display_name: displayName, description: `Conversation between ${participants.join(" & ")}`, source_context: "CANVAS_CHAT_THREAD", config: { chat: { participants } }, }; const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { method: "POST", headers: authHeaders(), body: JSON.stringify(payload), signal, }); if (!res.ok) throw new Error(`createThread ${res.status}`); const body = await res.json(); 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. */ async listMessages(threadKey: string, signal?: AbortSignal): Promise { const res = await jsonFetch(`/api/ea2/edges/defines?from=flow/${threadKey}&limit=200`, { signal }); const edges = (res?.items || []) as any[]; const childKeys = edges .filter((e) => e?.role === "next") .map((e) => (e._to || "").split("/").pop()) .filter(Boolean) as string[]; const messages = await Promise.all( childKeys.map(async (k) => { try { const doc = await jsonFetch(`/api/ea2/flow/${k}`, { signal }); return { _key: doc._key, display_name: doc.display_name || "", body: doc.description || "", author_email: doc.config?.chat?.author_email || "", thread_key: threadKey, created_at: doc.created_at, } as ChatMessage; } catch { return null; } }) ); return messages.filter((m): m is ChatMessage => !!m).sort((a, b) => a.created_at.localeCompare(b.created_at)); }, /** Append a message: one create + one create_edge in a single batch. */ async sendMessage(threadKey: string, body: string, authorEmail: string, signal?: AbortSignal) { if (!body.trim()) return; const created = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: "value", status: "published", name: `msg_${Date.now()}`, display_name: body.slice(0, 80), description: body, source_context: "CANVAS_CHAT_MSG", config: { chat: { author_email: authorEmail, thread_key: threadKey } }, }), signal, }); if (!created.ok) throw new Error(`sendMessage create ${created.status}`); const msg = await created.json(); const linkOps: BatchOperation[] = [wizardApi.ops.childEdge(threadKey, msg._key)]; await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ request_id: newRequestId(), ops: linkOps }), signal, }); return msg._key; }, };