feat: deep-agent canvas, live observability, and multi-environment tooling

Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
nihalashetty
2026-07-28 01:49:19 +05:30
commit ae67bff5a3
350 changed files with 58244 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
"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<Workflow | null>(null);
const [wfs, setWfs] = useState<Workflow[]>([]);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [input, setInput] = useState("");
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
const [streaming, setStreaming] = useState("");
const [steps, setSteps] = useState<Step[]>([]);
const [activity, setActivity] = useState<Activity[]>([]);
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<Record<string, ComponentT>>({});
const [liveParts, setLiveParts] = useState<Part[]>([]); // 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<HTMLDivElement>(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<string | null>(null);
// Aborts the in-flight SSE stream when the user hits Stop.
const abortRef = useRef<AbortController | null>(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<string, string>) {
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 (
<div className="col" style={{ flex: 1, minHeight: 0 }}>
{/* header */}
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", flex: "none" }}>
<div className="row gap2">
<div>
<div className="t-display">Playground</div>
{wfs.length > 1 ? (
<select
className="select" disabled={running}
value={wf?.id || ""}
style={{ marginTop: 2, height: 24, fontSize: 12, padding: "0 6px", maxWidth: 280 }}
onChange={(e) => {
const next = wfs.find((w) => w.id === e.target.value) || null;
threadRef.current = null;
setWf(next); setMsgs([]); setSteps([]); setMeter(null); setStreaming("");
}}>
{wfs.map((w) => (
<option key={w.id} value={w.id}>{w.name}{w.status === "active" ? " · active" : " · draft"}</option>
))}
</select>
) : (
<div className="fg-2 t-caption mono">{wf ? wf.name : "loading…"}{running && " · running"}</div>
)}
</div>
</div>
<div className="row gap2">
{meter && (
<span className="chip chip-mono"><Icon name="bolt" size={13} />{meter.tokens} tok · {fmtUSD(meter.cost)}</span>
)}
<span className="chip chip-mono"><Icon name="knowledge" size={12} />grounded</span>
<button className="btn btn-ghost btn-sm" onClick={reset} disabled={running}>
<Icon name="refresh" size={14} />Reset
</button>
</div>
</div>
{/* 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. */}
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
<div className="col grow" style={{ minWidth: 0, minHeight: 0, borderRight: "1px solid var(--line)" }}>
<div ref={scrollRef} className="scroll-y" style={{ flex: 1, minHeight: 0, padding: "20px 0" }}>
{/* minHeight:100% + justify-end pins a short conversation to the bottom (chat-style);
once it outgrows the viewport it scrolls normally. */}
<div style={{ maxWidth: 720, margin: "0 auto", padding: "0 24px", width: "100%", minHeight: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
{loadErr && <div className="card" style={{ padding: 14, color: "var(--err)", marginBottom: 12 }}>{loadErr}</div>}
{msgs.length === 0 && !running && !loadErr && (
<div className="col center" style={{ minHeight: 300, gap: 10, color: "var(--fg-2)", textAlign: "center", margin: "auto 0" }}>
<Tile icon="sparkles" color="var(--accent)" size={44} glow />
<div className="t-h2" style={{ color: "var(--fg-1)" }}>Run {wf?.name || "your workflow"} live</div>
<div className="t-caption">Answers are grounded in this projects knowledge base & Q&A - it streams token by token.</div>
<div className="row gap2 wrap center" style={{ maxWidth: 460, marginTop: 6 }}>
{samples.map((s) => (
<button key={s} className="chip" style={{ cursor: "pointer" }} onClick={() => setInput(s)}>{s}</button>
))}
</div>
</div>
)}
<div className="col gap4">
{msgs.map((m, i) => (
<MessageBlock key={i} role={m.role} content={m.content} parts={m.parts} compDefs={compDefs} onAction={handleComponentAction} />
))}
{(running || liveParts.length > 0) && (
<MessageBlock role="assistant" parts={liveParts} streaming compDefs={compDefs} onAction={handleComponentAction} />
)}
{pendingInterrupt && (() => {
const info = parseInterrupt(pendingInterrupt.payload);
return (
<div className="card fade-up" style={{ padding: 14, borderLeft: "3px solid var(--warn)" }}>
<div className="row gap2" style={{ marginBottom: 6 }}>
<Icon name="user" size={15} style={{ color: "var(--warn)" }} />
<span className="t-h3">Approval required</span>
</div>
<div className="t-body-sm fg-1" style={{ marginBottom: 10, whiteSpace: "pre-wrap" }}>{info.prompt}</div>
<div className="row gap2">
{info.decisions.map((d) => (
<button key={d}
className={d === "approve" ? "btn btn-primary btn-sm" : "btn btn-secondary btn-sm"}
disabled={resuming} onClick={() => resume(d)}>
{resuming ? "…" : d}
</button>
))}
</div>
</div>
);
})()}
</div>
</div>
</div>
{/* composer */}
<div style={{ padding: "14px 24px", borderTop: "1px solid var(--line)", flex: "none" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<div className="row gap2" style={{ background: "var(--bg-1)", border: "1px solid var(--line-strong)", borderRadius: 12, padding: "7px 7px 7px 14px", boxShadow: "var(--sh-1)" }}>
<input value={input} onChange={(e) => 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 ? (
<button className="btn btn-secondary" onClick={stop} title="Stop the run">
<Icon name="stop" size={15} />Stop
</button>
) : (
<button className="btn btn-primary" onClick={() => send()} disabled={!wf}>
<Icon name="play" size={15} />Run
</button>
)}
</div>
<div className="fg-2 t-caption" style={{ textAlign: "center", marginTop: 7 }}>Runs against the active workflow · interrupts surface for approval</div>
</div>
</div>
</div>
{/* Steps column */}
<div style={{ width: 280, flex: "none", background: "var(--bg-1)", minHeight: 0 }} className="scroll-y">
<div className="t-micro" style={{ padding: "14px 16px 8px" }}>Run steps</div>
<div className="col" style={{ padding: "0 12px 12px" }}>
{steps.length === 0 && <div className="fg-2 t-caption" style={{ padding: "4px 8px" }}>Nodes light up as the graph executes.</div>}
{steps.map((s, i) => (
<div key={i} className="row gap2 fade-in" style={{ padding: "8px 8px", borderRadius: 7 }}>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: "var(--ok-bg)", color: "var(--ok)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><Icon name="check" size={12} /></div>
<div className="grow" style={{ minWidth: 0 }}>
<div className="t-body-sm truncate" style={{ color: "var(--fg-1)" }}>{nodeLabel(s.node, nodeLabels)}</div>
{nodeLabels[s.node] && nodeLabels[s.node].label !== s.node && (
<div className="t-caption fg-2 mono truncate">{s.node}</div>
)}
</div>
<span className="t-caption fg-2">{i + 1}</span>
</div>
))}
{running && (
<div className="row gap2" style={{ padding: "8px", color: "var(--accent)" }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />
<span className="t-caption">streaming</span>
</div>
)}
{/* 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 && (
<>
<div className="t-micro" style={{ padding: "12px 4px 6px" }}>Agent activity</div>
{activity.map((a) => {
const isSub = a.kind === "subagent";
return (
<div key={a.id} className="row gap2 fade-in" style={{ padding: "6px 8px", borderRadius: 7 }}>
<div style={{ width: 16, height: 16, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
{a.error ? <Icon name="x" size={12} style={{ color: "var(--err)" }} />
: a.done ? <Icon name="check" size={12} style={{ color: isSub ? "var(--accent)" : "var(--ok)" }} />
: <div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />}
</div>
<Icon name={isSub ? "layers" : "sliders"} size={13} style={{ color: isSub ? "var(--accent)" : "var(--fg-2)", flex: "none" }} />
<span className="mono-sm grow truncate" style={{ color: isSub ? "var(--fg-0)" : "var(--fg-2)", fontWeight: isSub ? 600 : 400 }} title={a.name}>{a.name}</span>
</div>
);
})}
</>
)}
</div>
</div>
</div>
</div>
);
}
/* 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<string, ComponentT>;
onAction: (inst: ComponentInstance, action: string, fields: Record<string, string>) => void;
}) {
const user = role === "user";
if (user) {
return (
<div className="row" style={{ gap: 9, alignItems: "flex-start", flexDirection: "row-reverse" }}>
<Tile icon="user" color="var(--fg-2)" size={28} />
<div style={{ maxWidth: 560, padding: "10px 13px", borderRadius: 12, borderTopRightRadius: 3, fontSize: 14, lineHeight: "21px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word", background: "var(--accent)", color: "var(--fg-on-accent)" }}>
{content}
</div>
</div>
);
}
const renderComp = (inst: ComponentInstance, key: number | string) => {
const def = compDefs[inst.component_id];
if (!def) {
return (
<div key={key} className="card" style={{ padding: "8px 11px", fontSize: 12.5, color: "var(--fg-2)" }}>
Component {inst.name || inst.component_id} is unavailable.
</div>
);
}
return (
<ComponentRenderer key={key} def={{ id: def.id, name: def.name, html: def.html, css: def.css, actions: inst.actions || def.actions }} props={inst.props || {}} onAction={(a, f) => 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 (
<div className="row" style={{ gap: 9, alignItems: "flex-start" }}>
<Tile icon="sparkles" color="var(--accent)" size={28} />
<div className="col gap2" style={{ minWidth: 0, maxWidth: 620, flex: 1 }}>
{list.map((p, j) => (p.kind === "text" ? (
<div key={j} style={{ fontSize: 14, lineHeight: "21px", color: "var(--fg-0)", overflowWrap: "anywhere" }}>
<Markdown>{p.text}</Markdown>
{streaming && j === lastText && <span style={{ display: "inline-block", width: 7, height: 14, background: "var(--accent)", verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />}
</div>
) : renderComp(p.inst, j)))}
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 14 }}></span>}
</div>
</div>
);
}