User dogfood found many bugs. This wave addresses: - nginx.conf: proxy_next_upstream + retries on 502/503/504, explicit proxy_ssl_name, 8s connect timeout. The DNS-resolver race that caused the Studio 'apply_patch 502' is now eaten by nginx retry. - src/lib/wizardApi.ts: applyBatch retries 3x on 502/503/504 with exponential backoff. Even if nginx misses the retry the client takes a second shot. - src/lib/chatApi.ts: listThreads now THROWS when ensureInbox fails instead of silently returning []. Chat scene shows the error + retry button instead of an empty 'No conversations' state that hides the real failure. - src/scenes/Chat.tsx: explicit error banner with 'Try again' button. - src/lib/llmClient.ts: rewritten to call /api/v1/llm/generate (real demo gateway, same as dev.flow-master.ai uses). Removed the local /internal/canvas-llm/chat sidecar entirely. - src/scenes/Agent.tsx: removed the LLM-state probe + 'off by default' OFF pill. LLM is now part of the assistant unconditionally. - src/lib/flowCuration.ts: dropped the STARTABLE_FLOW_KEYS allowlist gate in curatedPublishedFlows so list_processes now returns every published business process, not just pr_to_po_def. - src/state/store.ts + tests: DataMode is now 'live' only. Removed the SNAPSHOT branch; live is the only mode. Renamed the failure toast accordingly. Initial scene is 'mission' not 'landing'. - src/scenes/Login.tsx + test: post-login navigates to 'mission' instead of the empty landing splash. - src/scenes/Settings.tsx + src/components/CommandBar.tsx: removed every snapshot/setMode reference. Settings shows 'EA2 · live always'. Command palette no longer offers a snapshot switch. - src/App.tsx: topbar redesign. Removed Settings + Home tabs (now in user menu), removed standalone Light/Dark + Console buttons (collapsed into user menu), removed the SNAPSHOT/LIVE mode pill + topbar refresh. New UserMenu component shows a 2-char avatar circle; clicking opens a dropdown with Home / Settings / Refresh data / theme toggle / API console toggle. Topbar now has: brand · tabs · cmd-k · notification bell · avatar. - src/index.css: user-menu styles + chat-err styles + dark variants. 30/30 vitest pass. tsc + vite build green.
230 lines
7.7 KiB
TypeScript
230 lines
7.7 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) {
|
|
throw new Error("Chat inbox setup failed (EA2 backend not reachable). Try refreshing.");
|
|
}
|
|
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;
|
|
},
|
|
};
|