feat(chat): per-user inbox anchor for listing kind=value threads
EA2 doesn't expose a generic flow-by-kind list endpoint; flow/processes is definition-only. Migrated chat to flow.kind=value, so direct listing needed a new strategy. - Each user has a deterministic inbox flow.kind=value doc keyed by email slug. ensureInbox() creates it idempotently on first listThreads. - createThread links the new thread into BOTH participants' inboxes via defines edges with role='presentation' (each link is one apply-batch). - listThreads(userEmail) reads defines edges from the user's inbox key. No more reliance on source_context regex filtering through the definition catalogue. Updated all 3 call sites: Chat.tsx (mount + after-create refresh) and agentTools.send_chat. 31/31 tests green.
This commit is contained in:
@@ -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);
|
||||
|
||||
+82
-14
@@ -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<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 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<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")}`,
|
||||
@@ -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<ChatThread[]> {
|
||||
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<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 || ""));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -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. */
|
||||
|
||||
+2
-2
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user