"use client"; /* Playground - chat that runs a real workflow over SSE with token-by-token streaming. */ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../icons"; import { Tile } from "../primitives"; import { api, openSSE, Workflow, ComponentT } from "@/lib/api"; import { fmtUSD, buildNodeLabels, nodeLabel } from "@/lib/data"; import { Markdown } from "../markdown"; import { ComponentRenderer } from "../component-renderer"; import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts"; import Mustache from "mustache"; interface ChatMsg { role: "user" | "assistant"; content?: string; parts?: Part[] } interface Step { node: string } interface Activity { id: string; kind: string; name: string; done: boolean; error?: boolean } export function PlaygroundScreen({ project }: { project: any }) { const [wf, setWf] = useState(null); const [wfs, setWfs] = useState([]); const [loadErr, setLoadErr] = useState(null); const [input, setInput] = useState(""); const [msgs, setMsgs] = useState([]); const [streaming, setStreaming] = useState(""); const [steps, setSteps] = useState([]); const [activity, setActivity] = useState([]); const [running, setRunning] = useState(false); const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null); const [pendingInterrupt, setPendingInterrupt] = useState<{ runId: string; payload: any } | null>(null); const [resuming, setResuming] = useState(false); const [compDefs, setCompDefs] = useState>({}); const [liveParts, setLiveParts] = useState([]); // in-flight assistant reply parts, rendered live (audit H3) // Resolve node ids (what the run stream emits) to the friendly names shown on the canvas, so the // Run steps read like the graph the operator built rather than raw ids. const nodeLabels = useMemo(() => buildNodeLabels(wf), [wf]); const scrollRef = useRef(null); // One backend thread per chat session: the checkpointer holds the conversation, so // each turn sends ONLY the new message (no full-transcript replay). const threadRef = useRef(null); // Aborts the in-flight SSE stream when the user hits Stop. const abortRef = useRef(null); useEffect(() => { if (!project?.id) return; threadRef.current = null; setWf(null); setLoadErr(null); setMsgs([]); setSteps([]); setMeter(null); api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {}); api.listWorkflows(project.id) .then((ws) => { setWfs(ws); const active = ws.find((w) => w.status === "active") || ws[0] || null; setWf(active); if (!active) setLoadErr("No workflows in this project yet. Create one in Workflows, or ask the Forge Assistant to build one."); }) .catch((e) => setLoadErr(String(e.message || e))); }, [project?.id]); useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, streaming, steps, liveParts]); async function send(textArg?: string) { const text = (typeof textArg === "string" ? textArg : input).trim(); if (!text || !wf || running) return; if (typeof textArg !== "string") setInput(""); setMsgs((m) => [...m, { role: "user", content: text }]); setStreaming(""); setSteps([]); setActivity([]); setMeter(null); setRunning(true); setLiveParts([]); let finalAnswer = ""; // The reply is an ordered list of parts (text + components). Components are positioned by the // [[forge:component:ID]] markers the agent writes into its text - NOT by the order their // frames arrive - so a widget lands in its natural place instead of always at the top. const acc = new ReplyAccumulator(); try { // The thread's checkpointer holds prior turns, so send only the new message when a // thread exists; the first turn establishes the thread. The run always acts as the logged-in // operator (the backend sets end_user from the session), so per-user auth providers resolve // this user's own connected credential — exactly like the Workflow test panel. const run = await api.createRun( project.id, wf.id, { messages: [{ role: "user", content: text }] }, threadRef.current || undefined, ); threadRef.current = run.thread_id; let interrupted = false; const url = api.runStreamUrl(project.id, wf.id, run.id); const controller = new AbortController(); abortRef.current = controller; await openSSE(url, (f) => { if (f.event === "messages" && f.data?.content) { acc.addText(f.data.content); setStreaming(acc.text); setLiveParts(acc.parts({ streaming: true })); } else if ((f.event === "node_start" || f.event === "updates") && f.data) { const node = f.event === "node_start" ? f.data.node : Object.keys(f.data || {})[0]; if (node) setSteps((s) => (s.some((x) => x.node === node) ? s : [...s, { node }])); } else if (f.event === "activity" && f.data?.id) { const a = f.data; if (a.phase === "start") { setActivity((xs) => xs.some((x) => x.id === a.id) ? xs : [...xs, { id: a.id, kind: a.kind, name: a.name, done: false }]); } else if (a.phase === "end") { setActivity((xs) => xs.map((x) => (x.id === a.id ? { ...x, done: true, error: !!a.error } : x))); } } else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) { acc.addComponent(f.data.payload as ComponentInstance); setLiveParts(acc.parts({ streaming: true })); } else if (f.event === "done") { finalAnswer = f.data?.answer || ""; setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 }); } else if (f.event === "interrupt") { interrupted = true; setPendingInterrupt({ runId: run.id, payload: f.data }); } else if (f.event === "error") { finalAnswer = `⚠ ${f.data?.message || "run failed"}`; } }, { signal: controller.signal }); if (interrupted) { // Preserve anything streamed before the pause (audit M4) - mirror the finalize commit. if (acc.hasComponents() || acc.text.trim()) { setMsgs((m) => [...m, acc.hasComponents() ? { role: "assistant", parts: acc.parts() } : { role: "assistant", content: acc.text }]); } setStreaming(""); setRunning(false); setLiveParts([]); return; // approval card takes over } } catch (e: any) { // A user-initiated Stop aborts the fetch - commit whatever streamed, no error banner. if (e?.name !== "AbortError") finalAnswer = `⚠ ${e.message || e}`; } finally { abortRef.current = null; setStreaming(""); setRunning(false); setLiveParts([]); } // resolveText reconciles the streamed buffer with the authoritative final answer / error // (covers non-LLM nodes that don't stream tokens) without dropping it (audit H2). const finalText = acc.resolveText(finalAnswer); if (acc.hasComponents()) { setMsgs((m) => [...m, { role: "assistant", parts: acc.parts({ finalText }) }]); } else { setMsgs((m) => [...m, { role: "assistant", content: finalText || "(no output)" }]); } } /** Pull the human-facing prompt + decision options out of the interrupt payload. Handles both shapes: the human_input node ({prompt, allowed_decisions}) and HumanInTheLoopMiddleware (action requests; resume wants {decisions:[{type}]}). */ function parseInterrupt(payload: any): { prompt: string; decisions: string[]; middleware: boolean } { const flat = (x: any): any[] => (Array.isArray(x) ? x.flatMap(flat) : [x]); const items = flat(payload).filter(Boolean); const values = items.map((i) => (i && typeof i === "object" && "value" in i ? i.value : i)); for (const v of values) { if (v && typeof v === "object" && v.prompt) { return { prompt: String(v.prompt), decisions: v.allowed_decisions || ["approve", "reject"], middleware: false }; } if (v && typeof v === "object" && (v.action_requests || v.action_request || v.action)) { const reqs = v.action_requests || [v.action_request || v]; const desc = reqs.map((r: any) => r.description || `${r.action || r.name || "tool"}(${JSON.stringify(r.args || {}).slice(0, 80)})`).join("; "); return { prompt: `Approve tool call: ${desc}`, decisions: ["approve", "reject"], middleware: true }; } } return { prompt: "This run paused for your approval.", decisions: ["approve", "reject"], middleware: false }; } async function resume(decision: string) { if (!pendingInterrupt || !wf || resuming) return; const { middleware } = parseInterrupt(pendingInterrupt.payload); setResuming(true); try { const value = middleware ? { decisions: [{ type: decision }] } : decision; const res = await api.resumeRun(project.id, wf.id, pendingInterrupt.runId, value); const msgsOut = res.messages || []; const last = [...msgsOut].reverse().find((m: any) => (m.type === "ai" || m.role === "assistant") && m.content); const content = last ? (typeof last.content === "string" ? last.content : JSON.stringify(last.content)) : (res.error || "(resumed)"); setMsgs((m) => [...m, { role: "assistant", content: res.interrupted ? content + "\n⏸ paused again for another approval - check Traces." : content }]); } catch (e: any) { setMsgs((m) => [...m, { role: "assistant", content: `⚠ resume failed: ${e.message || e}` }]); } finally { setPendingInterrupt(null); setResuming(false); } } // Abort the in-flight SSE run. The stream reader rejects with AbortError, which send() // treats as a graceful stop (partial output is committed, no error banner). function stop() { abortRef.current?.abort(); } // Reset clears the UI AND starts a fresh conversation thread, so the next message has no // server-side history (nulling threadRef) - not just a visual clear. function reset() { threadRef.current = null; setMsgs([]); setSteps([]); setMeter(null); setStreaming(""); setLiveParts([]); setPendingInterrupt(null); } function handleComponentAction(inst: ComponentInstance, action: string, fields: Record) { const def = (inst.actions || []).find((a: any) => a.id === action) || {}; let msg = (def as any).message || (def as any).label || action; try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {} if (msg) send(msg); } const samples = ["How do I reset my password?", "What can you help me with?"]; return (
{/* header */}
Playground
{wfs.length > 1 ? ( ) : (
{wf ? wf.name : "loading…"}{running && " · running"}
)}
{meter && ( {meter.tokens} tok · {fmtUSD(meter.cost)} )} grounded
{/* alignItems:stretch - the global .row centers children, which stops the chat column from filling the height: it then sizes to content, overflows the viewport, and the scroll area never scrolls. Stretch restores the fixed-height column + inner scroll. */}
{/* minHeight:100% + justify-end pins a short conversation to the bottom (chat-style); once it outgrows the viewport it scrolls normally. */}
{loadErr &&
{loadErr}
} {msgs.length === 0 && !running && !loadErr && (
Run “{wf?.name || "your workflow"}” live
Answers are grounded in this project’s knowledge base & Q&A - it streams token by token.
{samples.map((s) => ( ))}
)}
{msgs.map((m, i) => ( ))} {(running || liveParts.length > 0) && ( )} {pendingInterrupt && (() => { const info = parseInterrupt(pendingInterrupt.payload); return (
Approval required
{info.prompt}
{info.decisions.map((d) => ( ))}
); })()}
{/* composer */}
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} placeholder="Message the workflow…" disabled={!wf || running} style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 14, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} /> {running ? ( ) : ( )}
Runs against the active workflow · interrupts surface for approval
{/* Steps column */}
Run steps
{steps.length === 0 &&
Nodes light up as the graph executes.
} {steps.map((s, i) => (
{nodeLabel(s.node, nodeLabels)}
{nodeLabels[s.node] && nodeLabels[s.node].label !== s.node && (
{s.node}
)}
{i + 1}
))} {running && (
streaming…
)} {/* Live agent activity: sub-agent dispatches (prominent) and tool calls (dimmed), so a deep_agent's routing is visible as it happens - not just after the run in Traces. */} {activity.length > 0 && ( <>
Agent activity
{activity.map((a) => { const isSub = a.kind === "subagent"; return (
{a.error ? : a.done ? :
}
{a.name}
); })} )}
); } /* One chat turn. User turns keep the colored bubble. An assistant turn is ONE flowing reply under a single avatar - bare markdown text and inline components in order, with NO bubble chrome - so a rendered component reads as part of the reply, not a detached card below it (audit Priority C). Missing component defs degrade to a visible notice (audit M5). */ function MessageBlock({ role, content, streaming, parts, compDefs, onAction }: { role: "user" | "assistant"; content?: string; streaming?: boolean; parts?: Part[]; compDefs: Record; onAction: (inst: ComponentInstance, action: string, fields: Record) => void; }) { const user = role === "user"; if (user) { return (
{content}
); } const renderComp = (inst: ComponentInstance, key: number | string) => { const def = compDefs[inst.component_id]; if (!def) { return (
Component “{inst.name || inst.component_id}” is unavailable.
); } return ( onAction(inst, a, f)} /> ); }; const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []); let lastText = -1; for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } } return (
{list.map((p, j) => (p.kind === "text" ? (
{p.text} {streaming && j === lastText && }
) : renderComp(p.inst, j)))} {streaming && lastText === -1 && }
); }