Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ceb5c05bb | ||
|
|
0fb5351a3a | ||
|
|
97f14dd6f7 | ||
|
|
c28bd386ad | ||
|
|
b438e9a664 | ||
|
|
47b1bf6998 | ||
|
|
333667366d | ||
|
|
fd8c4853df | ||
|
|
4dad78aa86 | ||
|
|
b7145c325b |
@@ -19,8 +19,10 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
Generated
+48
@@ -20,12 +20,18 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.40.0
|
specifier: ^12.40.0
|
||||||
version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
leaflet:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
react-leaflet:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
reactflow:
|
reactflow:
|
||||||
specifier: ^11.11.4
|
specifier: ^11.11.4
|
||||||
version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -42,6 +48,9 @@ importers:
|
|||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^16.3.2
|
specifier: ^16.3.2
|
||||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@types/leaflet':
|
||||||
|
specifier: ^1.9.21
|
||||||
|
version: 1.9.21
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.12.3
|
specifier: ^24.12.3
|
||||||
version: 24.13.2
|
version: 24.13.2
|
||||||
@@ -492,6 +501,13 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@react-leaflet/core@2.1.0':
|
||||||
|
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^18.0.0
|
||||||
|
react-dom: ^18.0.0
|
||||||
|
|
||||||
'@reactflow/background@11.3.14':
|
'@reactflow/background@11.3.14':
|
||||||
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
|
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -772,6 +788,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
'@types/node@24.13.2':
|
'@types/node@24.13.2':
|
||||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||||
|
|
||||||
@@ -1297,6 +1316,9 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
leaflet@1.9.4:
|
||||||
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1499,6 +1521,13 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-leaflet@4.2.1:
|
||||||
|
resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^18.0.0
|
||||||
|
react-dom: ^18.0.0
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8:
|
react-remove-scroll-bar@2.3.8:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2270,6 +2299,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
|
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
'@reactflow/background@11.3.14(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
'@reactflow/background@11.3.14(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reactflow/core': 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
'@reactflow/core': 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -2572,6 +2607,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/node@24.13.2':
|
'@types/node@24.13.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
@@ -3118,6 +3157,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -3276,6 +3317,13 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
'@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const URL = "https://canvas.flow-master.ai/";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
const enter = p.locator("button", { hasText: /Enter Mission Control/i }).first();
|
||||||
|
if (await enter.count()) { await enter.click(); await p.waitForTimeout(1200); }
|
||||||
|
|
||||||
|
const tabs = await p.locator(".tab").allTextContents();
|
||||||
|
console.log("[debug] visible tabs:", tabs);
|
||||||
|
await p.locator(".tab", { hasText: /Pi/ }).first().click({ timeout: 5000 }).catch(e => console.log("Pi click err:", e.message));
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
|
||||||
|
const welcome = await p.locator(".agent-turn-agent .agent-turn-body").first().textContent();
|
||||||
|
console.log("[agent] welcome:", welcome?.slice(0, 120));
|
||||||
|
|
||||||
|
const tools = await p.locator(".agent-tool-name").allTextContents();
|
||||||
|
console.log("[agent] visible tools:", tools);
|
||||||
|
|
||||||
|
async function ask(text) {
|
||||||
|
await p.locator(".agent-composer textarea").fill(text);
|
||||||
|
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
|
||||||
|
await p.waitForTimeout(2200);
|
||||||
|
const all = await p.locator(".agent-turn-agent .agent-turn-body").allTextContents();
|
||||||
|
return all[all.length - 1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n[ask] list processes →", (await ask("list processes")).slice(0, 200));
|
||||||
|
console.log("[ask] open mission →", (await ask("open mission")).slice(0, 120));
|
||||||
|
console.log("[ask] tell Aisha that the laptop quote is approved →",
|
||||||
|
(await ask("tell Aisha that the laptop quote is approved")).slice(0, 200));
|
||||||
|
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Full chat dog-food: HR persona opens a thread with CEO, sends a message,
|
||||||
|
// then we re-load as CEO and verify the message is visible.
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const URL = "https://canvas.flow-master.ai/";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
|
||||||
|
async function asPersona(b, persona, work) {
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
const writes = [];
|
||||||
|
p.on("request", (req) => {
|
||||||
|
const u = req.url();
|
||||||
|
if ((req.method() === "POST" || req.method() === "PUT") && /\/api\/(ea2|v1)\//.test(u)) {
|
||||||
|
writes.push({ method: req.method(), path: u.replace("https://canvas.flow-master.ai", "") });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
p.on("response", async (res) => {
|
||||||
|
if (/\/api\/(ea2|v1)\//.test(res.url()) && res.status() >= 400) {
|
||||||
|
const t = await res.text().catch(() => "");
|
||||||
|
console.log(`[${persona.label} resp ${res.status()}] ${res.url().replace("https://canvas.flow-master.ai","")} → ${t.slice(0,180)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".persona-chip", { hasText: persona.chipText }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
console.log(`[${persona.label}] logged in`);
|
||||||
|
const enter = p.locator("button", { hasText: /Enter Mission Control/i }).first();
|
||||||
|
if (await enter.count()) {
|
||||||
|
await enter.click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
await work(p, writes);
|
||||||
|
await ctx.close();
|
||||||
|
return writes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
|
||||||
|
// HR opens a chat with CEO and sends a message
|
||||||
|
const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p, writes) => {
|
||||||
|
// Go to chat
|
||||||
|
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
|
||||||
|
await chatTab.click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
console.log("[HR] visible after Chat click:");
|
||||||
|
for (const b of await p.locator("button, .chat-thread-name").all()) {
|
||||||
|
const t = (await b.textContent())?.trim();
|
||||||
|
if (t) console.log(" -", t.slice(0, 70));
|
||||||
|
}
|
||||||
|
// Pick CEO from select
|
||||||
|
const sel = p.locator(".chat-new-row select").first();
|
||||||
|
if (await sel.count()) {
|
||||||
|
await sel.selectOption("ceo-head@flow-master.ai");
|
||||||
|
await p.locator(".chat-new-row button").click();
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
// Send a message
|
||||||
|
const ta = p.locator(".chat-composer textarea").first();
|
||||||
|
if (await ta.count()) {
|
||||||
|
await ta.fill("Hey Mariana, store manager is requesting a new laptop. What's the approval cap this quarter?");
|
||||||
|
await p.locator(".chat-composer button", { hasText: /Send/i }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
}
|
||||||
|
console.log(`[HR] writes so far: ${writes.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CEO logs in and verifies the thread + message is visible
|
||||||
|
await asPersona(b, { label: "CEO", chipText: /Mariana/ }, async (p) => {
|
||||||
|
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
|
||||||
|
await chatTab.click();
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
const threadRows = await p.locator(".chat-thread-name").allTextContents();
|
||||||
|
console.log("[CEO] visible threads:", threadRows);
|
||||||
|
// Click the first thread that mentions Aisha
|
||||||
|
const aishaThread = p.locator(".chat-thread-row", { hasText: /Aisha/ }).first();
|
||||||
|
if (await aishaThread.count()) {
|
||||||
|
await aishaThread.click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const bodies = await p.locator(".chat-bubble-body").allTextContents();
|
||||||
|
console.log("[CEO] visible message bodies:", bodies);
|
||||||
|
} else {
|
||||||
|
console.log("[CEO] no Aisha thread found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n=== HR side EA2 writes: ${hrWrites.length} ===`);
|
||||||
|
hrWrites.forEach((w) => console.log(` ${w.method} ${w.path}`));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
await p.locator(".hub-chip", { hasText: /Attendance Map/ }).click();
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
const tile = await p.locator(".leaflet-tile").count();
|
||||||
|
const markers = await p.locator(".leaflet-marker-icon").count();
|
||||||
|
const stats = await p.locator(".geo-stat").allTextContents();
|
||||||
|
console.log(`tiles loaded: ${tile}, markers: ${markers}, stats: ${stats.join(" | ")}`);
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const URL = "https://canvas.flow-master.ai/";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
const chips = await p.locator(".hub-chip").allTextContents();
|
||||||
|
console.log("[landing] hub chips:", chips);
|
||||||
|
|
||||||
|
for (const hub of ["Procurement Hub", "People Hub", "IT Hub"]) {
|
||||||
|
await p.locator(".hub-chip", { hasText: hub }).click();
|
||||||
|
await p.waitForTimeout(2200);
|
||||||
|
const title = await p.locator(".mc-hero-title").first().textContent();
|
||||||
|
const quickActions = await p.locator(".hub-quick-title").allTextContents();
|
||||||
|
const flowCards = await p.locator(".hub-flow-title").allTextContents();
|
||||||
|
console.log(`\n[${hub}] title=${title?.trim()} | quick=${quickActions.length} | flows=${flowCards.length}`);
|
||||||
|
console.log(` flows visible (top 3): ${flowCards.slice(0, 3).join(" | ")}`);
|
||||||
|
// navigate back to landing
|
||||||
|
await p.locator(".tab", { hasText: /Home/ }).first().click().catch(() => {});
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
}
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Single end-to-end QA gate. Exercises every new surface as a real user would,
|
||||||
|
// against the live canvas.flow-master.ai URL with real EA2 traffic. Exits non-zero
|
||||||
|
// on any failure so CI can wrap it.
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const URL = "https://canvas.flow-master.ai/";
|
||||||
|
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail = "") {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true, args });
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
|
||||||
|
// 1. Landing renders & login page is gated
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(1000);
|
||||||
|
const onLogin = await p.locator(".login-page").count();
|
||||||
|
record("auth_guard_unauthed_redirects_to_login", onLogin === 1);
|
||||||
|
const chips = await p.locator(".persona-chip").allTextContents();
|
||||||
|
record("login_shows_three_personas", chips.length === 3, chips.join(" | "));
|
||||||
|
const ssoBtn = await p.locator(".sso-btn").count();
|
||||||
|
record("login_has_microsoft_sso_button", ssoBtn === 1);
|
||||||
|
|
||||||
|
// 2. Persona dev-login works
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||||
|
await p.locator(".dev-login-btn").click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
const onLanding = await p.locator(".hero-actions").count();
|
||||||
|
record("ceo_persona_dev_login_lands_on_landing", onLanding === 1);
|
||||||
|
|
||||||
|
// 3. Landing chips include every new surface
|
||||||
|
const hubChips = await p.locator(".hub-chip").allTextContents();
|
||||||
|
const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Talk to Pi", "Team Chat", "What is FlowMaster?"];
|
||||||
|
const missing = expected.filter((e) => !hubChips.some((h) => h.includes(e)));
|
||||||
|
record("landing_exposes_all_hubs_and_extras", missing.length === 0, missing.length ? `missing: ${missing.join(", ")}` : "all 7 present");
|
||||||
|
|
||||||
|
// 4. Explainer renders 8 cards
|
||||||
|
await p.locator(".hub-chip", { hasText: /What is FlowMaster/ }).click();
|
||||||
|
await p.waitForTimeout(1000);
|
||||||
|
const explainerCards = await p.locator(".explainer-card").count();
|
||||||
|
record("explainer_renders_eight_cards", explainerCards === 8, `cards=${explainerCards}`);
|
||||||
|
|
||||||
|
// 5. Geo-attendance: tiles + markers + stats
|
||||||
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".hub-chip", { hasText: /Attendance Map/ }).click();
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
const tiles = await p.locator(".leaflet-tile").count();
|
||||||
|
const markers = await p.locator(".leaflet-marker-icon").count();
|
||||||
|
record("geo_attendance_renders_tiles", tiles > 10, `tiles=${tiles}`);
|
||||||
|
record("geo_attendance_renders_eight_markers", markers === 8, `markers=${markers}`);
|
||||||
|
|
||||||
|
// 6. Pi agent: tool list + list_processes round-trip
|
||||||
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".hub-chip", { hasText: /Talk to Pi/ }).click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
const toolNames = await p.locator(".agent-tool-name").allTextContents();
|
||||||
|
record("pi_agent_shows_five_tools", toolNames.length === 5, toolNames.join(", "));
|
||||||
|
const beforeCount = await p.locator(".agent-turn-agent .agent-turn-body").count();
|
||||||
|
await p.locator(".agent-composer textarea").fill("list processes");
|
||||||
|
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
|
||||||
|
await p.waitForFunction(
|
||||||
|
(n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n,
|
||||||
|
beforeCount,
|
||||||
|
{ timeout: 8000 }
|
||||||
|
).catch(() => {});
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
const lastReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
|
||||||
|
record("pi_agent_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80));
|
||||||
|
|
||||||
|
// 7. Procurement hub returns real catalogue
|
||||||
|
await p.locator(".tab", { hasText: /Home/ }).first().click();
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator(".hub-chip", { hasText: /Procurement Hub/ }).click();
|
||||||
|
await p.waitForTimeout(2200);
|
||||||
|
const hubFlows = await p.locator(".hub-flow-title").count();
|
||||||
|
record("procurement_hub_lists_real_flows", hubFlows > 0, `flows=${hubFlows}`);
|
||||||
|
|
||||||
|
// 8. Chat thread + message round-trip in one session (already covered by audit_chat,
|
||||||
|
// here we just verify the Chat scene loads + sidebar shows existing threads)
|
||||||
|
await p.locator(".tab", { hasText: /Chat/ }).first().click();
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
const existingThreads = await p.locator(".chat-thread-row").count();
|
||||||
|
record("chat_scene_loads_with_thread_sidebar", existingThreads >= 0, `threads=${existingThreads}`);
|
||||||
|
|
||||||
|
// 9. Theme toggle flips the data-theme attribute
|
||||||
|
const themeBtn = p.locator(".theme-toggle").first();
|
||||||
|
const before = await p.evaluate(() => document.documentElement.dataset.theme || "light");
|
||||||
|
await themeBtn.click();
|
||||||
|
await p.waitForTimeout(300);
|
||||||
|
const after = await p.evaluate(() => document.documentElement.dataset.theme || "light");
|
||||||
|
record("theme_toggle_flips_theme", before !== after, `${before} -> ${after}`);
|
||||||
|
|
||||||
|
// 10. Security headers
|
||||||
|
const headers = (await p.request.get(URL)).headers();
|
||||||
|
const securityChecks = {
|
||||||
|
"strict-transport-security": (v) => /max-age=\d+/.test(v),
|
||||||
|
"x-frame-options": (v) => /deny|sameorigin/i.test(v),
|
||||||
|
"x-content-type-options": (v) => /nosniff/i.test(v),
|
||||||
|
"referrer-policy": (v) => v.length > 0,
|
||||||
|
"content-security-policy": (v) => v.length > 0,
|
||||||
|
"permissions-policy": (v) => v.length > 0,
|
||||||
|
};
|
||||||
|
for (const [h, check] of Object.entries(securityChecks)) {
|
||||||
|
const v = headers[h] || "";
|
||||||
|
record(`security_header_${h.replace(/-/g, "_")}`, !!v && check(v), v ? v.slice(0, 60) : "missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarise
|
||||||
|
const fails = results.filter((r) => !r.ok);
|
||||||
|
console.log(`\n=== ${results.length - fails.length}/${results.length} passed ===`);
|
||||||
|
if (fails.length) {
|
||||||
|
console.log("FAILED:");
|
||||||
|
fails.forEach((f) => console.log(` - ${f.name}${f.detail ? ": " + f.detail : ""}`));
|
||||||
|
}
|
||||||
|
await b.close();
|
||||||
|
process.exit(fails.length === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({ headless: true, args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"] });
|
||||||
|
const p = await (await b.newContext()).newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(1000);
|
||||||
|
const chips = await p.locator(".persona-chip").allTextContents();
|
||||||
|
console.log("persona chips:", chips);
|
||||||
|
// Click CEO chip → email field should fill
|
||||||
|
await p.locator(".persona-chip", { hasText: /Mariana/ }).click().catch(() => {});
|
||||||
|
await p.waitForTimeout(300);
|
||||||
|
const email = await p.locator("input[type='email']").inputValue();
|
||||||
|
console.log("after Mariana click, email field:", email);
|
||||||
|
await b.close();
|
||||||
+19
-1
@@ -8,10 +8,15 @@ import Wizard from "./scenes/Wizard";
|
|||||||
import Settings from "./scenes/Settings";
|
import Settings from "./scenes/Settings";
|
||||||
import Login from "./scenes/Login";
|
import Login from "./scenes/Login";
|
||||||
import SsoCallback from "./scenes/SsoCallback";
|
import SsoCallback from "./scenes/SsoCallback";
|
||||||
|
import Chat from "./scenes/Chat";
|
||||||
|
import Agent from "./scenes/Agent";
|
||||||
|
import Hub from "./scenes/Hub";
|
||||||
|
import GeoAttendance from "./scenes/GeoAttendance";
|
||||||
|
import Explainer from "./scenes/Explainer";
|
||||||
import CommandBar from "./components/CommandBar";
|
import CommandBar from "./components/CommandBar";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
import Console from "./components/Console";
|
import Console from "./components/Console";
|
||||||
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";
|
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot } from "./components/icons";
|
||||||
import { liveMeta } from "./data/scenarios";
|
import { liveMeta } from "./data/scenarios";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -71,6 +76,12 @@ export default function App() {
|
|||||||
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
||||||
<Branch size={13} /> Studio
|
<Branch size={13} /> Studio
|
||||||
</button>
|
</button>
|
||||||
|
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
||||||
|
<Bot size={13} /> Chat
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||||
|
<Bot size={13} /> Pi
|
||||||
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
||||||
<Cog size={13} /> Settings
|
<Cog size={13} /> Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -149,6 +160,13 @@ export default function App() {
|
|||||||
{scene === "mission" && <MissionControl />}
|
{scene === "mission" && <MissionControl />}
|
||||||
{scene === "history" && <RunHistory />}
|
{scene === "history" && <RunHistory />}
|
||||||
{scene === "studio" && <Wizard />}
|
{scene === "studio" && <Wizard />}
|
||||||
|
{scene === "chat" && <Chat />}
|
||||||
|
{scene === "agent" && <Agent />}
|
||||||
|
{scene === "hub-procurement" && <Hub hub="procurement" />}
|
||||||
|
{scene === "hub-hr" && <Hub hub="hr" />}
|
||||||
|
{scene === "hub-it" && <Hub hub="it" />}
|
||||||
|
{scene === "geo-attendance" && <GeoAttendance />}
|
||||||
|
{scene === "explainer" && <Explainer />}
|
||||||
{scene === "settings" && <Settings />}
|
{scene === "settings" && <Settings />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+204
@@ -1296,6 +1296,41 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.login-personas {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--bp-navy);
|
||||||
|
border-top-color: color-mix(in srgb, var(--bp-navy) 20%, transparent);
|
||||||
|
}
|
||||||
|
.login-persona-label {
|
||||||
|
font-family: var(--bp-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--bp-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.login-persona-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.persona-chip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bp-paper);
|
||||||
|
border: 1px solid var(--bp-navy);
|
||||||
|
color: var(--bp-navy);
|
||||||
|
font-family: var(--bp-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.persona-chip:hover:not(:disabled) {
|
||||||
|
background: var(--bp-amber);
|
||||||
|
color: var(--bp-paper);
|
||||||
|
}
|
||||||
|
.persona-chip:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.sso-btn {
|
.sso-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1790,3 +1825,172 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
|
|
||||||
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
|
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.chat-scene {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: var(--bp-paper);
|
||||||
|
}
|
||||||
|
.chat-sidebar {
|
||||||
|
border: 1px solid var(--bp-navy);
|
||||||
|
background: var(--bp-paper);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-sidebar-head { padding: 12px; border-bottom: 1px solid var(--bp-navy); }
|
||||||
|
.chat-new-thread { padding: 12px; border-bottom: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.chat-new-thread label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); text-transform: uppercase; }
|
||||||
|
.chat-new-row { display: flex; gap: 6px; }
|
||||||
|
.chat-new-row select {
|
||||||
|
flex: 1; padding: 6px 8px; background: var(--bp-paper); border: 1px solid var(--bp-navy);
|
||||||
|
color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px;
|
||||||
|
}
|
||||||
|
.chat-thread-list { flex: 1; overflow-y: auto; }
|
||||||
|
.chat-thread-row {
|
||||||
|
width: 100%; text-align: left; padding: 10px 12px; background: var(--bp-paper);
|
||||||
|
border: 0; border-bottom: 1px solid color-mix(in srgb, var(--bp-navy) 15%, transparent);
|
||||||
|
color: var(--bp-navy); cursor: pointer; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.chat-thread-row:hover { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); }
|
||||||
|
.chat-thread-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); }
|
||||||
|
.chat-thread-name { font-weight: 600; font-size: 12px; }
|
||||||
|
.chat-thread-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
|
||||||
|
.chat-empty { padding: 16px; font-size: 11px; color: var(--bp-muted); text-align: center; }
|
||||||
|
.chat-main {
|
||||||
|
border: 1px solid var(--bp-navy);
|
||||||
|
background: var(--bp-paper);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-empty-main { padding: 32px; color: var(--bp-muted); text-align: center; font-size: 12px; }
|
||||||
|
.chat-main-head { padding: 12px 16px; border-bottom: 1px solid var(--bp-navy); }
|
||||||
|
.chat-title { font-weight: 700; font-size: 13px; color: var(--bp-navy); }
|
||||||
|
.chat-subtitle { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-top: 2px; }
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.chat-bubble-row { display: flex; }
|
||||||
|
.chat-bubble-row.mine { justify-content: flex-end; }
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bp-paper);
|
||||||
|
border: 1px solid var(--bp-navy);
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble-row.mine .chat-bubble {
|
||||||
|
background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper));
|
||||||
|
}
|
||||||
|
.chat-bubble-author { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
|
||||||
|
.chat-bubble-body { font-size: 12px; line-height: 1.4; color: var(--bp-navy); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.chat-bubble-time { font-family: var(--bp-mono); font-size: 9px; color: var(--bp-muted); text-align: right; }
|
||||||
|
.chat-composer { padding: 12px; border-top: 1px solid var(--bp-navy); display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.chat-composer textarea {
|
||||||
|
flex: 1; padding: 8px; resize: none;
|
||||||
|
background: var(--bp-paper); border: 1px solid var(--bp-navy);
|
||||||
|
color: var(--bp-navy); font-family: var(--bp-mono); font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.chat-scene { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.chat-sidebar { max-height: 240px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-scene {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: var(--bp-paper);
|
||||||
|
}
|
||||||
|
.agent-sidebar {
|
||||||
|
border: 1px solid var(--bp-navy);
|
||||||
|
background: var(--bp-paper);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.agent-side-head { padding: 12px; border-bottom: 1px solid var(--bp-navy); }
|
||||||
|
.agent-tool-list { padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.agent-tool-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); margin-bottom: 4px; }
|
||||||
|
.agent-tool-row { display: flex; gap: 8px; align-items: flex-start; }
|
||||||
|
.agent-tool-name { font-size: 11px; font-weight: 700; color: var(--bp-navy); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.agent-tool-desc { font-size: 10px; color: var(--bp-muted); line-height: 1.4; }
|
||||||
|
.agent-main { border: 1px solid var(--bp-navy); background: var(--bp-paper); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.agent-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.agent-turn { padding: 10px 12px; border: 1px solid var(--bp-navy); max-width: 80%; }
|
||||||
|
.agent-turn-user { align-self: flex-end; background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); }
|
||||||
|
.agent-turn-agent { align-self: flex-start; background: var(--bp-paper); }
|
||||||
|
.agent-turn-err { border-color: #c25555; }
|
||||||
|
.agent-turn-thinking { font-style: italic; color: var(--bp-muted); }
|
||||||
|
.agent-turn-author { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-bottom: 4px; }
|
||||||
|
.agent-turn-body { font-size: 12px; color: var(--bp-navy); white-space: pre-wrap; line-height: 1.4; }
|
||||||
|
.agent-suggestions { padding: 0 16px 12px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.agent-suggestion { padding: 6px 10px; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 10px; cursor: pointer; }
|
||||||
|
.agent-suggestion:hover { background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); }
|
||||||
|
.agent-composer { padding: 12px; border-top: 1px solid var(--bp-navy); display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.agent-composer textarea { flex: 1; padding: 8px; resize: none; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.agent-scene { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.agent-sidebar { max-height: 240px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-scene { padding: 24px; max-width: 1200px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
|
||||||
|
.hub-head { margin-bottom: 24px; }
|
||||||
|
.hub-intro { font-size: 13px; color: var(--bp-muted); max-width: 720px; line-height: 1.5; margin-top: 6px; }
|
||||||
|
.hub-section-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); margin-bottom: 10px; text-transform: uppercase; }
|
||||||
|
.hub-quick { margin-bottom: 32px; }
|
||||||
|
.hub-quick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
.hub-quick-card { text-align: left; padding: 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); cursor: pointer; transition: background 0.1s; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.hub-quick-card:hover:not(:disabled) { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); }
|
||||||
|
.hub-quick-card:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.hub-quick-title { font-size: 12px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.hub-quick-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
|
||||||
|
.hub-catalogue-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
.hub-flow-card { padding: 12px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.hub-flow-title { font-size: 12px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.hub-flow-desc { font-size: 11px; color: var(--bp-muted); line-height: 1.4; }
|
||||||
|
.hub-empty { padding: 16px; font-size: 11px; color: var(--bp-muted); }
|
||||||
|
.link-inline { background: transparent; border: 0; padding: 0; color: var(--bp-navy); text-decoration: underline; cursor: pointer; font: inherit; }
|
||||||
|
|
||||||
|
.hero-hub-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||||
|
.hub-chip { padding: 8px 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px; letter-spacing: 0.04em; cursor: pointer; transition: background 0.1s; }
|
||||||
|
.hub-chip:hover { background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); }
|
||||||
|
|
||||||
|
.geo-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
|
||||||
|
.geo-head { margin-bottom: 20px; }
|
||||||
|
.geo-intro { font-size: 13px; color: var(--bp-muted); max-width: 720px; line-height: 1.5; margin-top: 6px; }
|
||||||
|
.geo-stats { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.geo-stat { font-family: var(--bp-mono); font-size: 11px; padding: 4px 10px; border: 1px solid var(--bp-navy); }
|
||||||
|
.geo-stat-ok { background: color-mix(in srgb, #4a8a3f 25%, var(--bp-paper)); }
|
||||||
|
.geo-stat-late { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); }
|
||||||
|
.geo-stat-off { color: var(--bp-muted); }
|
||||||
|
.geo-filter-row { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.hub-chip.active { background: var(--bp-amber); color: var(--bp-paper); }
|
||||||
|
.geo-map-wrap { border: 1px solid var(--bp-navy); height: 560px; overflow: hidden; }
|
||||||
|
.geo-map { width: 100%; height: 100%; }
|
||||||
|
.geo-popup-tag { font-family: var(--bp-mono); font-size: 10px; padding: 2px 6px; border: 1px solid currentColor; display: inline-block; margin: 2px 0; }
|
||||||
|
.geo-popup-checked_in { color: #2e7a25; }
|
||||||
|
.geo-popup-late { color: #c08020; }
|
||||||
|
.geo-popup-checked_out { color: #777; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.geo-map-wrap { height: 380px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.explainer-scene { padding: 32px 24px; max-width: 1200px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
|
||||||
|
.explainer-head { margin-bottom: 32px; max-width: 800px; }
|
||||||
|
.explainer-intro { font-size: 14px; color: var(--bp-muted); line-height: 1.6; margin-top: 8px; }
|
||||||
|
.explainer-jump-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||||
|
.explainer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
||||||
|
.explainer-card { padding: 18px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.explainer-card-num { font-family: var(--bp-mono); font-size: 11px; color: var(--bp-muted); }
|
||||||
|
.explainer-card-eyebrow { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-amber); text-transform: uppercase; }
|
||||||
|
.explainer-card-title { font-size: 16px; font-weight: 700; color: var(--bp-navy); margin: 0; }
|
||||||
|
.explainer-card-body { font-size: 12px; line-height: 1.55; color: var(--bp-navy); white-space: pre-wrap; margin: 0; }
|
||||||
|
.explainer-foot { margin-top: 32px; padding-top: 16px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 25%, transparent); }
|
||||||
|
.explainer-foot-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); }
|
||||||
|
.explainer-foot p { font-size: 11px; color: var(--bp-muted); margin-top: 4px; }
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { api } from "./api";
|
||||||
|
import { wizardApi } from "./wizardApi";
|
||||||
|
import { chatApi } from "./chatApi";
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
ok: boolean;
|
||||||
|
display: string;
|
||||||
|
action?: { kind: "navigate"; scene: string };
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolContext {
|
||||||
|
navigate: (scene: string) => void;
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDef {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
matcher: RegExp;
|
||||||
|
run: (input: string, ctx: ToolContext) => Promise<ToolResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS: ToolDef[] = [
|
||||||
|
{
|
||||||
|
name: "navigate",
|
||||||
|
description: "Open a section of FlowMaster — mission, runs, studio, chat, settings, hubs",
|
||||||
|
matcher: /^(go to|open|show me|navigate to|take me to)\s+(.+)$/i,
|
||||||
|
async run(input, ctx) {
|
||||||
|
const m = input.match(/^(go to|open|show me|navigate to|take me to)\s+(.+)$/i);
|
||||||
|
const dest = m?.[2]?.toLowerCase().trim().replace(/[.!?]+$/, "") || "";
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
mission: "mission",
|
||||||
|
"mission control": "mission",
|
||||||
|
runs: "history",
|
||||||
|
"run history": "history",
|
||||||
|
history: "history",
|
||||||
|
studio: "studio",
|
||||||
|
wizard: "studio",
|
||||||
|
chat: "chat",
|
||||||
|
settings: "settings",
|
||||||
|
home: "landing",
|
||||||
|
landing: "landing",
|
||||||
|
procurement: "hub-procurement",
|
||||||
|
"procurement hub": "hub-procurement",
|
||||||
|
hr: "hub-hr",
|
||||||
|
"hr hub": "hub-hr",
|
||||||
|
it: "hub-it",
|
||||||
|
"it hub": "hub-it",
|
||||||
|
attendance: "geo-attendance",
|
||||||
|
"geo attendance": "geo-attendance",
|
||||||
|
explainer: "explainer",
|
||||||
|
"what is flowmaster": "explainer",
|
||||||
|
};
|
||||||
|
const target = map[dest];
|
||||||
|
if (!target) return { ok: false, display: `I don't know where "${dest}" is. Try: mission, runs, studio, chat, procurement hub, HR hub, IT hub, attendance.` };
|
||||||
|
ctx.navigate(target);
|
||||||
|
return { ok: true, display: `Opened ${dest}.`, action: { kind: "navigate", scene: target } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start_process",
|
||||||
|
description: "Start a process instance",
|
||||||
|
matcher: /^(start|run|kick off|trigger)\s+(.+)$/i,
|
||||||
|
async run(input, _ctx) {
|
||||||
|
const m = input.match(/^(start|run|kick off|trigger)\s+(.+)$/i);
|
||||||
|
const phrase = m?.[2] || "";
|
||||||
|
const subjectMatch = phrase.match(/^(.*?)\s+(for|on|about)\s+(.+)$/i);
|
||||||
|
const processName = subjectMatch ? subjectMatch[1] : phrase;
|
||||||
|
const subject = subjectMatch ? subjectMatch[3] : undefined;
|
||||||
|
|
||||||
|
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=200`, {
|
||||||
|
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const items: any[] = procs?.items || [];
|
||||||
|
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||||
|
const target = norm(processName);
|
||||||
|
const match = items
|
||||||
|
.filter((it) => it.status === "published" && it.kind === "definition")
|
||||||
|
.find((it) => norm(it.display_name || "").includes(target) || norm(it.name || "").includes(target));
|
||||||
|
|
||||||
|
if (!match) return { ok: false, display: `Couldn't find a published process matching "${processName}". Try the Studio to create one.` };
|
||||||
|
try {
|
||||||
|
const res = await wizardApi.startInstance(match._key, subject);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
display: `Started ${match.display_name}${subject ? ` for ${subject}` : ""}. Transaction ${res.transaction_id?.slice(0, 8) || "ok"}.`,
|
||||||
|
data: res,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, display: `Start failed: ${e.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "send_chat",
|
||||||
|
description: "Send a chat message to a teammate",
|
||||||
|
matcher: /^(message|tell|text|ask)\s+(.+?)\s+(that|about|:)\s+(.+)$/i,
|
||||||
|
async run(input, ctx) {
|
||||||
|
const m = input.match(/^(message|tell|text|ask)\s+(.+?)\s+(that|about|:)\s+(.+)$/i);
|
||||||
|
if (!m) return { ok: false, display: "Try: message Mariana that the laptop quote is approved" };
|
||||||
|
const target = m[2].toLowerCase();
|
||||||
|
const body = m[4];
|
||||||
|
const directory: Record<string, string> = {
|
||||||
|
mariana: "ceo-head@flow-master.ai", ceo: "ceo-head@flow-master.ai",
|
||||||
|
aisha: "hr-head@flow-master.ai", hr: "hr-head@flow-master.ai",
|
||||||
|
rohan: "it-head@flow-master.ai", it: "it-head@flow-master.ai",
|
||||||
|
};
|
||||||
|
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 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);
|
||||||
|
return { ok: true, display: `Sent to ${recipient.split("@")[0]}: "${body}".` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_process",
|
||||||
|
description: "Open the Process Creation Wizard to build a new process",
|
||||||
|
matcher: /^(create|new|design|build)\s+(a\s+)?(process|workflow|flow)/i,
|
||||||
|
async run(_input, ctx) {
|
||||||
|
ctx.navigate("studio");
|
||||||
|
return { ok: true, display: "Opened the Process Creation Wizard. Describe the process and I'll seed the structure.", action: { kind: "navigate", scene: "studio" } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_processes",
|
||||||
|
description: "List published processes for this tenant",
|
||||||
|
matcher: /^(list|show|what)\s+(processes|workflows|flows)/i,
|
||||||
|
async run() {
|
||||||
|
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=20`, {
|
||||||
|
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const published = (procs?.items || []).filter((it: any) => it.status === "published" && it.kind === "definition").slice(0, 10);
|
||||||
|
if (!published.length) return { ok: true, display: "No published processes yet. Build one in the Studio." };
|
||||||
|
const lines = published.map((it: any) => `• ${it.display_name || it.name}`).join("\n");
|
||||||
|
return { ok: true, display: `Here's what's published:\n${lines}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function routeAgentInput(text: string, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return { ok: false, display: "Tell me what to do." };
|
||||||
|
for (const tool of TOOLS) {
|
||||||
|
if (tool.matcher.test(trimmed)) {
|
||||||
|
try {
|
||||||
|
return await tool.run(trimmed, ctx);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, display: `${tool.name} failed: ${e.message || e}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
display:
|
||||||
|
"I can: navigate (e.g. 'open mission'), start a process ('run laptop procurement for store-204'), message a teammate ('tell Rohan that...'), list processes, or open the wizard ('create a new process').",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentToolList(): { name: string; description: string }[] {
|
||||||
|
return TOOLS.map((t) => ({ name: t.name, description: t.description }));
|
||||||
|
}
|
||||||
@@ -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 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: "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;
|
||||||
|
},
|
||||||
|
};
|
||||||
+19
-12
@@ -120,30 +120,37 @@ export const wizardApi = {
|
|||||||
return api.startTransaction(process_definition_id, business_subject, signal);
|
return api.startTransaction(process_definition_id, business_subject, signal);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Helpers so call sites don't have to know the discriminated-union shape by heart. */
|
|
||||||
ops: {
|
ops: {
|
||||||
createNode(flowKey: string, node: { _key: string; display_name: string; dispatch_kind: string; agent_capability?: string }): CreateOp {
|
createStep(node: { display_name: string; dispatch_kind: string; agent_capability?: string; description?: string }): CreateOp {
|
||||||
const { _key, ...rest } = node;
|
const name = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40) || "step";
|
||||||
return {
|
return {
|
||||||
op: "create",
|
op: "create",
|
||||||
coll: "ea2_node",
|
coll: "flow",
|
||||||
data: { _key, flow_key: flowKey, ...rest },
|
data: {
|
||||||
|
kind: "definition",
|
||||||
|
name,
|
||||||
|
display_name: node.display_name,
|
||||||
|
status: "draft",
|
||||||
|
description: node.description || node.display_name,
|
||||||
|
source_context: "EA2_WIZARD_STEP",
|
||||||
|
dispatch_kind: node.dispatch_kind,
|
||||||
|
...(node.agent_capability ? { agent_capability: node.agent_capability } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
createEdge(_flowKey: string, fromKey: string, toKey: string, role = "next"): CreateEdgeOp {
|
createStepEdge(parentFlowKey: string, childFlowKey: string): CreateEdgeOp {
|
||||||
// EA2 server rejects `data` on create_edge despite the OpenAPI schema marking it optional.
|
|
||||||
return {
|
return {
|
||||||
op: "create_edge",
|
op: "create_edge",
|
||||||
edge_coll: "ea2_edge",
|
edge_coll: "defines",
|
||||||
from: `ea2_node/${fromKey}`,
|
from: `flow/${parentFlowKey}`,
|
||||||
to: `ea2_node/${toKey}`,
|
to: `flow/${childFlowKey}`,
|
||||||
role,
|
role: "next",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
publishFlow(flowKey: string): UpdateOp {
|
publishFlow(flowKey: string): UpdateOp {
|
||||||
return {
|
return {
|
||||||
op: "update",
|
op: "update",
|
||||||
coll: "ea2_flow",
|
coll: "flow",
|
||||||
key: flowKey,
|
key: flowKey,
|
||||||
data: { status: "published" },
|
data: { status: "published" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { routeAgentInput, agentToolList, type ToolResult } from "../lib/agentTools";
|
||||||
|
import { Bot, Branch } from "../components/icons";
|
||||||
|
|
||||||
|
interface Turn {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "agent";
|
||||||
|
text: string;
|
||||||
|
ok?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
"Open mission control",
|
||||||
|
"List processes",
|
||||||
|
"Run procurement to pay for store-204",
|
||||||
|
"Tell Rohan that the laptop quote is approved",
|
||||||
|
"Create a new process",
|
||||||
|
"Open the IT hub",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Agent() {
|
||||||
|
const userEmail = useApp((s) => s.userEmail);
|
||||||
|
const setScene = useApp((s) => s.setScene as (s: string) => void);
|
||||||
|
const [turns, setTurns] = useState<Turn[]>([
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
role: "agent",
|
||||||
|
ok: true,
|
||||||
|
text:
|
||||||
|
`Hi ${userEmail.split("@")[0]}. I'm Pi — your FlowMaster co-pilot. I can navigate the cockpit, start processes, message your team, and walk you through the Studio.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }), 50);
|
||||||
|
}, [turns]);
|
||||||
|
|
||||||
|
const submit = async (text: string) => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t || working) return;
|
||||||
|
const userTurn: Turn = { id: `u-${Date.now()}`, role: "user", text: t };
|
||||||
|
setTurns((prev) => [...prev, userTurn]);
|
||||||
|
setDraft("");
|
||||||
|
setWorking(true);
|
||||||
|
try {
|
||||||
|
const res: ToolResult = await routeAgentInput(t, {
|
||||||
|
navigate: (s) => setScene(s),
|
||||||
|
userEmail,
|
||||||
|
});
|
||||||
|
setTurns((prev) => [...prev, { id: `a-${Date.now()}`, role: "agent", text: res.display, ok: res.ok }]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTurns((prev) => [...prev, { id: `a-${Date.now()}`, role: "agent", text: `Error: ${e.message}`, ok: false }]);
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="agent-scene">
|
||||||
|
<aside className="agent-sidebar">
|
||||||
|
<header className="agent-side-head">
|
||||||
|
<div className="mc-hero-eyebrow"><Bot size={12} /> Pi — FlowMaster Agent</div>
|
||||||
|
<h2 className="mc-hero-title">Talk to your cockpit</h2>
|
||||||
|
</header>
|
||||||
|
<div className="agent-tool-list">
|
||||||
|
<div className="agent-tool-label">CAPABILITIES</div>
|
||||||
|
{agentToolList().map((t) => (
|
||||||
|
<div key={t.name} className="agent-tool-row">
|
||||||
|
<Branch size={11} />
|
||||||
|
<div>
|
||||||
|
<div className="agent-tool-name">{t.name.replace(/_/g, " ")}</div>
|
||||||
|
<div className="agent-tool-desc">{t.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="agent-main">
|
||||||
|
<div className="agent-messages" ref={scrollRef}>
|
||||||
|
{turns.map((t) => (
|
||||||
|
<div key={t.id} className={`agent-turn agent-turn-${t.role}${t.ok === false ? " agent-turn-err" : ""}`}>
|
||||||
|
<div className="agent-turn-author">{t.role === "user" ? userEmail.split("@")[0] : "Pi"}</div>
|
||||||
|
<div className="agent-turn-body">{t.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{working && <div className="agent-turn agent-turn-agent agent-turn-thinking">Pi is thinking…</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{turns.length <= 1 && (
|
||||||
|
<div className="agent-suggestions">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button key={s} className="agent-suggestion" onClick={() => submit(s)}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="agent-composer">
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit(draft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Ask Pi to navigate, start a process, or message a teammate. Enter to send."
|
||||||
|
rows={2}
|
||||||
|
disabled={working}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={() => submit(draft)} disabled={!draft.trim() || working}>
|
||||||
|
{working ? "…" : "Send"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi";
|
||||||
|
import { Bot } from "../components/icons";
|
||||||
|
|
||||||
|
const PERSONA_DIRECTORY: { email: string; label: string }[] = [
|
||||||
|
{ email: "ceo-head@flow-master.ai", label: "Mariana Cole · CEO" },
|
||||||
|
{ email: "hr-head@flow-master.ai", label: "Aisha Khan · HR" },
|
||||||
|
{ email: "it-head@flow-master.ai", label: "Rohan Patel · IT" },
|
||||||
|
{ email: "dev@flow-master.ai", label: "Developer" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function personaLabel(email: string): string {
|
||||||
|
return PERSONA_DIRECTORY.find((p) => p.email === email)?.label || email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const userEmail = useApp((s) => s.userEmail);
|
||||||
|
const pushToast = useApp((s) => s.pushToast);
|
||||||
|
const me = userEmail || "dev@flow-master.ai";
|
||||||
|
|
||||||
|
const [threads, setThreads] = useState<ChatThread[]>([]);
|
||||||
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [recipient, setRecipient] = useState<string>("hr-head@flow-master.ai");
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingThreads(true);
|
||||||
|
chatApi
|
||||||
|
.listThreads()
|
||||||
|
.then((rows) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setThreads(rows);
|
||||||
|
if (!activeKey && rows.length > 0) setActiveKey(rows[0]._key);
|
||||||
|
})
|
||||||
|
.catch((err) => pushToast("err", `Threads failed: ${err.message}`))
|
||||||
|
.finally(() => !cancelled && setLoadingThreads(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingMessages(true);
|
||||||
|
chatApi
|
||||||
|
.listMessages(activeKey)
|
||||||
|
.then((rows) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMessages(rows);
|
||||||
|
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
|
||||||
|
})
|
||||||
|
.catch((err) => pushToast("err", `Messages failed: ${err.message}`))
|
||||||
|
.finally(() => !cancelled && setLoadingMessages(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeKey]);
|
||||||
|
|
||||||
|
const handleNewThread = async () => {
|
||||||
|
if (!recipient || recipient === me) {
|
||||||
|
pushToast("err", "Pick a recipient that's not yourself");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const otherLabel = personaLabel(recipient);
|
||||||
|
const key = await chatApi.createThread(`${personaLabel(me)} ↔ ${otherLabel}`, [me, recipient]);
|
||||||
|
const next = await chatApi.listThreads();
|
||||||
|
setThreads(next);
|
||||||
|
setActiveKey(key);
|
||||||
|
pushToast("ok", `Thread opened with ${otherLabel}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
pushToast("err", `New thread failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!activeKey || !draft.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
const body = draft.trim();
|
||||||
|
const optimistic: ChatMessage = {
|
||||||
|
_key: `pending-${Date.now()}`,
|
||||||
|
display_name: body.slice(0, 80),
|
||||||
|
body,
|
||||||
|
author_email: me,
|
||||||
|
thread_key: activeKey,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, optimistic]);
|
||||||
|
setDraft("");
|
||||||
|
try {
|
||||||
|
await chatApi.sendMessage(activeKey, body, me);
|
||||||
|
const fresh = await chatApi.listMessages(activeKey);
|
||||||
|
setMessages(fresh);
|
||||||
|
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessages((prev) => prev.filter((m) => m._key !== optimistic._key));
|
||||||
|
pushToast("err", `Send failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = threads.find((t) => t._key === activeKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-scene">
|
||||||
|
<aside className="chat-sidebar">
|
||||||
|
<header className="chat-sidebar-head">
|
||||||
|
<div className="mc-hero-eyebrow"><Bot size={12} /> Conversations</div>
|
||||||
|
<h2 className="mc-hero-title">Talk to your team</h2>
|
||||||
|
</header>
|
||||||
|
<div className="chat-new-thread">
|
||||||
|
<label>Start a new thread</label>
|
||||||
|
<div className="chat-new-row">
|
||||||
|
<select value={recipient} onChange={(e) => setRecipient(e.target.value)}>
|
||||||
|
{PERSONA_DIRECTORY.filter((p) => p.email !== me).map((p) => (
|
||||||
|
<option key={p.email} value={p.email}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-secondary" onClick={handleNewThread}>Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-thread-list">
|
||||||
|
{loadingThreads && <div className="chat-empty">Loading threads…</div>}
|
||||||
|
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
||||||
|
{threads.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t._key}
|
||||||
|
className={`chat-thread-row ${t._key === activeKey ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveKey(t._key)}
|
||||||
|
>
|
||||||
|
<div className="chat-thread-name">{t.display_name}</div>
|
||||||
|
<div className="chat-thread-meta">{t.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="chat-main">
|
||||||
|
{!active && (
|
||||||
|
<div className="chat-empty-main">
|
||||||
|
Select a thread to read messages, or start a new one with a teammate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<header className="chat-main-head">
|
||||||
|
<div className="chat-title">{active.display_name}</div>
|
||||||
|
<div className="chat-subtitle">{active.description}</div>
|
||||||
|
</header>
|
||||||
|
<div className="chat-messages" ref={scrollRef}>
|
||||||
|
{loadingMessages && <div className="chat-empty">Loading…</div>}
|
||||||
|
{!loadingMessages && messages.length === 0 && (
|
||||||
|
<div className="chat-empty">No messages yet. Say hi.</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => {
|
||||||
|
const mine = m.author_email === me;
|
||||||
|
return (
|
||||||
|
<div key={m._key} className={`chat-bubble-row ${mine ? "mine" : "theirs"}`}>
|
||||||
|
<div className="chat-bubble">
|
||||||
|
<div className="chat-bubble-author">{personaLabel(m.author_email)}</div>
|
||||||
|
<div className="chat-bubble-body">{m.body}</div>
|
||||||
|
<div className="chat-bubble-time">{new Date(m.created_at).toLocaleTimeString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<footer className="chat-composer">
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type a message — Enter to send, Shift+Enter for newline"
|
||||||
|
rows={2}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={handleSend} disabled={!draft.trim() || sending}>
|
||||||
|
{sending ? "Sending…" : "Send"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { Branch, Layers, Pulse, Bot } from "../components/icons";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
title: "What is FlowMaster?",
|
||||||
|
eyebrow: "PHILOSOPHY",
|
||||||
|
body:
|
||||||
|
"FlowMaster treats business processes the same way engineers treat software: write it once as code, version-control every change, deploy it like an app, execute it at runtime, and never delete anything. We call this Business-as-Code. A process is no longer a Visio diagram that gathers dust — it's a running program your whole company can read, change, and ship.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Why this matters for your team",
|
||||||
|
eyebrow: "THE BUSINESS CASE",
|
||||||
|
body:
|
||||||
|
"Because the process is code, you get the things software has had for thirty years: audit trails, rollbacks, A/B testing, code review, fast iteration. A store manager can request a new laptop, the request flows through approval, procurement, and 3-way match — and every step is the same composable element. Nobody has to rebuild the same form five times.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Five element types",
|
||||||
|
eyebrow: "EA2 ARCHITECTURE",
|
||||||
|
body:
|
||||||
|
"Every process is composed of five element types. Each answers one question.\n\n• Flow — what happens, and in what order\n• Data — what information is collected and produced\n• View — how a person sees and interacts with a step\n• Rule — what decisions are made automatically\n• Version — what changed, when, and who signed off\n\nEach element is one document in the database. The relationships between them — \"this Flow uses that Data\", \"this View shows these Rules\" — are edges in a graph. The graph IS the data model.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Process lifecycle",
|
||||||
|
eyebrow: "FROM PEN TO PROD",
|
||||||
|
body:
|
||||||
|
"Every process travels the same four stops:\n\n1. DEFINE — author it in the Process Creation Wizard (drag, drop, describe, upload a doc, ask Pi)\n2. VERSION — freeze a release, get sign-off, immortalise the diff\n3. DEPLOY — roll it out to the tenant\n4. EXECUTE — the Execution Engine runs every instance, step-by-step, persisting state to EA2 at every write\n\nAuthoring tools and the runtime are separate concerns by design. They never bleed into each other.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "What's a 'hub'?",
|
||||||
|
eyebrow: "HOW WORK IS GROUPED",
|
||||||
|
body:
|
||||||
|
"A hub is a curated view onto your work for a specific function — Procurement, People, IT, Finance, and so on. Each hub shows the published processes that matter to that function, plus a queue of work items waiting on it. The Procurement Hub is for the people who buy things; the People Hub is for HR; the IT Hub is for service desk and access requests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "What's a 'work item'?",
|
||||||
|
eyebrow: "RUNTIME UNIT",
|
||||||
|
body:
|
||||||
|
"When a process gets executed for a real subject — \"Store 204 needs a laptop\" — that running instance becomes a work item. It has a transaction_id, an active step, a current human or agent assignee, and an audit trail of every decision so far. Work items live in queues, and people pull from queues like a Trello board with a brain.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Where does the data live?",
|
||||||
|
eyebrow: "ONE GRAPH, ONE TRUTH",
|
||||||
|
body:
|
||||||
|
"Everything is stored in EA2, the FlowMaster graph database, organised into five document collections (flow, data, view, rule, version) and four edge collections (instantiates, defines, relates, governs). When the Wizard creates a step, when an agent applies a decision, when an SAP record gets transformed into a native shape — all of it lands in the same graph. There is exactly one source of truth.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Why an agent (Pi)?",
|
||||||
|
eyebrow: "AN INTERFACE FOR EVERYONE",
|
||||||
|
body:
|
||||||
|
"Most people don't want to learn nineteen tabs. Pi is a co-pilot you can talk to: navigate the cockpit, start a process, message your team, list what's published, build a new flow. It's the same API the UI uses — Pi just speaks human. If a regional store manager asks 'start a laptop request for store 204', Pi finds the right published flow and starts it.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function Explainer() {
|
||||||
|
const setScene = useApp((s) => s.setScene);
|
||||||
|
return (
|
||||||
|
<div className="explainer-scene">
|
||||||
|
<header className="explainer-head">
|
||||||
|
<div className="mc-hero-eyebrow"><Layers size={12} /> The FlowMaster Explainer</div>
|
||||||
|
<h2 className="mc-hero-title">Business-as-Code, in plain English</h2>
|
||||||
|
<p className="explainer-intro">
|
||||||
|
Eight pages, eight minutes — everything a new operator needs to understand before they touch the cockpit. Distilled from the FlowMaster Element Architecture (v2.1) and the EA2 contracts.
|
||||||
|
</p>
|
||||||
|
<div className="explainer-jump-row">
|
||||||
|
<button className="hub-chip" onClick={() => setScene("agent")}><Bot size={11} /> Ask Pi instead</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("studio")}><Branch size={11} /> Open the Wizard</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("mission")}><Pulse size={11} /> Go to Mission Control</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="explainer-grid">
|
||||||
|
{SECTIONS.map((s, i) => (
|
||||||
|
<article key={s.title} className="explainer-card">
|
||||||
|
<div className="explainer-card-num">{String(i + 1).padStart(2, "0")}</div>
|
||||||
|
<div className="explainer-card-eyebrow">{s.eyebrow}</div>
|
||||||
|
<h3 className="explainer-card-title">{s.title}</h3>
|
||||||
|
<p className="explainer-card-body">{s.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="explainer-foot">
|
||||||
|
<div className="explainer-foot-label">SOURCE OF TRUTH</div>
|
||||||
|
<p>FlowMaster Element Architecture v2.1 · EA2 contracts (apply-batch, edges/defines, work-items) · seeded into Hindsight 2026-05-21.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { Pulse } from "../components/icons";
|
||||||
|
|
||||||
|
L.Marker.prototype.options.icon = L.icon({
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "store" | "office" | "warehouse";
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
status: "checked_in" | "checked_out" | "late";
|
||||||
|
who: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{ id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
|
||||||
|
{ id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
|
||||||
|
{ id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
|
||||||
|
{ id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
|
||||||
|
{ id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
|
||||||
|
{ id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
|
||||||
|
{ id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
|
||||||
|
{ id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusTag(s: Site["status"]) {
|
||||||
|
if (s === "checked_in") return "ON-SITE";
|
||||||
|
if (s === "late") return "LATE";
|
||||||
|
return "OFF";
|
||||||
|
}
|
||||||
|
|
||||||
|
function FitToSites({ sites }: { sites: Site[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sites.length) return;
|
||||||
|
const bounds = L.latLngBounds(sites.map((s) => [s.lat, s.lng] as [number, number]));
|
||||||
|
map.fitBounds(bounds, { padding: [40, 40] });
|
||||||
|
}, [map, sites]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeoAttendance() {
|
||||||
|
const setScene = useApp((s) => s.setScene);
|
||||||
|
const [filter, setFilter] = useState<"all" | Site["kind"]>("all");
|
||||||
|
const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter);
|
||||||
|
const stats = SITES.reduce(
|
||||||
|
(acc, s) => ({
|
||||||
|
onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0),
|
||||||
|
late: acc.late + (s.status === "late" ? 1 : 0),
|
||||||
|
off: acc.off + (s.status === "checked_out" ? 1 : 0),
|
||||||
|
}),
|
||||||
|
{ onSite: 0, late: 0, off: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="geo-scene">
|
||||||
|
<header className="geo-head">
|
||||||
|
<div className="mc-hero-eyebrow"><Pulse size={12} /> Real-time attendance</div>
|
||||||
|
<h2 className="mc-hero-title">Where your people are right now</h2>
|
||||||
|
<p className="geo-intro">
|
||||||
|
Live attendance for stores, offices, and warehouses. Click a marker to see who's checked in and when. Powered by OpenStreetMap tiles — no proprietary mapping key required.
|
||||||
|
</p>
|
||||||
|
<div className="geo-stats">
|
||||||
|
<span className="geo-stat geo-stat-ok">{stats.onSite} on-site</span>
|
||||||
|
<span className="geo-stat geo-stat-late">{stats.late} late</span>
|
||||||
|
<span className="geo-stat geo-stat-off">{stats.off} off-shift</span>
|
||||||
|
</div>
|
||||||
|
<div className="geo-filter-row">
|
||||||
|
{(["all", "store", "office", "warehouse"] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
className={`hub-chip ${filter === k ? "active" : ""}`}
|
||||||
|
onClick={() => setFilter(k)}
|
||||||
|
>
|
||||||
|
{k.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="geo-map-wrap">
|
||||||
|
<MapContainer center={[51.5074, -0.1278]} zoom={4} scrollWheelZoom={false} className="geo-map">
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<FitToSites sites={visible} />
|
||||||
|
{visible.map((s) => (
|
||||||
|
<Marker key={s.id} position={[s.lat, s.lng]}>
|
||||||
|
<Popup>
|
||||||
|
<strong>{s.label}</strong>
|
||||||
|
<br />
|
||||||
|
<span className={`geo-popup-tag geo-popup-${s.status}`}>{statusTag(s.status)}</span>
|
||||||
|
<br />
|
||||||
|
{s.who}
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { wizardApi } from "../lib/wizardApi";
|
||||||
|
import { Branch, Layers, Pulse } from "../components/icons";
|
||||||
|
|
||||||
|
export type HubKey = "procurement" | "hr" | "it";
|
||||||
|
|
||||||
|
interface HubSpec {
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
intro: string;
|
||||||
|
match: RegExp;
|
||||||
|
quickActions: { label: string; subject: string; processHint: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUBS: Record<HubKey, HubSpec> = {
|
||||||
|
procurement: {
|
||||||
|
title: "Procurement Hub",
|
||||||
|
eyebrow: "Purchase requests, vendor approvals, three-way match",
|
||||||
|
intro:
|
||||||
|
"Everything a store manager needs to buy something — from a laptop to a forklift — and watch it move through the approval chain.",
|
||||||
|
match: /procure|purchase|vendor|\bpo\b|\bp2p\b|requisition|payment|invoice/i,
|
||||||
|
quickActions: [
|
||||||
|
{ label: "Request a new laptop", subject: "store-204-laptop", processHint: "procurement" },
|
||||||
|
{ label: "Submit a quote for review", subject: "vendor-quote-Q3", processHint: "procurement" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
title: "People Hub",
|
||||||
|
eyebrow: "Hiring, onboarding, leave, performance",
|
||||||
|
intro:
|
||||||
|
"All people movements run here. Start an onboarding for a new hire, file a leave request, or kick off a review cycle.",
|
||||||
|
match: /onboard|offboard|leave|\bhr\b|hiring|payroll|employee|people operations/i,
|
||||||
|
quickActions: [
|
||||||
|
{ label: "Onboard a new hire", subject: "new-hire-2026Q3", processHint: "onboard" },
|
||||||
|
{ label: "Request annual leave", subject: "leave-7d-may", processHint: "leave" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
title: "IT Hub",
|
||||||
|
eyebrow: "Tickets, access requests, incident response",
|
||||||
|
intro:
|
||||||
|
"When something breaks, request access, or you need a new account — file it here and route it to the right on-call.",
|
||||||
|
match: /\bit\b|ticket|incident|service operations|access request|sap access|laptop request|hardware request|software request|password reset|account provisioning/i,
|
||||||
|
quickActions: [
|
||||||
|
{ label: "Open an incident", subject: "incident-store-204", processHint: "service" },
|
||||||
|
{ label: "Request system access", subject: "access-sap-RW", processHint: "access" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PublishedFlow {
|
||||||
|
_key: string;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_BUSINESS_SOURCE_CONTEXTS = new Set([
|
||||||
|
"EA2_CHAT_THREAD",
|
||||||
|
"EA2_CHAT_MSG",
|
||||||
|
"EA2_WIZARD_STEP",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadPublishedFlows(): Promise<PublishedFlow[]> {
|
||||||
|
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=200`, {
|
||||||
|
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`processes ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
return ((body?.items || []) as any[])
|
||||||
|
.filter(
|
||||||
|
(it) =>
|
||||||
|
it.status === "published" &&
|
||||||
|
it.kind === "definition" &&
|
||||||
|
it.display_name &&
|
||||||
|
!NON_BUSINESS_SOURCE_CONTEXTS.has(it.source_context)
|
||||||
|
)
|
||||||
|
.map((it) => ({
|
||||||
|
_key: it._key,
|
||||||
|
display_name: it.display_name,
|
||||||
|
description: it.description,
|
||||||
|
name: it.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hub({ hub }: { hub: HubKey }) {
|
||||||
|
const spec = HUBS[hub];
|
||||||
|
const pushToast = useApp((s) => s.pushToast);
|
||||||
|
const setScene = useApp((s) => s.setScene);
|
||||||
|
const [flows, setFlows] = useState<PublishedFlow[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadPublishedFlows()
|
||||||
|
.then((rows) => !cancelled && setFlows(rows))
|
||||||
|
.catch((err) => !cancelled && pushToast("err", `Catalogue failed: ${err.message}`));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [hub]);
|
||||||
|
|
||||||
|
const matching = (flows || []).filter((f) => spec.match.test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)).slice(0, 8);
|
||||||
|
const fallback = (flows || []).slice(0, 6);
|
||||||
|
const visible = matching.length > 0 ? matching : fallback;
|
||||||
|
|
||||||
|
const startInstance = async (flowKey: string, subject: string, label: string) => {
|
||||||
|
setBusy(flowKey);
|
||||||
|
try {
|
||||||
|
const res = await wizardApi.startInstance(flowKey, subject);
|
||||||
|
pushToast("ok", `Started ${label}${subject ? ` for ${subject}` : ""} — ${res.transaction_id?.slice(0, 8) || "ok"}`);
|
||||||
|
setScene("mission");
|
||||||
|
} catch (err: any) {
|
||||||
|
pushToast("err", `Start failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryQuickAction = async (label: string, subject: string, processHint: string) => {
|
||||||
|
if (!flows) return;
|
||||||
|
const match = flows.find((f) =>
|
||||||
|
new RegExp(processHint, "i").test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
pushToast("err", `No published process matching "${processHint}". Build one in Studio.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startInstance(match._key, subject, label);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hub-scene">
|
||||||
|
<header className="hub-head">
|
||||||
|
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.eyebrow}</div>
|
||||||
|
<h2 className="mc-hero-title">{spec.title}</h2>
|
||||||
|
<p className="hub-intro">{spec.intro}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="hub-quick">
|
||||||
|
<div className="hub-section-label">QUICK ACTIONS</div>
|
||||||
|
<div className="hub-quick-grid">
|
||||||
|
{spec.quickActions.map((qa) => (
|
||||||
|
<button
|
||||||
|
key={qa.label}
|
||||||
|
className="hub-quick-card"
|
||||||
|
onClick={() => tryQuickAction(qa.label, qa.subject, qa.processHint)}
|
||||||
|
disabled={!flows}
|
||||||
|
>
|
||||||
|
<div className="hub-quick-title"><Pulse size={12} /> {qa.label}</div>
|
||||||
|
<div className="hub-quick-meta">Looks up "{qa.processHint}" in your catalogue</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="hub-catalogue">
|
||||||
|
<div className="hub-section-label">RELATED PROCESSES</div>
|
||||||
|
{flows === null && <div className="hub-empty">Loading catalogue…</div>}
|
||||||
|
{flows !== null && visible.length === 0 && (
|
||||||
|
<div className="hub-empty">
|
||||||
|
No matching published processes yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to build one.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hub-catalogue-grid">
|
||||||
|
{visible.map((f) => (
|
||||||
|
<div key={f._key} className="hub-flow-card">
|
||||||
|
<div className="hub-flow-title"><Branch size={11} /> {f.display_name}</div>
|
||||||
|
{f.description && <div className="hub-flow-desc">{f.description.slice(0, 160)}</div>}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy === f._key}
|
||||||
|
onClick={() => startInstance(f._key, "", f.display_name)}
|
||||||
|
>
|
||||||
|
{busy === f._key ? "Starting…" : "Start an instance"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,6 +75,16 @@ export default function Landing() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-hub-row">
|
||||||
|
<button className="hub-chip" onClick={() => setScene("hub-procurement")}>Procurement Hub</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("hub-it")}>IT Hub</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("geo-attendance")}>Attendance Map</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("agent")}>Talk to Pi</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button>
|
||||||
|
<button className="hub-chip" onClick={() => setScene("explainer")}>What is FlowMaster?</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hero-stats">
|
<div className="hero-stats">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<Pulse size={12} />
|
<Pulse size={12} />
|
||||||
|
|||||||
@@ -137,6 +137,30 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{devLoginEnabled && (
|
||||||
|
<div className="login-personas">
|
||||||
|
<div className="login-persona-label">QUICK PERSONA</div>
|
||||||
|
<div className="login-persona-row">
|
||||||
|
{[
|
||||||
|
{ key: "ceo", email: "ceo-head@flow-master.ai", title: "Mariana Cole · CEO" },
|
||||||
|
{ key: "hr", email: "hr-head@flow-master.ai", title: "Aisha Khan · HR" },
|
||||||
|
{ key: "it", email: "it-head@flow-master.ai", title: "Rohan Patel · IT" },
|
||||||
|
].map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
className="persona-chip"
|
||||||
|
onClick={() => setEmail(p.email)}
|
||||||
|
disabled={loading}
|
||||||
|
title={p.email}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-14
@@ -103,23 +103,27 @@ export default function Wizard() {
|
|||||||
if (!draft.flowKey || !actor) return;
|
if (!draft.flowKey || !actor) return;
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
try {
|
try {
|
||||||
const ops: BatchOperation[] = [
|
const createOps: BatchOperation[] = draft.nodes.map(n =>
|
||||||
...draft.nodes.map(n =>
|
wizardApi.ops.createStep({
|
||||||
wizardApi.ops.createNode(draft.flowKey!, {
|
display_name: n.display_name,
|
||||||
_key: n._key,
|
dispatch_kind: n.dispatch_kind,
|
||||||
display_name: n.display_name,
|
agent_capability: n.agent_capability,
|
||||||
dispatch_kind: n.dispatch_kind,
|
})
|
||||||
agent_capability: n.agent_capability,
|
);
|
||||||
})
|
const createRes = await wizardApi.applyBatch(draft.flowKey, createOps, actor);
|
||||||
),
|
const createdKeys: string[] = (createRes?.result?.ops || []).map((o: any) => o.key);
|
||||||
...draft.nodes.slice(0, -1).map((n, i) =>
|
const nodesWithKeys = draft.nodes.map((n, i) => ({ ...n, _key: createdKeys[i] || n._key }));
|
||||||
wizardApi.ops.createEdge(draft.flowKey!, n._key, draft.nodes[i + 1]._key, "next")
|
|
||||||
|
const linkOps: BatchOperation[] = [
|
||||||
|
wizardApi.ops.createStepEdge(draft.flowKey!, nodesWithKeys[0]._key),
|
||||||
|
...nodesWithKeys.slice(0, -1).map((n, i) =>
|
||||||
|
wizardApi.ops.createStepEdge(n._key, nodesWithKeys[i + 1]._key)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
if (linkOps.length) await wizardApi.applyBatch(draft.flowKey, linkOps, actor);
|
||||||
|
|
||||||
await wizardApi.applyBatch(draft.flowKey, ops, actor);
|
updateDraft({ step: "Generate", nodes: nodesWithKeys });
|
||||||
updateDraft({ step: "Generate" });
|
pushToast("ok", `Saved ${createdKeys.length} steps to EA2`);
|
||||||
pushToast("ok", "Structure saved");
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
pushToast("err", `Save failed: ${err.message}`);
|
pushToast("err", `Save failed: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
|||||||
import { api, type ApiCall, type Actor } from "../lib/api";
|
import { api, type ApiCall, type Actor } from "../lib/api";
|
||||||
import type { ProcessScenario } from "../data/types";
|
import type { ProcessScenario } from "../data/types";
|
||||||
|
|
||||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
|
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer";
|
||||||
export type DataMode = "snapshot" | "live";
|
export type DataMode = "snapshot" | "live";
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user