Files
nihalashetty ae67bff5a3 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.
2026-07-28 01:49:19 +05:30

270 lines
15 KiB
TypeScript

"use client";
/* Standalone embeddable chat widget (Phase 3b/4). Served same-origin from /embed and dropped
into any allowed site via an <iframe> (see /launcher.js for the floating-bubble launcher).
Gated by a publishable key (?key=…); identity comes from an optional verified ?session_token=…
Reaches Playground parity: structured replies + inline components + human-in-the-loop
approvals (interrupt → approval card → resume). Operator-only affordances (the run-steps
panel and token/cost meter) are intentionally NOT shown to end users. */
import { useEffect, useRef, useState } from "react";
import { openSSE } from "@/lib/api";
import { Markdown } from "@/components/markdown";
import { ComponentRenderer } from "@/components/component-renderer";
import { Icon } from "@/components/icons";
import { ReplyAccumulator, type Part } from "@/lib/chat-parts";
import Mustache from "mustache";
interface Msg { role: "user" | "assistant"; content?: string; parts?: Part[] }
const EMBED = (key: string, path: string) => `/api/forge/v1/embed/${encodeURIComponent(key)}${path}`;
/* Normalize a LangGraph interrupt payload into {prompt, decisions, middleware} - ported
verbatim from the Playground so the widget builds the same buttons AND the same resume value
encoding. The `middleware` flag is load-bearing: it selects {decisions:[{type}]} vs a bare
string at resume time and MUST be recomputed from the stored payload then. */
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 as any).value : i));
for (const v of values) {
if (v && typeof v === "object" && (v as any).prompt) {
return { prompt: String((v as any).prompt), decisions: (v as any).allowed_decisions || ["approve", "reject"], middleware: false };
}
if (v && typeof v === "object" && ((v as any).action_requests || (v as any).action_request || (v as any).action)) {
const reqs = (v as any).action_requests || [(v as any).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 action: ${desc}`, decisions: ["approve", "reject"], middleware: true };
}
}
return { prompt: "This needs your approval to continue.", decisions: ["approve", "reject"], middleware: false };
}
export default function EmbedWidget() {
const [key, setKey] = useState<string | null>(null);
const [sessionToken, setSessionToken] = useState<string | null>(null);
const [cfg, setCfg] = useState<{ name: string } | null>(null);
const [compDefs, setCompDefs] = useState<Record<string, any>>({});
const [err, setErr] = useState<string | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [liveParts, setLiveParts] = useState<Part[]>([]);
const [pendingInterrupt, setPendingInterrupt] = useState<{ runId: string; payload: any } | null>(null);
const [resuming, setResuming] = useState(false);
const [input, setInput] = useState("");
const [running, setRunning] = useState(false);
const [embedded, setEmbedded] = useState(false);
const threadRef = useRef<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const parentOriginRef = useRef<string>("");
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
setKey(sp.get("key"));
setSessionToken(sp.get("session_token"));
}, []);
// Launcher handshake (only when embedded in a parent frame). The launcher passes its origin
// via ?host=… (the trusted source); we fall back to the referrer origin. We NEVER trust "*":
// without a validated host origin we post nothing on the channel and hide the close button.
// Escape is handled HERE (inside the iframe) because a host-page key handler can't see
// keystrokes once focus is in this cross-origin frame.
useEffect(() => {
if (window.parent === window) return;
const sp = new URLSearchParams(window.location.search);
let po = sp.get("host") || "";
if (!po) { try { po = document.referrer ? new URL(document.referrer).origin : ""; } catch { po = ""; } }
try { po = po ? new URL(po).origin : ""; } catch { po = ""; }
parentOriginRef.current = po;
setEmbedded(!!po);
if (po) { try { window.parent.postMessage({ type: "forge:ready" }, po); } catch { /* ignore */ } }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") closeWidget(); }
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (!key) return;
fetch(EMBED(key, "/config"))
.then((r) => { if (!r.ok) throw new Error("This chat is unavailable."); return r.json(); })
.then(setCfg)
.catch((e) => setErr(String(e.message || e)));
fetch(EMBED(key, "/components"))
.then((r) => (r.ok ? r.json() : []))
.then((cs: any[]) => setCompDefs(Object.fromEntries((cs || []).map((c) => [c.id, c]))))
.catch(() => {});
}, [key]);
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, liveParts, pendingInterrupt]);
function closeWidget() {
const po = parentOriginRef.current;
if (!po) return; // only post to a validated host origin, never "*"
try { window.parent.postMessage({ type: "forge:close" }, po); } catch { /* ignore */ }
}
async function send(text: string) {
const q = text.trim();
if (!q || !key || !cfg || running) return;
setInput("");
setMsgs((m) => [...m, { role: "user", content: q }]);
setRunning(true);
setLiveParts([]);
let finalAnswer = "";
let interrupted = false;
// Components are positioned by the [[forge:component:ID]] markers the agent writes into its
// reply (not by frame-arrival order), so a widget lands in its natural place, not at the top.
const acc = new ReplyAccumulator();
try {
const runRes = await fetch(EMBED(key, "/runs"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input: { messages: [{ role: "user", content: q }] },
thread_id: threadRef.current || undefined,
session_token: sessionToken || undefined,
}),
});
if (!runRes.ok) throw new Error("Could not start the chat.");
const run = await runRes.json();
threadRef.current = run.thread_id;
await openSSE(EMBED(key, `/runs/${run.id}/stream`), (f) => {
if (f.event === "messages" && f.data?.content) { acc.addText(f.data.content); setLiveParts(acc.parts({ streaming: true })); }
else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) { acc.addComponent(f.data.payload); setLiveParts(acc.parts({ streaming: true })); }
else if (f.event === "interrupt") { interrupted = true; setPendingInterrupt({ runId: run.id, payload: f.data }); }
else if (f.event === "done") { finalAnswer = f.data?.answer || ""; }
else if (f.event === "error") { finalAnswer = `⚠ ${f.data?.message || "error"}`; }
});
// Paused for approval: commit whatever streamed before the pause, then hand off to the card.
if (interrupted) {
if (acc.hasComponents() || acc.text.trim()) {
setMsgs((m) => [...m, acc.hasComponents()
? { role: "assistant", parts: acc.parts() }
: { role: "assistant", content: acc.text }]);
}
return; // approval card takes over; `finally` resets running/liveParts
}
} catch (e: any) {
finalAnswer = `⚠ ${e.message || e}`;
} finally {
setRunning(false);
setLiveParts([]);
}
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)" }]);
}
}
// Submit an approval decision and render the resumed reply (single JSON response, like the
// Playground). The resume value encoding depends on the interrupt kind (middleware vs human_input).
async function resume(decision: string) {
if (!pendingInterrupt || !key || resuming) return;
const { middleware } = parseInterrupt(pendingInterrupt.payload);
setResuming(true);
try {
const value = middleware ? { decisions: [{ type: decision }] } : decision;
const res = await fetch(EMBED(key, `/runs/${pendingInterrupt.runId}/resume`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
const data = await res.json().catch(() => ({} as any));
if (!res.ok || data.error) throw new Error(data.error || `resume failed (${res.status})`);
const out: any[] = data.messages || [];
const last = [...out].reverse().find((m) => (m.type === "ai" || m.role === "assistant") && m.content);
const content = last ? (typeof last.content === "string" ? last.content : JSON.stringify(last.content)) : "(resumed)";
setMsgs((m) => [...m, { role: "assistant", content: data.interrupted ? content + "\n\n⏸ This needs another approval step." : content }]);
} catch (e: any) {
setMsgs((m) => [...m, { role: "assistant", content: `⚠ resume failed: ${e.message || e}` }]);
} finally {
setPendingInterrupt(null);
setResuming(false);
}
}
function onAction(inst: any, action: string, fields: Record<string, string>) {
const def = (inst.actions || []).find((a: any) => a.id === action) || {};
let msg = def.message || def.label || action;
try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {}
if (msg) send(msg);
}
if (err) return <div style={{ padding: 24, color: "var(--fg-2)", fontFamily: "var(--font-ui)" }}>{err}</div>;
return (
<div className="col" style={{ height: "100vh", background: "var(--bg-1)" }}>
<div className="row" style={{ padding: "12px 16px", borderBottom: "1px solid var(--line)", alignItems: "center", justifyContent: "space-between", flex: "none" }}>
<span style={{ fontFamily: "var(--font-display)", fontWeight: 600, fontSize: 15, color: "var(--fg-0)" }}>{cfg?.name || "Chat"}</span>
{embedded && (
<button aria-label="Close chat" className="btn btn-ghost btn-sm" onClick={closeWidget} style={{ padding: 4 }}>
<Icon name="x" size={16} />
</button>
)}
</div>
<div ref={scrollRef} className="scroll-y col gap4" style={{ flex: 1, minHeight: 0, padding: 16 }}>
{msgs.length === 0 && !running && <div className="fg-2" style={{ fontSize: 13 }}>Ask a question to get started.</div>}
{msgs.map((m, i) => <EmbedMsg key={i} m={m} compDefs={compDefs} onAction={onAction} />)}
{(running || liveParts.length > 0) && <EmbedMsg m={{ role: "assistant", parts: liveParts }} streaming compDefs={compDefs} onAction={onAction} />}
{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={{ alignItems: "center", marginBottom: 8 }}>
<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={{ whiteSpace: "pre-wrap", marginBottom: 10 }}>{info.prompt}</div>
<div className="row gap2" style={{ flexWrap: "wrap" }}>
{info.decisions.map((d) => (
<button key={d} className={"btn btn-sm " + (d === "approve" ? "btn-primary" : "btn-secondary")} onClick={() => resume(d)} disabled={resuming}>
{resuming ? "…" : d}
</button>
))}
</div>
</div>
);
})()}
</div>
<div style={{ padding: 12, borderTop: "1px solid var(--line)", flex: "none" }}>
<div className="row gap2" style={{ background: "var(--bg-3)", border: "1px solid var(--line)", borderRadius: 10, padding: "6px 6px 6px 12px" }}>
<input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send(input)} placeholder="Message…" disabled={!cfg || running}
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 14, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
<button className="btn btn-primary btn-sm" onClick={() => send(input)} disabled={!cfg || running || !input.trim()}>Send</button>
</div>
</div>
</div>
);
}
function EmbedMsg({ m, streaming, compDefs, onAction }: { m: Msg; streaming?: boolean; compDefs: Record<string, any>; onAction: (inst: any, a: string, f: Record<string, string>) => void }) {
if (m.role === "user") {
return (
<div className="row" style={{ flexDirection: "row-reverse" }}>
<div style={{ maxWidth: "85%", padding: "9px 12px", borderRadius: 12, borderTopRightRadius: 3, fontSize: 14, lineHeight: "21px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", background: "var(--accent)", color: "var(--fg-on-accent)" }}>{m.content}</div>
</div>
);
}
const list: Part[] = m.parts && m.parts.length ? m.parts : (m.content && m.content.trim() ? [{ kind: "text", text: m.content }] : []);
let lastText = -1;
for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } }
return (
<div className="col gap2" style={{ minWidth: 0 }}>
{list.map((p, j) => {
if (p.kind === "text") {
return (
<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)", animation: "blink 1s steps(1) infinite" }} />}
</div>
);
}
const inst = (p as any).inst;
const def = compDefs[inst.component_id];
if (!def) return <div key={j} className="card" style={{ padding: "8px 11px", fontSize: 12.5, color: "var(--fg-2)" }}>Component unavailable.</div>;
return <ComponentRenderer key={j} 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)} />;
})}
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 14 }}></span>}
</div>
);
}