Files
canvas-frontend/src/lib/chatApi.ts
T
shad b951724c5f feat(wizard): produce fully startable EA2 flows
Reverse-engineered the EA2 runtime startability contract by diffing
pr_to_po_def (startable) against wizard-created drafts (500). Minimum
shape required:

  Parent flow (kind=definition, status=published)
  + Step flow (kind=definition, status=published, dispatch_kind=human)
  + View doc (kind=definition, status=active, layout={fields,layout:form})
  + Version doc (kind=definition, status=active)
  + defines edge: parent -> step (role=child)
  + defines edge: step -> step (role=dispatch self-edge)
  + defines edge: step -> view (role=presentation)
  + governs edge collection 'governs': version -> parent (role=governs)

wizardApi.ops gains: createView, createVersion, childEdge, dispatchEdge,
presentationEdge, governsEdge. createStepEdge renamed to childEdge to
reflect the actual EA2 role. Steps now create with status=published
(was draft) so they're visible to the runtime.

Wizard.handleStructureSave emits all of the above in two apply-batches
(creates then wires). Verified live: posted a probe flow with this
contract, /api/runtime/transactions returned 200 + a running
transaction with step_run_id.
2026-06-14 14:42:55 +04:00

228 lines
7.6 KiB
TypeScript

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<boolean> {
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<void> {
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<string, string> {
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<ChatThread[]> {
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<string> {
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<ChatMessage[]> {
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;
},
};