diff --git a/src/App.tsx b/src/App.tsx index eaa517c..d56a0df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import Settings from "./scenes/Settings"; import Login from "./scenes/Login"; import SsoCallback from "./scenes/SsoCallback"; import Chat from "./scenes/Chat"; +import Agent from "./scenes/Agent"; import CommandBar from "./components/CommandBar"; import Toaster from "./components/Toaster"; import Console from "./components/Console"; @@ -75,6 +76,9 @@ export default function App() { setScene("chat")}> Chat + setScene("agent")}> + Pi + setScene("settings")}> Settings @@ -154,6 +158,7 @@ export default function App() { {scene === "history" && } {scene === "studio" && } {scene === "chat" && } + {scene === "agent" && } {scene === "settings" && } diff --git a/src/index.css b/src/index.css index b43dd3a..a71052d 100644 --- a/src/index.css +++ b/src/index.css @@ -1898,3 +1898,42 @@ select.studio-input { background: var(--bp-paper); } .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; } +} diff --git a/src/lib/agentTools.ts b/src/lib/agentTools.ts new file mode 100644 index 0000000..0d9c389 --- /dev/null +++ b/src/lib/agentTools.ts @@ -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; +} + +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 = { + 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 = { + 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 { + 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 })); +} diff --git a/src/scenes/Agent.tsx b/src/scenes/Agent.tsx new file mode 100644 index 0000000..85186ae --- /dev/null +++ b/src/scenes/Agent.tsx @@ -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([ + { + 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(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 ( + + + + + + {turns.map((t) => ( + + {t.role === "user" ? userEmail.split("@")[0] : "Pi"} + {t.text} + + ))} + {working && Pi is thinking…} + + + {turns.length <= 1 && ( + + {SUGGESTIONS.map((s) => ( + submit(s)}>{s} + ))} + + )} + + + + + ); +}