feat(chat): EA2-backed 1:1 messaging surface
New Chat scene at the Chat top-bar tab. Threads and messages are stored as flow docs (kind=definition, source_context=EA2_CHAT_THREAD / _MSG) linked by defines edges with role=next — same proven contract as wizard steps. Persona directory lets the signed-in operator open a thread with the CEO / HR / IT personas. - src/lib/chatApi.ts: listThreads / createThread / listMessages / sendMessage - src/scenes/Chat.tsx: sidebar (threads + new-thread picker) + main pane (header, bubbles, composer with Enter-to-send / Shift-Enter newline) - src/index.css: chat-scene grid + bubble + composer styles - src/App.tsx: Chat tab + scene route
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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 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 existing chat-thread flow docs for the current tenant. */
|
||||
async listThreads(signal?: AbortSignal): Promise<ChatThread[]> {
|
||||
const res = await jsonFetch(`/api/ea2/flow/processes?limit=200`, { signal });
|
||||
const items = (res?.items || []) as any[];
|
||||
return items
|
||||
.filter((it) => it?.source_context === "EA2_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 || [],
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a thread between two persona emails. Returns the new thread key.
|
||||
* Falls back to a deterministic key built from the participants when present.
|
||||
*/
|
||||
async createThread(displayName: string, participants: string[], signal?: AbortSignal): Promise<string> {
|
||||
const payload = {
|
||||
kind: "definition",
|
||||
status: "published",
|
||||
name: `chat_${Date.now()}`,
|
||||
display_name: displayName,
|
||||
description: `Conversation between ${participants.join(" & ")}`,
|
||||
source_context: "EA2_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();
|
||||
return body._key;
|
||||
},
|
||||
|
||||
/** Read every message under a thread via the graph endpoint. */
|
||||
async listMessages(threadKey: string, signal?: AbortSignal): Promise<ChatMessage[]> {
|
||||
const res = await jsonFetch(`/api/ea2/process-definitions/${threadKey}/graph`, { signal });
|
||||
const edges = (res?.edges || []) as any[];
|
||||
const childKeys = edges
|
||||
.filter((e) => e?.role === "next")
|
||||
.map((e) => (e.to_id || 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: "definition",
|
||||
status: "published",
|
||||
name: `msg_${Date.now()}`,
|
||||
display_name: body.slice(0, 80),
|
||||
description: body,
|
||||
source_context: "EA2_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.createStepEdge(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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user