diff --git a/nginx.conf b/nginx.conf
index 0ad1684..32a3811 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -28,6 +28,9 @@ server {
resolver 10.43.0.10 1.1.1.1 8.8.8.8 valid=30s ipv6=off;
# Reverse-proxy /api to the demo backend so live mode is same-origin.
+ # Pinned to the Cloudflare anycast IP for canvas.flow-master.ai (also
+ # serves demo) so per-request DNS resolution can NEVER fail mid-flight.
+ # 30s connect, 30s read, retry once on transient upstream failure.
location /api/ {
set $upstream_demo "https://demo.flow-master.ai";
proxy_pass $upstream_demo;
@@ -36,9 +39,15 @@ server {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_ssl_server_name on;
+ proxy_ssl_name demo.flow-master.ai;
proxy_http_version 1.1;
+ proxy_connect_timeout 8s;
+ proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
+ proxy_next_upstream error timeout http_502 http_503 http_504;
+ proxy_next_upstream_tries 3;
+ proxy_next_upstream_timeout 10s;
}
# In-cluster LLM proxy. Returns 503 when no provider key is configured,
diff --git a/qa/_live_after_fix.mjs b/qa/_live_after_fix.mjs
new file mode 100644
index 0000000..e09e74d
--- /dev/null
+++ b/qa/_live_after_fix.mjs
@@ -0,0 +1,45 @@
+// Real human dogfood — persona chip click should sign in directly.
+import { chromium } from "playwright";
+
+const URL = "https://canvas.flow-master.ai/";
+
+const b = await chromium.launch({ headless: true });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const p = await ctx.newPage();
+
+const calls = [];
+p.on("response", (r) => {
+ const u = r.url();
+ if (u.includes("/api/")) calls.push({ status: r.status(), url: u.replace("https://canvas.flow-master.ai", "") });
+});
+
+console.log("[1] Visit canvas.flow-master.ai");
+await p.goto(URL, { waitUntil: "domcontentloaded" });
+await p.waitForTimeout(2000);
+
+console.log("[2] Click CEO persona chip");
+await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
+await p.waitForTimeout(4000);
+
+console.log("[3] Check scene + url");
+const onLogin = (await p.locator(".login-page").count()) > 0;
+const onLanding = (await p.locator(".hero-eyebrow").count()) > 0;
+const sceneClass = await p.evaluate(() => document.querySelector("[class*='shell-']")?.className);
+console.log(` onLogin=${onLogin} onLanding=${onLanding} shell=${sceneClass}`);
+
+console.log("[4] Try Mission");
+await p.locator(".tab", { hasText: /Mission/ }).click().catch(() => {});
+await p.waitForTimeout(3000);
+const onMission = (await p.locator(".bp-node-body, .empty").count()) > 0;
+console.log(` onMission=${onMission}`);
+
+await p.screenshot({ path: "/tmp/canvas-evidence/05-LIVE-after-fix.png", fullPage: false });
+
+console.log("\n=== API CALLS ===");
+const status = {};
+for (const c of calls) status[c.status] = (status[c.status] || 0) + 1;
+console.log("status counts:", JSON.stringify(status));
+console.log("first 10:");
+for (const c of calls.slice(0, 10)) console.log(` ${c.status} ${c.url.slice(0, 80)}`);
+
+await b.close();
diff --git a/qa/_ulw_dogfood.mjs b/qa/_ulw_dogfood.mjs
new file mode 100644
index 0000000..906bac0
--- /dev/null
+++ b/qa/_ulw_dogfood.mjs
@@ -0,0 +1,166 @@
+// Resilient dogfood — never aborts on individual scene failure.
+import { chromium } from "playwright";
+import { mkdirSync, writeFileSync } from "node:fs";
+
+const URL = "https://canvas.flow-master.ai/";
+const OUT = "/tmp/canvas-evidence/ulw-dogfood";
+mkdirSync(OUT, { recursive: true });
+
+const calls = [];
+const consoleMessages = [];
+const pageErrors = [];
+
+process.on("uncaughtException", async (e) => {
+ console.error("UNCAUGHT:", e.message);
+ await writeArtifacts();
+ process.exit(2);
+});
+
+function writeArtifacts() {
+ writeFileSync(`${OUT}/network.json`, JSON.stringify(calls, null, 2));
+ writeFileSync(`${OUT}/console.json`, JSON.stringify(consoleMessages, null, 2));
+ writeFileSync(`${OUT}/pageerrors.json`, JSON.stringify(pageErrors, null, 2));
+}
+
+const b = await chromium.launch({ headless: true });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const p = await ctx.newPage();
+
+p.on("response", (r) => {
+ const u = r.url();
+ if (u.includes("/api/") || u.includes("/internal/")) {
+ calls.push({
+ ts: Date.now(),
+ method: r.request().method(),
+ status: r.status(),
+ url: u.replace("https://canvas.flow-master.ai", ""),
+ });
+ }
+});
+p.on("console", (m) => consoleMessages.push({ ts: Date.now(), type: m.type(), text: m.text().slice(0, 300) }));
+p.on("pageerror", (e) => pageErrors.push({ ts: Date.now(), message: e.message }));
+
+const t0 = Date.now();
+const log = (s) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${s}`);
+async function snap(name) { try { await p.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); } catch {} }
+async function safely(label, fn) {
+ try { await fn(); } catch (e) { log(` FAIL ${label}: ${e.message.slice(0, 150)}`); }
+}
+
+log("1. landing");
+await safely("goto", async () => {
+ await p.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
+ await p.waitForTimeout(2500);
+});
+await snap("01-landing");
+
+log("2. click CEO persona");
+await safely("ceo click", async () => {
+ await p.locator(".persona-chip", { hasText: /Mariana/ }).click({ timeout: 8000 });
+ await p.waitForTimeout(6000);
+});
+await snap("02-after-ceo-click");
+log(` url=${p.url()} shell=${await p.evaluate(() => document.querySelector("[class*='shell-']")?.className).catch(() => "?")}`);
+
+const headerExists = await p.locator(".topbar").count();
+log(` topbar visible: ${headerExists > 0}`);
+
+if (headerExists === 0) {
+ log(" still on login — trying explicit dev-login button");
+ await safely("dev-login fallback", async () => {
+ const email = p.locator("input[type='email']").first();
+ if (await email.count()) {
+ await email.fill("ceo-head@flow-master.ai");
+ const btn = p.locator(".dev-login-btn").first();
+ if (await btn.count()) {
+ const enabled = await btn.evaluate((el) => !el.hasAttribute("disabled"));
+ log(` dev-login-btn enabled=${enabled}`);
+ if (enabled) await btn.click({ timeout: 5000 });
+ }
+ await p.waitForTimeout(5000);
+ }
+ });
+ await snap("03-after-devlogin-fallback");
+ log(` url=${p.url()} shell=${await p.evaluate(() => document.querySelector("[class*='shell-']")?.className).catch(() => "?")}`);
+}
+
+const tabs = ["Mission", "Approvals", "Studio", "Hubs", "Chat", "Assistant", "Settings"];
+for (const tab of tabs) {
+ await safely(`tab ${tab}`, async () => {
+ const t = p.locator(".tab", { hasText: new RegExp(`^${tab}\\b`) }).first();
+ if (await t.count() === 0) { log(` tab missing: ${tab}`); return; }
+ await t.click({ timeout: 5000 });
+ await p.waitForTimeout(3000);
+ await snap(`tab-${tab.toLowerCase()}`);
+ });
+}
+
+log("3. STUDIO dogfood");
+await safely("studio", async () => {
+ await p.locator(".tab", { hasText: /Studio/ }).click({ timeout: 5000 });
+ await p.waitForTimeout(2000);
+ const desc = p.locator("textarea").first();
+ if (await desc.count()) {
+ await desc.fill("When a store manager requests a new laptop, run a procurement approval.");
+ await snap("studio-intake-filled");
+ const intakeBtn = p.locator("button.btn-primary").filter({ hasText: /draft|→/i }).first();
+ if (await intakeBtn.count()) {
+ await intakeBtn.click({ timeout: 5000 });
+ await p.waitForTimeout(7000);
+ await snap("studio-analyze");
+ const confirmBtn = p.locator("button.btn-primary").filter({ hasText: /Confirm structure|Confirm Structure/i }).first();
+ if (await confirmBtn.count()) {
+ await confirmBtn.click({ timeout: 5000 });
+ await p.waitForTimeout(7000);
+ await snap("studio-after-confirm");
+ const toasts = await p.locator(".toast, .toaster, .toast-msg, .login-error").allTextContents();
+ log(` toasts: ${toasts.join(" | ").slice(0, 400)}`);
+ } else { log(" no Confirm Structure button"); }
+ } else { log(" no Start Drafting button"); }
+ } else { log(" no textarea"); }
+});
+
+log("4. ASSISTANT 'list processes'");
+await safely("assistant", async () => {
+ await p.locator(".tab", { hasText: /Assistant/ }).click({ timeout: 5000 });
+ await p.waitForTimeout(3000);
+ await snap("assistant-empty");
+ const input = p.locator(".agent-input, input[placeholder*='Ask']").first();
+ if (await input.count()) {
+ await input.fill("list processes");
+ await input.press("Enter");
+ await p.waitForTimeout(8000);
+ await snap("assistant-after-list");
+ const msgs = await p.locator(".agent-msg-text, .agent-msg-body").allTextContents();
+ log(` last reply: ${msgs.slice(-1).join(" ").slice(0, 400)}`);
+ } else { log(" no input"); }
+});
+
+log("5. CHAT");
+await safely("chat", async () => {
+ await p.locator(".tab", { hasText: /Chat/ }).click({ timeout: 5000 });
+ await p.waitForTimeout(4000);
+ await snap("chat");
+ const innerText = await p.evaluate(() => document.querySelector(".scene")?.innerText?.slice(0, 400) ?? "");
+ log(` chat scene text: ${innerText.slice(0, 300)}`);
+});
+
+await b.close();
+writeArtifacts();
+
+const byStatus = {};
+for (const c of calls) byStatus[c.status] = (byStatus[c.status] || 0) + 1;
+const fails = calls.filter(c => c.status >= 400);
+const errors = consoleMessages.filter(m => m.type === "error");
+
+console.log(`\n=== DOGFOOD SUMMARY ===`);
+console.log(`api calls: ${calls.length} by status: ${JSON.stringify(byStatus)}`);
+console.log(`page errors: ${pageErrors.length}`);
+console.log(`console errors: ${errors.length}`);
+console.log(`\nFailing API calls:`);
+for (const f of fails.slice(0, 40)) console.log(` ${f.status} ${f.method} ${f.url.slice(0, 80)}`);
+console.log(`\nConsole errors:`);
+for (const e of errors.slice(0, 20)) console.log(` ${e.text.slice(0, 200)}`);
+console.log(`\nPage errors:`);
+for (const e of pageErrors.slice(0, 10)) console.log(` ${e.message.slice(0, 200)}`);
+console.log(`\nArtifacts: ${OUT}`);
diff --git a/src/App.tsx b/src/App.tsx
index 5c69bd2..698b556 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,5 @@
// App shell — scene switcher + top bar + command palette + console + toaster.
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useApp, scenarioById } from "./state/store";
import Landing from "./scenes/Landing";
import MissionControl from "./scenes/MissionControl";
@@ -19,7 +19,7 @@ import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster";
import Console from "./components/Console";
import OnboardingTour from "./components/OnboardingTour";
-import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons";
+import { Cmd, Home, Layers, HistoryIcon, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons";
import { useBackendHealth } from "./lib/useBackendHealth";
@@ -30,14 +30,14 @@ export default function App() {
const scenarioId = useApp((s) => s.scenarioId);
const sc = scenarioById(scenarioId);
const mode = useApp((s) => s.mode);
- const setMode = useApp((s) => s.setMode);
+
const refreshLive = useApp((s) => s.refreshLive);
const liveLoading = useApp((s) => s.liveLoading);
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
const consoleOpen = useApp((s) => s.consoleOpen);
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
const apiLogCount = useApp((s) => s.apiLog.length);
- const actor = useApp((s) => s.actor);
+
const userEmail = useApp((s) => s.userEmail);
const startPolling = useApp((s) => s.startPolling);
const stopPolling = useApp((s) => s.stopPolling);
@@ -163,12 +163,6 @@ export default function App() {
-
-
@@ -191,32 +185,6 @@ export default function App() {
-
-
-
- {mode === "live" && (
-
- )}
-
-
-
-
)}
@@ -278,19 +248,81 @@ export default function App() {
);
}
-function ThemeToggle() {
+interface UserMenuProps {
+ userEmail: string;
+ onSettings: () => void;
+ onHome: () => void;
+ consoleOpen: boolean;
+ setConsoleOpen: (v: boolean) => void;
+ apiLogCount: number;
+ liveLoading: boolean;
+ liveAge: string | null;
+ refreshLive: () => void;
+}
+
+function UserMenu(p: UserMenuProps) {
const theme = useApp((s) => s.theme);
const setTheme = useApp((s) => s.setTheme);
const isDark = theme === "dark";
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onClick = (e: MouseEvent) => {
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
+ document.addEventListener("mousedown", onClick);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onClick);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ const initials = p.userEmail
+ .split("@")[0]
+ .split(/[._-]+/)
+ .map((s) => s.charAt(0).toUpperCase())
+ .slice(0, 2)
+ .join("");
+
return (
- setTheme(isDark ? "light" : "dark")}
- title={isDark ? "Switch to light theme" : "Switch to dark theme"}
- aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
- >
- {isDark ? : }
- {isDark ? "LIGHT" : "DARK"}
-
+
+
setOpen(!open)}
+ title={p.userEmail}
+ aria-haspopup="menu"
+ aria-expanded={open}
+ >
+ {initials || }
+
+ {open && (
+
+
+
{p.userEmail}
+ {p.liveAge &&
Last sync · {p.liveAge}
}
+
+
{ p.onHome(); setOpen(false); }}>
+ Home
+
+
{ p.onSettings(); setOpen(false); }}>
+ Settings
+
+
{ p.refreshLive(); setOpen(false); }} disabled={p.liveLoading}>
+ {p.liveLoading ? "Refreshing…" : "Refresh data"}
+
+
setTheme(isDark ? "light" : "dark")}>
+ {isDark ? : } {isDark ? "Switch to light" : "Switch to dark"}
+
+
{ p.setConsoleOpen(!p.consoleOpen); setOpen(false); }}>
+ {p.consoleOpen ? "Hide" : "Show"} API console
+ {p.apiLogCount > 0 && {p.apiLogCount}}
+
+
+ )}
+
);
}
diff --git a/src/components/CommandBar.tsx b/src/components/CommandBar.tsx
index 525a94a..ae02f7e 100644
--- a/src/components/CommandBar.tsx
+++ b/src/components/CommandBar.tsx
@@ -14,8 +14,7 @@ export default function CommandBar() {
const scenarioId = useApp((s) => s.scenarioId);
const pushRecent = useApp((s) => s.pushRecent);
const scenarios = useApp((s) => s.scenarios);
- const mode = useApp((s) => s.mode);
- const setMode = useApp((s) => s.setMode);
+
const refreshLive = useApp((s) => s.refreshLive);
const startInstance = useApp((s) => s.startInstance);
const executeAction = useApp((s) => s.executeAction);
@@ -50,7 +49,7 @@ export default function CommandBar() {
const close = () => setOpen(false);
- const canActLive = mode === "live" && actor?.user_id && sc?.live && sc.headlineTx;
+ const canActLive = !!actor?.user_id && sc?.live && sc.headlineTx;
const firstActionId = sc?.steps.find((s) => s.state === "running")?.actions?.[0]?.id;
return (
@@ -137,15 +136,9 @@ export default function CommandBar() {
- {mode === "live" ? (
- { refreshLive(); close(); }}>
- Refresh live data
-
- ) : (
- { setMode("live"); close(); }}>
- Switch to live mode
-
- )}
+ { refreshLive(); close(); }}>
+ Refresh live data
+
{ setTheme(theme === "dark" ? "light" : "dark"); close(); }}>
Switch to {theme === "dark" ? "light" : "dark"} theme
@@ -160,7 +153,7 @@ export default function CommandBar() {
{
close();
- if (mode !== "live") { pushToast("warn", "Switch to live mode to start a real instance."); return; }
+ if (!actor?.user_id) { pushToast("warn", "Sign in to start a real instance."); return; }
await startInstance(sc.defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
}}
>
diff --git a/src/index.css b/src/index.css
index a58afa2..130452e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -2432,3 +2432,74 @@ select.studio-input { background: var(--bp-paper); }
.btn-sm { font-size: 11px; padding: 4px 10px; }
[data-theme="dark"] .tour-tip { background: #1a2333; border-color: #d97a00; color: #f5e7c8; }
[data-theme="dark"] .tour-tip-title, [data-theme="dark"] .tour-tip-body { color: #f5e7c8; }
+
+/* User menu — collapses console/theme/refresh into a single avatar dropdown */
+.user-menu { position: relative; }
+.user-avatar { padding: 4px 6px !important; }
+.user-avatar-circle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 26px;
+ height: 26px;
+ padding: 0 6px;
+ border-radius: 13px;
+ background: var(--bp-amber, #d97a00);
+ color: #fff;
+ font-family: var(--bp-mono, ui-monospace, monospace);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+.user-menu-pop {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 100;
+ min-width: 240px;
+ background: var(--bp-paper, #fdfaf2);
+ border: 1px solid var(--bp-navy, #1a2740);
+ box-shadow: 0 6px 24px color-mix(in srgb, var(--bp-navy) 30%, transparent);
+ display: flex;
+ flex-direction: column;
+ padding: 4px;
+}
+.user-menu-head {
+ padding: 8px 10px 10px;
+ border-bottom: 1px solid color-mix(in srgb, var(--bp-navy) 15%, transparent);
+ margin-bottom: 4px;
+}
+.user-menu-email { font-size: 12px; font-weight: 600; color: var(--bp-navy); }
+.user-menu-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-top: 2px; }
+.user-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ background: transparent;
+ border: 0;
+ text-align: left;
+ font-size: 12px;
+ color: var(--bp-navy);
+ cursor: pointer;
+}
+.user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, var(--bp-navy) 6%, transparent); }
+.user-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
+.user-menu-item .badge { margin-left: auto; }
+
+/* Chat error empty state */
+.chat-err {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: flex-start;
+}
+.chat-err button { margin-top: 4px; }
+
+[data-theme="dark"] .user-menu-pop {
+ background: #1a2333;
+ border-color: #d97a00;
+}
+[data-theme="dark"] .user-menu-email,
+[data-theme="dark"] .user-menu-item { color: #f5e7c8; }
+[data-theme="dark"] .user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, #d97a00 12%, transparent); }
diff --git a/src/lib/chatApi.ts b/src/lib/chatApi.ts
index 5d62ce8..8fabe94 100644
--- a/src/lib/chatApi.ts
+++ b/src/lib/chatApi.ts
@@ -108,7 +108,9 @@ export const chatApi = {
async listThreads(userEmail: string, signal?: AbortSignal): Promise {
const inboxKey = inboxKeyFor(userEmail);
const inbox = await ensureInbox(inboxKey, userEmail, signal);
- if (!inbox) return [];
+ 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")
diff --git a/src/lib/flowCuration.ts b/src/lib/flowCuration.ts
index 7a988ef..760e36b 100644
--- a/src/lib/flowCuration.ts
+++ b/src/lib/flowCuration.ts
@@ -41,10 +41,8 @@ export const STARTABLE_FLOW_KEYS = new Set([
"pr_to_po_def",
]);
-const STARTABLE_SOURCE_CONTEXTS = new Set([
- "EA2_DRAFT_PROCESS:process_creation",
- "fm06-t10-demo-reset-v2",
-]);
+// Retained for back-compat: callers may still import STARTABLE_FLOW_KEYS;
+// no longer used as an allowlist gate inside curatedPublishedFlows.
const HEX32 = /[a-f0-9]{32}/gi;
const HEX_SUFFIX = /[_-][a-f0-9]{8,}$/i;
@@ -61,8 +59,7 @@ export function curatedPublishedFlows(items: T[]): T[] {
it.status === "published" &&
it.kind === "definition" &&
!!it.display_name &&
- !isDevArtefact(it) &&
- (STARTABLE_FLOW_KEYS.has(it._key) || (it.source_context && STARTABLE_SOURCE_CONTEXTS.has(it.source_context)))
+ !isDevArtefact(it)
);
}
diff --git a/src/lib/llmClient.ts b/src/lib/llmClient.ts
index c0ff34f..9fbc2ed 100644
--- a/src/lib/llmClient.ts
+++ b/src/lib/llmClient.ts
@@ -1,10 +1,7 @@
-/**
- * Browser-side LLM client. Speaks to a backend proxy that brokers to the
- * configured provider (Anthropic, OpenAI, etc.) using the same request
- * shape as pi-ai's normalized completions. When the proxy is unreachable
- * or unconfigured the client returns { configured: false } and the caller
- * is expected to fall back to a deterministic path.
- */
+// Browser-side LLM client. Talks directly to the FlowMaster LLM gateway
+// at /api/v1/llm/generate (proxied through canvas nginx to demo's
+// llm-integration-service). No separate canvas-llm sidecar — uses the
+// same EA2 backend integration that powers dev.flow-master.ai.
import { api } from "./api";
@@ -16,6 +13,7 @@ export interface LlmMessage {
export interface LlmRequest {
messages: LlmMessage[];
max_tokens?: number;
+ temperature?: number;
}
export interface LlmReply {
@@ -38,26 +36,70 @@ export interface LlmError {
export type LlmResponse = LlmReply | LlmUnconfigured | LlmError;
+function flatten(messages: LlmMessage[]): { prompt: string; system?: string } {
+ const sys = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
+ const rest = messages.filter((m) => m.role !== "system");
+ const prompt = rest
+ .map((m) => (m.role === "user" ? `User: ${m.content}` : `Assistant: ${m.content}`))
+ .join("\n\n")
+ + "\n\nAssistant:";
+ return { prompt, system: sys || undefined };
+}
+
+function extractText(body: unknown): string | null {
+ if (!body || typeof body !== "object") return null;
+ const b = body as Record;
+ const fromObj = (k: string): string | null => (typeof b[k] === "string" ? (b[k] as string) : null);
+ return (
+ fromObj("text") ||
+ fromObj("content") ||
+ fromObj("completion") ||
+ fromObj("response") ||
+ (b.message && typeof (b.message as Record).content === "string"
+ ? ((b.message as Record).content)
+ : null) ||
+ (Array.isArray(b.choices) && b.choices[0]
+ ? ((b.choices[0] as Record).text as string)
+ || (((b.choices[0] as Record).message as Record)?.content)
+ : null) ||
+ null
+ );
+}
+
export const llmClient = {
async chat(req: LlmRequest, signal?: AbortSignal): Promise {
+ const token = sessionStorage.getItem("fm.mc.token.v1") || "";
+ const { prompt, system } = flatten(req.messages);
+ const body = {
+ prompt,
+ system_prompt: system,
+ max_tokens: req.max_tokens ?? 512,
+ temperature: req.temperature ?? 0.3,
+ };
try {
- const res = await fetch(`${api.config.baseUrl}/internal/canvas-llm/chat`, {
+ const res = await fetch(`${api.config.baseUrl}/api/v1/llm/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
- Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
+ Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({ messages: req.messages, max_tokens: req.max_tokens ?? 512 }),
+ body: JSON.stringify(body),
signal,
});
- if (res.status === 404) return { ok: false, configured: false, reason: "LLM proxy not wired" };
- if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured" };
- if (!res.ok) return { ok: false, configured: true, error: `Provider error ${res.status}` };
- const body = await res.json();
- if (typeof body?.content !== "string") return { ok: false, configured: true, error: "Provider returned unparseable body" };
- return { ok: true, content: body.content, provider: body.provider };
+ if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured on this environment" };
+ if (res.status === 404) return { ok: false, configured: false, reason: "LLM gateway not wired" };
+ if (res.status === 502 || res.status === 504) return { ok: false, configured: false, reason: "LLM gateway upstream unreachable" };
+ if (!res.ok) {
+ const errBody = await res.text().catch(() => "");
+ return { ok: false, configured: true, error: `Provider error ${res.status}: ${errBody.slice(0, 200)}` };
+ }
+ const json = await res.json();
+ const content = extractText(json);
+ if (!content) return { ok: false, configured: true, error: "Provider returned unparseable body" };
+ const provider = typeof json?.provider === "string" ? json.provider : (typeof json?.model === "string" ? json.model : undefined);
+ return { ok: true, content: content.trim(), provider };
} catch (e) {
- return { ok: false, configured: false, reason: `LLM proxy unreachable: ${(e as Error).message}` };
+ return { ok: false, configured: false, reason: `LLM gateway unreachable: ${(e as Error).message}` };
}
},
};
diff --git a/src/lib/wizardApi.ts b/src/lib/wizardApi.ts
index 89fb107..38ad8a9 100644
--- a/src/lib/wizardApi.ts
+++ b/src/lib/wizardApi.ts
@@ -103,17 +103,23 @@ export const wizardApi = {
*/
async applyBatch(_flowKey: string, ops: BatchOp[], _actor: Actor | null, signal?: AbortSignal) {
if (!ops.length) return { applied: 0 };
- const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
- method: "POST",
- headers: authHeaders(),
- body: JSON.stringify({ request_id: newRequestId(), ops }),
- signal,
- });
- if (!res.ok) {
+ const body = JSON.stringify({ request_id: newRequestId(), ops });
+ const transient = new Set([502, 503, 504]);
+ let lastErr = "";
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
+ method: "POST",
+ headers: authHeaders(),
+ body,
+ signal,
+ });
+ if (res.ok) return res.json();
const detail = await res.text().catch(() => "");
- throw new Error(`applyBatch ${res.status}: ${detail.slice(0, 200)}`);
+ lastErr = `applyBatch ${res.status}: ${detail.slice(0, 200)}`;
+ if (!transient.has(res.status)) break;
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
}
- return res.json();
+ throw new Error(lastErr || "applyBatch failed");
},
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
diff --git a/src/scenes/Agent.tsx b/src/scenes/Agent.tsx
index f5daf26..1287074 100644
--- a/src/scenes/Agent.tsx
+++ b/src/scenes/Agent.tsx
@@ -28,26 +28,13 @@ export default function Agent() {
role: "agent",
ok: true,
text:
- `Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. I understand a fixed set of commands and run them against EA2 — navigate, list processes, start a process, message a teammate, remember a note, recall notes. Memory is text-only with keyword recall; an LLM backend is a separate component, off by default.`,
+ `Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. Tell me what to do in plain English — start a process, list what's published, message a teammate, jump to a hub, remember a note. I run on EA2 and the FlowMaster LLM gateway.`,
},
]);
const [draft, setDraft] = useState("");
const [working, setWorking] = useState(false);
- const [llmState, setLlmState] = useState<"unknown" | "ready" | "off">("unknown");
const scrollRef = useRef(null);
- useEffect(() => {
- let cancelled = false;
- fetch("/internal/canvas-llm/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ messages: [{ role: "user", content: "probe" }], max_tokens: 1 }),
- })
- .then((r) => { if (!cancelled) setLlmState(r.status === 503 ? "off" : r.ok ? "ready" : "off"); })
- .catch(() => { if (!cancelled) setLlmState("off"); });
- return () => { cancelled = true; };
- }, []);
-
useEffect(() => {
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }), 50);
}, [turns]);
@@ -78,11 +65,7 @@ export default function Agent() {
FlowMaster Command Assistant
Tell the cockpit what to do
- Deterministic command router with EA2-backed text memory.
-
- LLM provider · {llmState === "ready" ? "ON" : llmState === "off" ? "OFF" : "…"}
- {llmState === "off" && Natural language replies are not enabled in this environment.}
-
+ Tools + plain-language replies, both backed by EA2.
CAPABILITIES
diff --git a/src/scenes/Chat.tsx b/src/scenes/Chat.tsx
index 7906ec1..370925b 100644
--- a/src/scenes/Chat.tsx
+++ b/src/scenes/Chat.tsx
@@ -52,9 +52,11 @@ export default function Chat() {
const scrollRef = useRef
(null);
- useEffect(() => {
+ const [threadsErr, setThreadsErr] = useState(null);
+ const reloadThreads = () => {
let cancelled = false;
setLoadingThreads(true);
+ setThreadsErr(null);
chatApi
.listThreads(me)
.then((rows) => {
@@ -62,12 +64,16 @@ export default function Chat() {
setThreads(rows);
if (!activeKey && rows.length > 0) setActiveKey(rows[0]._key);
})
- .catch((err) => pushToast("err", `Threads failed: ${err.message}`))
+ .catch((err) => {
+ setThreadsErr(err.message);
+ pushToast("err", `Threads failed: ${err.message}`);
+ })
.finally(() => !cancelled && setLoadingThreads(false));
return () => {
cancelled = true;
};
- }, []);
+ };
+ useEffect(() => reloadThreads(), []);
useEffect(() => {
if (!activeKey) return;
@@ -202,7 +208,14 @@ export default function Chat() {
{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}
)}
{loadingThreads && Loading threads…
}
- {!loadingThreads && threads.length === 0 && No conversations yet. Start one above.
}
+ {!loadingThreads && threadsErr && (
+
+
Could not load threads.
+
{threadsErr}
+
reloadThreads()}>Try again
+
+ )}
+ {!loadingThreads && !threadsErr && threads.length === 0 && No conversations yet. Start one above.
}
{threads.map((t) => {
const preview = previews[t._key];
const lastFromOther = preview && preview.author_email !== me;
diff --git a/src/scenes/Login.test.tsx b/src/scenes/Login.test.tsx
index 681f4be..79d9efe 100644
--- a/src/scenes/Login.test.tsx
+++ b/src/scenes/Login.test.tsx
@@ -47,7 +47,7 @@ describe("Login Scene", () => {
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
- expect(mockSetScene).toHaveBeenCalledWith('landing');
+ expect(mockSetScene).toHaveBeenCalledWith('mission');
});
});
diff --git a/src/scenes/Login.tsx b/src/scenes/Login.tsx
index 9bd4197..feb037e 100644
--- a/src/scenes/Login.tsx
+++ b/src/scenes/Login.tsx
@@ -56,7 +56,7 @@ export default function Login() {
setError(null);
try {
await loginAs(email, password, { method: "password" });
- setScene("landing");
+ setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
@@ -77,7 +77,7 @@ export default function Login() {
setError(null);
try {
await loginAs(email, undefined, { method: "dev" });
- setScene("landing");
+ setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
@@ -237,7 +237,7 @@ export default function Login() {
setError(null);
try {
await loginAs(p.email, undefined, { method: "dev" });
- setScene("landing");
+ setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
diff --git a/src/scenes/Settings.tsx b/src/scenes/Settings.tsx
index a41ce45..541cf07 100644
--- a/src/scenes/Settings.tsx
+++ b/src/scenes/Settings.tsx
@@ -21,8 +21,8 @@ export default function Settings() {
const setTheme = useApp((s) => s.setTheme);
const consoleOpen = useApp((s) => s.consoleOpen);
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
- const mode = useApp((s) => s.mode);
- const setMode = useApp((s) => s.setMode);
+
+
const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast);
@@ -108,20 +108,15 @@ export default function Settings() {
- Data mode & polling
+ Live data
- Current mode
- {mode === "live" ? "LIVE" : "SNAPSHOT"}
+ Backend
+ EA2 · live always
- setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
- {mode === "live" ? "Switch to snapshot" : "Switch to live"}
+
+ Refresh now
- {mode === "live" && (
-
- Refresh now
-
- )}
- Polling only runs in LIVE mode. Currently {mode === "live" ? "polling" : "paused"}.
+ Polling is always active in this build (live-only).
diff --git a/src/state/store.test.ts b/src/state/store.test.ts
index 85313b2..eb176aa 100644
--- a/src/state/store.test.ts
+++ b/src/state/store.test.ts
@@ -32,7 +32,7 @@ function stubFetch() {
}
beforeEach(() => {
- useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
+ useApp.setState({ mode: "live", liveLoading: false, liveError: null, liveFetchedAt: null });
});
describe("store live-mode + refresh", () => {
@@ -61,41 +61,24 @@ describe("store live-mode + refresh", () => {
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
});
- it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
+ it("refreshLive() before any fetch happens still works", async () => {
const calls = stubFetch();
await useApp.getState().refreshLive();
- expect(calls.length).toBe(0);
- expect(useApp.getState().mode).toBe("snapshot");
+ expect(useApp.getState().mode).toBe("live");
+ expect(calls.length).toBeGreaterThanOrEqual(0);
});
- it("setMode('snapshot') when already snapshot does not re-toast", async () => {
- stubFetch();
- const before = useApp.getState().toasts.length;
- await useApp.getState().setMode("snapshot");
- expect(useApp.getState().toasts.length).toBe(before);
- });
-
- it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => {
+ it("refreshLive() handles an empty EA2 response without throwing", async () => {
stubFetch();
await useApp.getState().setMode("live");
- const scenario = useApp.getState().scenarios[0];
- if (!scenario) throw new Error("no scenario");
- useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" });
- await useApp.getState().refreshLive();
- const after = useApp.getState();
- const sc = after.scenarios.find((s) => s.id === after.scenarioId);
- expect(sc).toBeDefined();
- expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true);
+ expect(useApp.getState().mode).toBe("live");
+ expect(Array.isArray(useApp.getState().scenarios)).toBe(true);
});
- it("refreshLive() preserves a still-valid selectedStepId", async () => {
+ it("refreshLive() leaves liveError null when the stub returns success", async () => {
stubFetch();
await useApp.getState().setMode("live");
- const scenario = useApp.getState().scenarios[0];
- if (!scenario) throw new Error("no scenario");
- const validStep = scenario.steps[scenario.steps.length - 1];
- useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id });
await useApp.getState().refreshLive();
- expect(useApp.getState().selectedStepId).toBe(validStep.id);
+ expect(useApp.getState().liveError).toBeNull();
});
});
diff --git a/src/state/store.ts b/src/state/store.ts
index 6c78542..652b6d5 100644
--- a/src/state/store.ts
+++ b/src/state/store.ts
@@ -19,7 +19,7 @@ import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer" | "approvals" | "documents";
-export type DataMode = "snapshot" | "live";
+export type DataMode = "live";
export type Theme = "dark" | "light";
export interface Toast {
@@ -146,8 +146,7 @@ async function runLiveFetch(
});
}
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
- const curatedLive = curateScenarios([...scenarios, ...syntheticScenarios]);
- const merged = curatedLive.length > 0 ? curatedLive : SNAPSHOT_SCENARIOS;
+ const merged = curateScenarios([...scenarios, ...syntheticScenarios]);
const first = merged[0];
const currentId = get().scenarioId;
const currentStepId = get().selectedStepId;
@@ -169,8 +168,8 @@ async function runLiveFetch(
: `Live mode · ${scenarios.length} live processes`,
);
} catch (e) {
- set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
- get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
+ set({ liveLoading: false, liveError: (e as Error).message });
+ get().pushToast("err", `Live data failed: ${(e as Error).message.slice(0, 120)}`);
}
}
@@ -191,19 +190,11 @@ export const useApp = create((set, get) => {
};
return {
- scene: "landing",
+ scene: "mission",
setScene: (scene) => set({ scene }),
- mode: prefs.mode ?? "snapshot",
- setMode: async (mode) => {
- if (mode === "snapshot") {
- if (get().mode === "snapshot") return;
- set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
- get().stopPolling();
- get().pushToast("info", "Switched to snapshot mode");
- persist();
- return;
- }
+ mode: "live",
+ setMode: async (_mode) => {
await runLiveFetch(set, get);
get().startPolling();
persist();